feat(core): enhance hook system with PreCompress replacement and Idle event

PreCompress hooks can now return newHistory to replace built-in compression.
Add Idle hook event that fires after configurable inactivity period.

- PreCompress: accept history in hook input, return newHistory to skip
  built-in summarization (CompressionStatus.HOOK_REPLACED)
- Idle: new HookEventName with fireIdleEvent, auto-activates when
  extensions register Idle hooks (default 300s, configurable via
  hooksConfig.idleTimeout)
- Hook can return hookSpecificOutput.prompt to auto-submit a message
This commit is contained in:
Sandy Tao
2026-03-08 11:41:36 -07:00
parent d485e08606
commit 5570b1c046
10 changed files with 344 additions and 9 deletions

View File

@@ -2021,6 +2021,16 @@ const SETTINGS_SCHEMA = {
description: 'Show visual indicators when hooks are executing.',
showInDialog: true,
},
idleTimeout: {
type: 'number',
label: 'Idle Timeout',
category: 'Advanced',
requiresRestart: false,
default: 0,
description:
'Time in seconds before the Idle hook fires when there is no user input. Set to 0 to disable.',
showInDialog: true,
},
},
},
@@ -2165,6 +2175,18 @@ const SETTINGS_SCHEMA = {
ref: 'HookDefinitionArray',
mergeStrategy: MergeStrategy.CONCAT,
},
Idle: {
type: 'array',
label: 'Idle Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute after a period of inactivity. Can trigger maintenance tasks like memory consolidation.',
showInDialog: false,
ref: 'HookDefinitionArray',
mergeStrategy: MergeStrategy.CONCAT,
},
},
additionalProperties: {
type: 'array',

View File

@@ -1874,6 +1874,64 @@ export const useGeminiStream = (
storage,
]);
// Idle hook timer: fires after idleTimeout seconds of no activity.
// If idleTimeout is explicitly set, use it. Otherwise, if any Idle hooks
// are registered (e.g. by an extension), use a default of 300 seconds.
const DEFAULT_IDLE_TIMEOUT = 300;
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const configuredIdleTimeout = settings.merged.hooksConfig?.idleTimeout ?? 0;
useEffect(() => {
// Clear any existing timer
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
if (streamingState !== StreamingState.Idle || !config.getEnableHooks()) {
return;
}
// Compute effective timeout: use configured value, or default if
// Idle hooks are registered (e.g. by an extension).
let idleTimeoutSeconds = configuredIdleTimeout;
if (idleTimeoutSeconds <= 0) {
const hookSystem = config.getHookSystem();
const hasIdleHooks = hookSystem
?.getAllHooks()
.some((h) => h.eventName === 'Idle' && h.enabled);
idleTimeoutSeconds = hasIdleHooks ? DEFAULT_IDLE_TIMEOUT : 0;
}
if (idleTimeoutSeconds <= 0) {
return;
}
const startTime = Date.now();
idleTimerRef.current = setTimeout(async () => {
const hookSystem = config.getHookSystem();
if (!hookSystem) return;
const elapsed = Math.round((Date.now() - startTime) / 1000);
try {
const result = await hookSystem.fireIdleEvent(elapsed);
const prompt = result?.finalOutput?.hookSpecificOutput?.['prompt'];
if (typeof prompt === 'string' && prompt.trim()) {
// Auto-submit the prompt returned by the hook
void submitQuery(prompt);
}
} catch {
// Idle hook failures are non-fatal
}
}, idleTimeoutSeconds * 1000);
return () => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
};
}, [streamingState, configuredIdleTimeout, config, submitQuery]);
const lastOutputTime = Math.max(
lastToolOutputTime,
lastShellOutputTime,