|
@@ -11,6 +11,7 @@ const CONTINUATION_PROMPT =
|
|
|
// Suppress window after user abort (Esc/Ctrl+C) to avoid immediately
|
|
// Suppress window after user abort (Esc/Ctrl+C) to avoid immediately
|
|
|
// re-continuing something the user explicitly stopped
|
|
// re-continuing something the user explicitly stopped
|
|
|
const SUPPRESS_AFTER_ABORT_MS = 5_000;
|
|
const SUPPRESS_AFTER_ABORT_MS = 5_000;
|
|
|
|
|
+const NOTIFICATION_BUSY_GRACE_MS = 250;
|
|
|
|
|
|
|
|
const QUESTION_PHRASES = [
|
|
const QUESTION_PHRASES = [
|
|
|
'would you like',
|
|
'would you like',
|
|
@@ -33,11 +34,18 @@ interface ContinuationState {
|
|
|
enabled: boolean;
|
|
enabled: boolean;
|
|
|
consecutiveContinuations: number;
|
|
consecutiveContinuations: number;
|
|
|
pendingTimer: ReturnType<typeof setTimeout> | null;
|
|
pendingTimer: ReturnType<typeof setTimeout> | null;
|
|
|
|
|
+ pendingTimerSessionId: string | null;
|
|
|
suppressUntil: number;
|
|
suppressUntil: number;
|
|
|
- orchestratorSessionId: string | null;
|
|
|
|
|
|
|
+ orchestratorSessionIds: Set<string>;
|
|
|
|
|
+ sawChatMessage: boolean;
|
|
|
// True while our auto-injection prompt is in flight — prevents counter reset
|
|
// True while our auto-injection prompt is in flight — prevents counter reset
|
|
|
// on session.status→busy and blocks duplicate injections
|
|
// on session.status→busy and blocks duplicate injections
|
|
|
isAutoInjecting: boolean;
|
|
isAutoInjecting: boolean;
|
|
|
|
|
+ // session IDs with an in-flight noReply countdown notification.
|
|
|
|
|
+ notifyingSessionIds: Set<string>;
|
|
|
|
|
+ // sessionID → timestamp until which just-completed noReply countdown
|
|
|
|
|
+ // notification busy transitions are ignored, covering HTTP/SSE reordering.
|
|
|
|
|
+ notificationBusyUntilBySession: Map<string, number>;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function isQuestion(text: string): boolean {
|
|
function isQuestion(text: string): boolean {
|
|
@@ -77,6 +85,7 @@ function cancelPendingTimer(state: ContinuationState): void {
|
|
|
clearTimeout(state.pendingTimer);
|
|
clearTimeout(state.pendingTimer);
|
|
|
state.pendingTimer = null;
|
|
state.pendingTimer = null;
|
|
|
}
|
|
}
|
|
|
|
|
+ state.pendingTimerSessionId = null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function resetState(state: ContinuationState): void {
|
|
function resetState(state: ContinuationState): void {
|
|
@@ -84,6 +93,8 @@ function resetState(state: ContinuationState): void {
|
|
|
state.consecutiveContinuations = 0;
|
|
state.consecutiveContinuations = 0;
|
|
|
state.suppressUntil = 0;
|
|
state.suppressUntil = 0;
|
|
|
state.isAutoInjecting = false;
|
|
state.isAutoInjecting = false;
|
|
|
|
|
+ state.notifyingSessionIds.clear();
|
|
|
|
|
+ state.notificationBusyUntilBySession.clear();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export function createTodoContinuationHook(
|
|
export function createTodoContinuationHook(
|
|
@@ -99,6 +110,7 @@ export function createTodoContinuationHook(
|
|
|
handleEvent: (input: {
|
|
handleEvent: (input: {
|
|
|
event: { type: string; properties?: Record<string, unknown> };
|
|
event: { type: string; properties?: Record<string, unknown> };
|
|
|
}) => Promise<void>;
|
|
}) => Promise<void>;
|
|
|
|
|
+ handleChatMessage: (input: { sessionID: string; agent?: string }) => void;
|
|
|
handleCommandExecuteBefore: (
|
|
handleCommandExecuteBefore: (
|
|
|
input: {
|
|
input: {
|
|
|
command: string;
|
|
command: string;
|
|
@@ -117,11 +129,67 @@ export function createTodoContinuationHook(
|
|
|
enabled: false,
|
|
enabled: false,
|
|
|
consecutiveContinuations: 0,
|
|
consecutiveContinuations: 0,
|
|
|
pendingTimer: null,
|
|
pendingTimer: null,
|
|
|
|
|
+ pendingTimerSessionId: null,
|
|
|
suppressUntil: 0,
|
|
suppressUntil: 0,
|
|
|
- orchestratorSessionId: null,
|
|
|
|
|
|
|
+ orchestratorSessionIds: new Set<string>(),
|
|
|
|
|
+ sawChatMessage: false,
|
|
|
isAutoInjecting: false,
|
|
isAutoInjecting: false,
|
|
|
|
|
+ notifyingSessionIds: new Set<string>(),
|
|
|
|
|
+ notificationBusyUntilBySession: new Map<string, number>(),
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
|
|
+ function markNotificationStarted(sessionID: string): void {
|
|
|
|
|
+ state.notifyingSessionIds.add(sessionID);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function markNotificationFinished(sessionID: string): void {
|
|
|
|
|
+ state.notifyingSessionIds.delete(sessionID);
|
|
|
|
|
+ state.notificationBusyUntilBySession.set(
|
|
|
|
|
+ sessionID,
|
|
|
|
|
+ Date.now() + NOTIFICATION_BUSY_GRACE_MS,
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function clearNotificationState(sessionID: string): void {
|
|
|
|
|
+ state.notifyingSessionIds.delete(sessionID);
|
|
|
|
|
+ state.notificationBusyUntilBySession.delete(sessionID);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function isNotificationBusy(sessionID: string): boolean {
|
|
|
|
|
+ if (state.notifyingSessionIds.has(sessionID)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const until = state.notificationBusyUntilBySession.get(sessionID) ?? 0;
|
|
|
|
|
+ if (until <= Date.now()) {
|
|
|
|
|
+ state.notificationBusyUntilBySession.delete(sessionID);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function isOrchestratorSession(sessionID: string): boolean {
|
|
|
|
|
+ return state.orchestratorSessionIds.has(sessionID);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function registerOrchestratorSession(sessionID: string): void {
|
|
|
|
|
+ state.orchestratorSessionIds.add(sessionID);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleChatMessage(input: {
|
|
|
|
|
+ sessionID: string;
|
|
|
|
|
+ agent?: string;
|
|
|
|
|
+ }): void {
|
|
|
|
|
+ if (!input.agent) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ state.sawChatMessage = true;
|
|
|
|
|
+ if (input.agent === 'orchestrator') {
|
|
|
|
|
+ registerOrchestratorSession(input.sessionID);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const autoContinue = tool({
|
|
const autoContinue = tool({
|
|
|
description:
|
|
description:
|
|
|
'Toggle auto-continuation for incomplete todos. When enabled, the orchestrator will automatically continue working through its todo list when it stops with incomplete items.',
|
|
'Toggle auto-continuation for incomplete todos. When enabled, the orchestrator will automatically continue working through its todo list when it stops with incomplete items.',
|
|
@@ -150,7 +218,11 @@ export function createTodoContinuationHook(
|
|
|
const { event } = input;
|
|
const { event } = input;
|
|
|
const properties = event.properties ?? {};
|
|
const properties = event.properties ?? {};
|
|
|
|
|
|
|
|
- if (event.type === 'session.idle') {
|
|
|
|
|
|
|
+ if (
|
|
|
|
|
+ event.type === 'session.idle' ||
|
|
|
|
|
+ (event.type === 'session.status' &&
|
|
|
|
|
+ (properties.status as { type?: string } | undefined)?.type === 'idle')
|
|
|
|
|
+ ) {
|
|
|
const sessionID = properties.sessionID as string;
|
|
const sessionID = properties.sessionID as string;
|
|
|
if (!sessionID) {
|
|
if (!sessionID) {
|
|
|
return;
|
|
return;
|
|
@@ -158,17 +230,17 @@ export function createTodoContinuationHook(
|
|
|
|
|
|
|
|
log(`[${HOOK_NAME}] Session idle`, { sessionID });
|
|
log(`[${HOOK_NAME}] Session idle`, { sessionID });
|
|
|
|
|
|
|
|
- // Track orchestrator session (assumes orchestrator is the first
|
|
|
|
|
- // session to go idle — correct for single-session main chat)
|
|
|
|
|
- if (!state.orchestratorSessionId) {
|
|
|
|
|
- state.orchestratorSessionId = sessionID;
|
|
|
|
|
|
|
+ // Backward compatibility: if no chat.message has identified the
|
|
|
|
|
+ // orchestrator yet, fall back to the first idle session.
|
|
|
|
|
+ if (!state.sawChatMessage && state.orchestratorSessionIds.size === 0) {
|
|
|
|
|
+ registerOrchestratorSession(sessionID);
|
|
|
log(`[${HOOK_NAME}] Tracked orchestrator session`, {
|
|
log(`[${HOOK_NAME}] Tracked orchestrator session`, {
|
|
|
sessionID,
|
|
sessionID,
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Gate: session is orchestrator (needed before auto-enable check)
|
|
// Gate: session is orchestrator (needed before auto-enable check)
|
|
|
- if (state.orchestratorSessionId !== sessionID) {
|
|
|
|
|
|
|
+ if (!isOrchestratorSession(sessionID)) {
|
|
|
log(`[${HOOK_NAME}] Skipped: not orchestrator session`, {
|
|
log(`[${HOOK_NAME}] Skipped: not orchestrator session`, {
|
|
|
sessionID,
|
|
sessionID,
|
|
|
});
|
|
});
|
|
@@ -320,6 +392,7 @@ export function createTodoContinuationHook(
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
// Show countdown notification (noReply = agent doesn't respond)
|
|
// Show countdown notification (noReply = agent doesn't respond)
|
|
|
|
|
+ markNotificationStarted(sessionID);
|
|
|
ctx.client.session
|
|
ctx.client.session
|
|
|
.prompt({
|
|
.prompt({
|
|
|
path: { id: sessionID },
|
|
path: { id: sessionID },
|
|
@@ -339,10 +412,16 @@ export function createTodoContinuationHook(
|
|
|
})
|
|
})
|
|
|
.catch(() => {
|
|
.catch(() => {
|
|
|
/* best-effort notification */
|
|
/* best-effort notification */
|
|
|
|
|
+ })
|
|
|
|
|
+ .finally(() => {
|
|
|
|
|
+ markNotificationFinished(sessionID);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
+ state.pendingTimerSessionId = sessionID;
|
|
|
state.pendingTimer = setTimeout(async () => {
|
|
state.pendingTimer = setTimeout(async () => {
|
|
|
state.pendingTimer = null;
|
|
state.pendingTimer = null;
|
|
|
|
|
+ state.pendingTimerSessionId = null;
|
|
|
|
|
+ clearNotificationState(sessionID);
|
|
|
|
|
|
|
|
// Guard: may have been disabled during cooldown
|
|
// Guard: may have been disabled during cooldown
|
|
|
if (!state.enabled) {
|
|
if (!state.enabled) {
|
|
@@ -378,11 +457,16 @@ export function createTodoContinuationHook(
|
|
|
const status = properties.status as { type: string };
|
|
const status = properties.status as { type: string };
|
|
|
const sessionID = properties.sessionID as string;
|
|
const sessionID = properties.sessionID as string;
|
|
|
if (status?.type === 'busy') {
|
|
if (status?.type === 'busy') {
|
|
|
- const isOrchestrator = sessionID === state.orchestratorSessionId;
|
|
|
|
|
|
|
+ const isOrchestrator = isOrchestratorSession(sessionID);
|
|
|
|
|
+ const isNotification = isNotificationBusy(sessionID);
|
|
|
|
|
|
|
|
// Only cancel timer for orchestrator session — sub-agents going
|
|
// Only cancel timer for orchestrator session — sub-agents going
|
|
|
// busy must not silently kill the orchestrator's continuation.
|
|
// busy must not silently kill the orchestrator's continuation.
|
|
|
- if (isOrchestrator) {
|
|
|
|
|
|
|
+ if (
|
|
|
|
|
+ isOrchestrator &&
|
|
|
|
|
+ !isNotification &&
|
|
|
|
|
+ state.pendingTimerSessionId === sessionID
|
|
|
|
|
+ ) {
|
|
|
cancelPendingTimer(state);
|
|
cancelPendingTimer(state);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -390,6 +474,7 @@ export function createTodoContinuationHook(
|
|
|
// not for our own auto-injection prompt. Scope to orchestrator only.
|
|
// not for our own auto-injection prompt. Scope to orchestrator only.
|
|
|
if (
|
|
if (
|
|
|
!state.isAutoInjecting &&
|
|
!state.isAutoInjecting &&
|
|
|
|
|
+ !isNotification &&
|
|
|
isOrchestrator &&
|
|
isOrchestrator &&
|
|
|
state.consecutiveContinuations > 0
|
|
state.consecutiveContinuations > 0
|
|
|
) {
|
|
) {
|
|
@@ -403,7 +488,7 @@ export function createTodoContinuationHook(
|
|
|
const error = properties.error as { name?: string };
|
|
const error = properties.error as { name?: string };
|
|
|
const sessionID = properties.sessionID as string;
|
|
const sessionID = properties.sessionID as string;
|
|
|
const errorName = error?.name;
|
|
const errorName = error?.name;
|
|
|
- const isOrchestrator = sessionID === state.orchestratorSessionId;
|
|
|
|
|
|
|
+ const isOrchestrator = isOrchestratorSession(sessionID);
|
|
|
if (
|
|
if (
|
|
|
isOrchestrator &&
|
|
isOrchestrator &&
|
|
|
(errorName === 'MessageAbortedError' || errorName === 'AbortError')
|
|
(errorName === 'MessageAbortedError' || errorName === 'AbortError')
|
|
@@ -427,16 +512,20 @@ export function createTodoContinuationHook(
|
|
|
(properties.info as { id?: string })?.id ??
|
|
(properties.info as { id?: string })?.id ??
|
|
|
(properties.sessionID as string);
|
|
(properties.sessionID as string);
|
|
|
|
|
|
|
|
- // Only cancel timer if the orchestrator session itself was deleted.
|
|
|
|
|
- // Background sub-agent deletion must not kill the orchestrator's timer.
|
|
|
|
|
- if (state.orchestratorSessionId === deletedSessionId) {
|
|
|
|
|
- cancelPendingTimer(state);
|
|
|
|
|
- log(`[${HOOK_NAME}] Cancelled pending timer on orchestrator delete`, {
|
|
|
|
|
- sessionID: deletedSessionId,
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ if (deletedSessionId && isOrchestratorSession(deletedSessionId)) {
|
|
|
|
|
+ if (state.pendingTimerSessionId === deletedSessionId) {
|
|
|
|
|
+ cancelPendingTimer(state);
|
|
|
|
|
+ log(`[${HOOK_NAME}] Cancelled pending timer on orchestrator delete`, {
|
|
|
|
|
+ sessionID: deletedSessionId,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- resetState(state);
|
|
|
|
|
- state.orchestratorSessionId = null;
|
|
|
|
|
|
|
+ state.orchestratorSessionIds.delete(deletedSessionId);
|
|
|
|
|
+ clearNotificationState(deletedSessionId);
|
|
|
|
|
+ if (state.orchestratorSessionIds.size === 0) {
|
|
|
|
|
+ resetState(state);
|
|
|
|
|
+ state.sawChatMessage = false;
|
|
|
|
|
+ }
|
|
|
log(`[${HOOK_NAME}] Reset orchestrator session on delete`, {
|
|
log(`[${HOOK_NAME}] Reset orchestrator session on delete`, {
|
|
|
sessionID: deletedSessionId,
|
|
sessionID: deletedSessionId,
|
|
|
});
|
|
});
|
|
@@ -458,9 +547,7 @@ export function createTodoContinuationHook(
|
|
|
|
|
|
|
|
// Seed orchestrator session from slash command (more reliable than
|
|
// Seed orchestrator session from slash command (more reliable than
|
|
|
// first-idle heuristic — slash commands only fire in main chat)
|
|
// first-idle heuristic — slash commands only fire in main chat)
|
|
|
- if (!state.orchestratorSessionId) {
|
|
|
|
|
- state.orchestratorSessionId = input.sessionID;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ registerOrchestratorSession(input.sessionID);
|
|
|
|
|
|
|
|
// Clear template text — hook handles everything directly
|
|
// Clear template text — hook handles everything directly
|
|
|
output.parts.length = 0;
|
|
output.parts.length = 0;
|
|
@@ -533,6 +620,7 @@ export function createTodoContinuationHook(
|
|
|
return {
|
|
return {
|
|
|
tool: { auto_continue: autoContinue },
|
|
tool: { auto_continue: autoContinue },
|
|
|
handleEvent,
|
|
handleEvent,
|
|
|
|
|
+ handleChatMessage,
|
|
|
handleCommandExecuteBefore,
|
|
handleCommandExecuteBefore,
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|