opencode-dux 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +452 -0
- package/dist/agents/descriptions.d.ts +6 -0
- package/dist/agents/designer.d.ts +2 -0
- package/dist/agents/explorer.d.ts +2 -0
- package/dist/agents/fixer.d.ts +2 -0
- package/dist/agents/index.d.ts +22 -0
- package/dist/agents/interpreter.d.ts +2 -0
- package/dist/agents/librarian.d.ts +2 -0
- package/dist/agents/oracle.d.ts +2 -0
- package/dist/agents/orchestrator.d.ts +27 -0
- package/dist/agents/overrides.d.ts +18 -0
- package/dist/agents/prompt-blocks.d.ts +97 -0
- package/dist/agents/steward.d.ts +3 -0
- package/dist/cli/config-io.d.ts +24 -0
- package/dist/cli/config-manager.d.ts +4 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1006 -0
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/mcps.d.ts +13 -0
- package/dist/cli/model-key-normalization.d.ts +1 -0
- package/dist/cli/paths.d.ts +35 -0
- package/dist/cli/providers.d.ts +137 -0
- package/dist/cli/skills.d.ts +22 -0
- package/dist/cli/system.d.ts +5 -0
- package/dist/cli/types.d.ts +38 -0
- package/dist/config/constants.d.ts +12 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/loader.d.ts +40 -0
- package/dist/config/runtime-preset.d.ts +12 -0
- package/dist/config/schema.d.ts +281 -0
- package/dist/config/utils.d.ts +10 -0
- package/dist/discovery/local/types.d.ts +79 -0
- package/dist/discovery/local.d.ts +73 -0
- package/dist/discovery/mcp-servers.d.ts +88 -0
- package/dist/discovery/skills.d.ts +94 -0
- package/dist/hooks/apply-patch/codec.d.ts +7 -0
- package/dist/hooks/apply-patch/errors.d.ts +25 -0
- package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
- package/dist/hooks/apply-patch/index.d.ts +15 -0
- package/dist/hooks/apply-patch/matching.d.ts +26 -0
- package/dist/hooks/apply-patch/operations.d.ts +3 -0
- package/dist/hooks/apply-patch/patch.d.ts +2 -0
- package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
- package/dist/hooks/apply-patch/resolution.d.ts +19 -0
- package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
- package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
- package/dist/hooks/apply-patch/types.d.ts +80 -0
- package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
- package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
- package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
- package/dist/hooks/auto-update-checker/index.d.ts +18 -0
- package/dist/hooks/auto-update-checker/types.d.ts +22 -0
- package/dist/hooks/chat-headers.d.ts +16 -0
- package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
- package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
- package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
- package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
- package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
- package/dist/hooks/filter-available-skills/index.d.ts +32 -0
- package/dist/hooks/foreground-fallback/index.d.ts +72 -0
- package/dist/hooks/image-hook.d.ts +5 -0
- package/dist/hooks/index.d.ts +14 -0
- package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
- package/dist/hooks/json-error-recovery/index.d.ts +1 -0
- package/dist/hooks/phase-reminder/index.d.ts +26 -0
- package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
- package/dist/hooks/task-session-manager/index.d.ts +52 -0
- package/dist/hooks/todo-continuation/index.d.ts +53 -0
- package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +31782 -0
- package/dist/mcp/context7.d.ts +6 -0
- package/dist/mcp/grep-app.d.ts +6 -0
- package/dist/mcp/index.d.ts +13 -0
- package/dist/mcp/types.d.ts +12 -0
- package/dist/mcp/websearch.d.ts +9 -0
- package/dist/skills/registry.d.ts +29 -0
- package/dist/subscriptions/accounts-store.d.ts +57 -0
- package/dist/subscriptions/index.d.ts +13 -0
- package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
- package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
- package/dist/subscriptions/types.d.ts +115 -0
- package/dist/subscriptions/usage-service.d.ts +74 -0
- package/dist/tools/ast-grep/cli.d.ts +15 -0
- package/dist/tools/ast-grep/constants.d.ts +25 -0
- package/dist/tools/ast-grep/downloader.d.ts +5 -0
- package/dist/tools/ast-grep/index.d.ts +10 -0
- package/dist/tools/ast-grep/tools.d.ts +3 -0
- package/dist/tools/ast-grep/types.d.ts +30 -0
- package/dist/tools/ast-grep/utils.d.ts +4 -0
- package/dist/tools/delegate.d.ts +14 -0
- package/dist/tools/index.d.ts +5 -0
- package/dist/tools/preset-manager.d.ts +27 -0
- package/dist/tools/smartfetch/binary.d.ts +3 -0
- package/dist/tools/smartfetch/cache.d.ts +6 -0
- package/dist/tools/smartfetch/constants.d.ts +12 -0
- package/dist/tools/smartfetch/index.d.ts +3 -0
- package/dist/tools/smartfetch/network.d.ts +38 -0
- package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
- package/dist/tools/smartfetch/tool.d.ts +3 -0
- package/dist/tools/smartfetch/types.d.ts +122 -0
- package/dist/tools/smartfetch/utils.d.ts +18 -0
- package/dist/tui-state.d.ts +168 -0
- package/dist/tui.d.ts +37 -0
- package/dist/tui.js +1896 -0
- package/dist/utils/agent-variant.d.ts +63 -0
- package/dist/utils/compat.d.ts +30 -0
- package/dist/utils/env.d.ts +1 -0
- package/dist/utils/index.d.ts +9 -0
- package/dist/utils/internal-initiator.d.ts +6 -0
- package/dist/utils/logger.d.ts +8 -0
- package/dist/utils/polling.d.ts +21 -0
- package/dist/utils/session-manager.d.ts +55 -0
- package/dist/utils/session.d.ts +90 -0
- package/dist/utils/subagent-depth.d.ts +35 -0
- package/dist/utils/system-collapse.d.ts +6 -0
- package/dist/utils/task.d.ts +4 -0
- package/dist/utils/zip-extractor.d.ts +1 -0
- package/index.ts +1 -0
- package/opencode-dux.schema.json +634 -0
- package/package.json +103 -0
- package/src/agents/descriptions.ts +55 -0
- package/src/agents/designer.test.ts +86 -0
- package/src/agents/designer.ts +154 -0
- package/src/agents/display-name.test.ts +186 -0
- package/src/agents/explorer.test.ts +79 -0
- package/src/agents/explorer.ts +144 -0
- package/src/agents/fixer.test.ts +79 -0
- package/src/agents/fixer.ts +145 -0
- package/src/agents/index.test.ts +472 -0
- package/src/agents/index.ts +248 -0
- package/src/agents/interpreter.ts +136 -0
- package/src/agents/librarian.test.ts +80 -0
- package/src/agents/librarian.ts +145 -0
- package/src/agents/oracle.test.ts +89 -0
- package/src/agents/oracle.ts +184 -0
- package/src/agents/orchestrator.test.ts +116 -0
- package/src/agents/orchestrator.ts +574 -0
- package/src/agents/overrides.ts +95 -0
- package/src/agents/prompt-blocks.test.ts +114 -0
- package/src/agents/prompt-blocks.ts +640 -0
- package/src/agents/steward.ts +146 -0
- package/src/cli/config-io.test.ts +536 -0
- package/src/cli/config-io.ts +473 -0
- package/src/cli/config-manager.test.ts +141 -0
- package/src/cli/config-manager.ts +4 -0
- package/src/cli/index.ts +88 -0
- package/src/cli/install.ts +282 -0
- package/src/cli/mcps.test.ts +62 -0
- package/src/cli/mcps.ts +39 -0
- package/src/cli/model-key-normalization.test.ts +21 -0
- package/src/cli/model-key-normalization.ts +60 -0
- package/src/cli/paths.test.ts +167 -0
- package/src/cli/paths.ts +144 -0
- package/src/cli/providers.test.ts +118 -0
- package/src/cli/providers.ts +141 -0
- package/src/cli/skills.test.ts +111 -0
- package/src/cli/skills.ts +103 -0
- package/src/cli/system.test.ts +91 -0
- package/src/cli/system.ts +180 -0
- package/src/cli/types.ts +43 -0
- package/src/config/constants.ts +58 -0
- package/src/config/index.ts +4 -0
- package/src/config/loader.test.ts +1194 -0
- package/src/config/loader.ts +269 -0
- package/src/config/model-resolution.test.ts +176 -0
- package/src/config/runtime-preset.test.ts +61 -0
- package/src/config/runtime-preset.ts +37 -0
- package/src/config/schema.ts +248 -0
- package/src/config/utils.test.ts +41 -0
- package/src/config/utils.ts +23 -0
- package/src/discovery/local/types.ts +85 -0
- package/src/discovery/local.ts +322 -0
- package/src/discovery/mcp-servers.ts +804 -0
- package/src/discovery/skills.ts +959 -0
- package/src/hooks/apply-patch/codec.test.ts +184 -0
- package/src/hooks/apply-patch/codec.ts +352 -0
- package/src/hooks/apply-patch/errors.ts +117 -0
- package/src/hooks/apply-patch/execution-context.ts +432 -0
- package/src/hooks/apply-patch/hook.test.ts +768 -0
- package/src/hooks/apply-patch/index.ts +126 -0
- package/src/hooks/apply-patch/matching.test.ts +215 -0
- package/src/hooks/apply-patch/matching.ts +586 -0
- package/src/hooks/apply-patch/operations.test.ts +1535 -0
- package/src/hooks/apply-patch/operations.ts +3 -0
- package/src/hooks/apply-patch/patch.ts +9 -0
- package/src/hooks/apply-patch/prepared-changes.ts +400 -0
- package/src/hooks/apply-patch/resolution.test.ts +420 -0
- package/src/hooks/apply-patch/resolution.ts +437 -0
- package/src/hooks/apply-patch/rewrite.ts +496 -0
- package/src/hooks/apply-patch/test-helpers.ts +52 -0
- package/src/hooks/apply-patch/types.ts +111 -0
- package/src/hooks/auto-update-checker/cache.test.ts +179 -0
- package/src/hooks/auto-update-checker/cache.ts +188 -0
- package/src/hooks/auto-update-checker/checker.test.ts +159 -0
- package/src/hooks/auto-update-checker/checker.ts +308 -0
- package/src/hooks/auto-update-checker/constants.ts +33 -0
- package/src/hooks/auto-update-checker/index.test.ts +282 -0
- package/src/hooks/auto-update-checker/index.ts +225 -0
- package/src/hooks/auto-update-checker/types.ts +26 -0
- package/src/hooks/chat-headers.test.ts +236 -0
- package/src/hooks/chat-headers.ts +97 -0
- package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
- package/src/hooks/context-pressure-reminder/index.ts +137 -0
- package/src/hooks/delegate-task-retry/guidance.ts +41 -0
- package/src/hooks/delegate-task-retry/hook.ts +23 -0
- package/src/hooks/delegate-task-retry/index.test.ts +38 -0
- package/src/hooks/delegate-task-retry/index.ts +7 -0
- package/src/hooks/delegate-task-retry/patterns.ts +79 -0
- package/src/hooks/filter-available-skills/index.test.ts +297 -0
- package/src/hooks/filter-available-skills/index.ts +160 -0
- package/src/hooks/foreground-fallback/index.test.ts +624 -0
- package/src/hooks/foreground-fallback/index.ts +374 -0
- package/src/hooks/image-hook.ts +6 -0
- package/src/hooks/index.ts +17 -0
- package/src/hooks/json-error-recovery/hook.ts +73 -0
- package/src/hooks/json-error-recovery/index.test.ts +111 -0
- package/src/hooks/json-error-recovery/index.ts +6 -0
- package/src/hooks/phase-reminder/index.test.ts +74 -0
- package/src/hooks/phase-reminder/index.ts +85 -0
- package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
- package/src/hooks/post-file-tool-nudge/index.ts +63 -0
- package/src/hooks/task-session-manager/index.test.ts +833 -0
- package/src/hooks/task-session-manager/index.ts +434 -0
- package/src/hooks/todo-continuation/index.test.ts +3026 -0
- package/src/hooks/todo-continuation/index.ts +878 -0
- package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
- package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
- package/src/index.ts +1672 -0
- package/src/mcp/context7.ts +14 -0
- package/src/mcp/grep-app.ts +11 -0
- package/src/mcp/index.test.ts +96 -0
- package/src/mcp/index.ts +66 -0
- package/src/mcp/types.ts +16 -0
- package/src/mcp/websearch.ts +47 -0
- package/src/skills/codemap/README.md +60 -0
- package/src/skills/codemap/SKILL.md +174 -0
- package/src/skills/codemap/scripts/codemap.mjs +483 -0
- package/src/skills/codemap/scripts/codemap.test.ts +129 -0
- package/src/skills/registry.ts +218 -0
- package/src/skills/simplify/README.md +19 -0
- package/src/skills/simplify/SKILL.md +138 -0
- package/src/subscriptions/accounts-store.test.ts +236 -0
- package/src/subscriptions/accounts-store.ts +184 -0
- package/src/subscriptions/index.ts +30 -0
- package/src/subscriptions/neuralwatt-scraper.ts +108 -0
- package/src/subscriptions/opencode-go-scraper.ts +301 -0
- package/src/subscriptions/types.ts +145 -0
- package/src/subscriptions/usage-service.test.ts +202 -0
- package/src/subscriptions/usage-service.ts +651 -0
- package/src/tools/ast-grep/cli.ts +257 -0
- package/src/tools/ast-grep/constants.ts +214 -0
- package/src/tools/ast-grep/downloader.ts +131 -0
- package/src/tools/ast-grep/index.ts +24 -0
- package/src/tools/ast-grep/tools.ts +117 -0
- package/src/tools/ast-grep/types.ts +51 -0
- package/src/tools/ast-grep/utils.ts +126 -0
- package/src/tools/delegate-handoff.test.ts +18 -0
- package/src/tools/delegate.ts +508 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/preset-manager.test.ts +795 -0
- package/src/tools/preset-manager.ts +332 -0
- package/src/tools/smartfetch/binary.ts +58 -0
- package/src/tools/smartfetch/cache.test.ts +34 -0
- package/src/tools/smartfetch/cache.ts +112 -0
- package/src/tools/smartfetch/constants.ts +29 -0
- package/src/tools/smartfetch/index.ts +8 -0
- package/src/tools/smartfetch/network.test.ts +178 -0
- package/src/tools/smartfetch/network.ts +614 -0
- package/src/tools/smartfetch/secondary-model.test.ts +85 -0
- package/src/tools/smartfetch/secondary-model.ts +276 -0
- package/src/tools/smartfetch/tool.test.ts +60 -0
- package/src/tools/smartfetch/tool.ts +832 -0
- package/src/tools/smartfetch/types.ts +135 -0
- package/src/tools/smartfetch/utils.test.ts +24 -0
- package/src/tools/smartfetch/utils.ts +456 -0
- package/src/tui-state.test.ts +867 -0
- package/src/tui-state.ts +1255 -0
- package/src/tui.test.ts +336 -0
- package/src/tui.ts +1539 -0
- package/src/utils/agent-variant.test.ts +244 -0
- package/src/utils/agent-variant.ts +187 -0
- package/src/utils/compat.ts +91 -0
- package/src/utils/env.ts +12 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/internal-initiator.ts +28 -0
- package/src/utils/logger.test.ts +220 -0
- package/src/utils/logger.ts +136 -0
- package/src/utils/polling.test.ts +191 -0
- package/src/utils/polling.ts +67 -0
- package/src/utils/session-manager.test.ts +173 -0
- package/src/utils/session-manager.ts +356 -0
- package/src/utils/session.test.ts +110 -0
- package/src/utils/session.ts +389 -0
- package/src/utils/subagent-depth.test.ts +170 -0
- package/src/utils/subagent-depth.ts +75 -0
- package/src/utils/system-collapse.test.ts +86 -0
- package/src/utils/system-collapse.ts +24 -0
- package/src/utils/task.test.ts +24 -0
- package/src/utils/task.ts +20 -0
- package/src/utils/zip-extractor.ts +102 -0
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
import type { PluginInput } from '@opencode-ai/plugin';
|
|
2
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
3
|
+
import {
|
|
4
|
+
createInternalAgentTextPart,
|
|
5
|
+
log,
|
|
6
|
+
SLIM_INTERNAL_INITIATOR_MARKER,
|
|
7
|
+
} from '../../utils';
|
|
8
|
+
import { createTodoHygiene } from './todo-hygiene';
|
|
9
|
+
|
|
10
|
+
const HOOK_NAME = 'todo-continuation';
|
|
11
|
+
const COMMAND_NAME = 'auto-continue';
|
|
12
|
+
|
|
13
|
+
const CONTINUATION_PROMPT =
|
|
14
|
+
'[Auto-continue: enabled - there are incomplete todos remaining. Continue with the next uncompleted item. Press Esc to cancel. If you need user input or review for the next item, ask instead of proceeding.]';
|
|
15
|
+
const TODO_HYGIENE_INSTRUCTION_OPEN = '<instruction name="todo_hygiene">';
|
|
16
|
+
const TODO_HYGIENE_INSTRUCTION_CLOSE = '</instruction>';
|
|
17
|
+
|
|
18
|
+
// Suppress window after user abort (Esc/Ctrl+C) to avoid immediately
|
|
19
|
+
// re-continuing something the user explicitly stopped
|
|
20
|
+
const SUPPRESS_AFTER_ABORT_MS = 5_000;
|
|
21
|
+
const NOTIFICATION_BUSY_GRACE_MS = 250;
|
|
22
|
+
|
|
23
|
+
const QUESTION_PHRASES = [
|
|
24
|
+
'would you like',
|
|
25
|
+
'should i',
|
|
26
|
+
'do you want',
|
|
27
|
+
'please review',
|
|
28
|
+
'let me know',
|
|
29
|
+
'what do you think',
|
|
30
|
+
'can you confirm',
|
|
31
|
+
'would you prefer',
|
|
32
|
+
'shall i',
|
|
33
|
+
'any thoughts',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Statuses that indicate a todo is terminal (won't be worked on further).
|
|
37
|
+
// Uses denylist approach: any status not listed here is considered incomplete.
|
|
38
|
+
const TERMINAL_TODO_STATUSES = ['completed', 'cancelled'];
|
|
39
|
+
|
|
40
|
+
interface ContinuationState {
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
consecutiveContinuations: number;
|
|
43
|
+
pendingTimer: ReturnType<typeof setTimeout> | null;
|
|
44
|
+
pendingTimerSessionId: string | null;
|
|
45
|
+
suppressUntil: number;
|
|
46
|
+
orchestratorSessionIds: Set<string>;
|
|
47
|
+
sawChatMessage: boolean;
|
|
48
|
+
// True while our auto-injection prompt is in flight - prevents counter reset
|
|
49
|
+
// on session.status→busy and blocks duplicate injections
|
|
50
|
+
isAutoInjecting: boolean;
|
|
51
|
+
// session IDs with an in-flight noReply countdown notification.
|
|
52
|
+
notifyingSessionIds: Set<string>;
|
|
53
|
+
// sessionID → timestamp until which just-completed noReply countdown
|
|
54
|
+
// notification busy transitions are ignored, covering HTTP/SSE reordering.
|
|
55
|
+
notificationBusyUntilBySession: Map<string, number>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isQuestion(text: string): boolean {
|
|
59
|
+
const lowerText = text.toLowerCase().trim();
|
|
60
|
+
// Match trailing '?' with optional whitespace after it
|
|
61
|
+
if (/\?\s*$/.test(lowerText)) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
return QUESTION_PHRASES.some((phrase) => lowerText.includes(phrase));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface TodoItem {
|
|
68
|
+
id: string;
|
|
69
|
+
content: string;
|
|
70
|
+
status: string;
|
|
71
|
+
priority: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface MessageInfo {
|
|
75
|
+
role?: string;
|
|
76
|
+
[key: string]: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface MessagePart {
|
|
80
|
+
type?: string;
|
|
81
|
+
text?: string;
|
|
82
|
+
[key: string]: unknown;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ChatTransformMessage {
|
|
86
|
+
info: {
|
|
87
|
+
id?: string;
|
|
88
|
+
role?: string;
|
|
89
|
+
agent?: string;
|
|
90
|
+
sessionID?: string;
|
|
91
|
+
};
|
|
92
|
+
parts: MessagePart[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface LastExternalUserMessage {
|
|
96
|
+
sessionID?: string;
|
|
97
|
+
agent?: string;
|
|
98
|
+
signature: string;
|
|
99
|
+
message: ChatTransformMessage;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface Message {
|
|
103
|
+
info?: MessageInfo;
|
|
104
|
+
parts?: MessagePart[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function cancelPendingTimer(state: ContinuationState): void {
|
|
108
|
+
if (state.pendingTimer) {
|
|
109
|
+
clearTimeout(state.pendingTimer);
|
|
110
|
+
state.pendingTimer = null;
|
|
111
|
+
}
|
|
112
|
+
state.pendingTimerSessionId = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resetState(state: ContinuationState): void {
|
|
116
|
+
cancelPendingTimer(state);
|
|
117
|
+
state.consecutiveContinuations = 0;
|
|
118
|
+
state.suppressUntil = 0;
|
|
119
|
+
state.isAutoInjecting = false;
|
|
120
|
+
state.notifyingSessionIds.clear();
|
|
121
|
+
state.notificationBusyUntilBySession.clear();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function stripTodoHygieneInstruction(text: string): string {
|
|
125
|
+
const trimmed = text.trimEnd();
|
|
126
|
+
if (!trimmed.endsWith(TODO_HYGIENE_INSTRUCTION_CLOSE)) {
|
|
127
|
+
return trimmed;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const start = trimmed.lastIndexOf(TODO_HYGIENE_INSTRUCTION_OPEN);
|
|
131
|
+
if (start === -1) {
|
|
132
|
+
return trimmed;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return trimmed.slice(0, start).trimEnd();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function appendTodoHygieneInstruction(
|
|
139
|
+
message: ChatTransformMessage,
|
|
140
|
+
reminder: string,
|
|
141
|
+
): void {
|
|
142
|
+
const textPart = [...message.parts]
|
|
143
|
+
.reverse()
|
|
144
|
+
.find((part) => part.type === 'text' && typeof part.text === 'string');
|
|
145
|
+
if (!textPart) return;
|
|
146
|
+
|
|
147
|
+
const baseText = stripTodoHygieneInstruction(textPart.text ?? '');
|
|
148
|
+
const instruction = `${TODO_HYGIENE_INSTRUCTION_OPEN}\n${reminder}\n${TODO_HYGIENE_INSTRUCTION_CLOSE}`;
|
|
149
|
+
textPart.text = baseText ? `${baseText}\n\n${instruction}` : instruction;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function stripTodoHygieneInstructionFromMessage(
|
|
153
|
+
message: ChatTransformMessage,
|
|
154
|
+
): void {
|
|
155
|
+
const textPart = [...message.parts]
|
|
156
|
+
.reverse()
|
|
157
|
+
.find((part) => part.type === 'text' && typeof part.text === 'string');
|
|
158
|
+
if (!textPart) return;
|
|
159
|
+
|
|
160
|
+
textPart.text = stripTodoHygieneInstruction(textPart.text ?? '');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function createTodoContinuationHook(
|
|
164
|
+
ctx: PluginInput,
|
|
165
|
+
config?: {
|
|
166
|
+
maxContinuations?: number;
|
|
167
|
+
cooldownMs?: number;
|
|
168
|
+
autoEnable?: boolean;
|
|
169
|
+
autoEnableThreshold?: number;
|
|
170
|
+
},
|
|
171
|
+
): {
|
|
172
|
+
tool: Record<string, unknown>;
|
|
173
|
+
handleToolExecuteAfter: (
|
|
174
|
+
input: {
|
|
175
|
+
tool: string;
|
|
176
|
+
sessionID?: string;
|
|
177
|
+
},
|
|
178
|
+
output?: { output?: unknown },
|
|
179
|
+
) => Promise<void>;
|
|
180
|
+
handleMessagesTransform: (output: {
|
|
181
|
+
messages: ChatTransformMessage[];
|
|
182
|
+
}) => Promise<void>;
|
|
183
|
+
handleEvent: (input: {
|
|
184
|
+
event: { type: string; properties?: Record<string, unknown> };
|
|
185
|
+
}) => Promise<void>;
|
|
186
|
+
handleChatMessage: (input: { sessionID: string; agent?: string }) => void;
|
|
187
|
+
handleCommandExecuteBefore: (
|
|
188
|
+
input: {
|
|
189
|
+
command: string;
|
|
190
|
+
sessionID: string;
|
|
191
|
+
arguments: string;
|
|
192
|
+
},
|
|
193
|
+
output: { parts: Array<{ type: string; text?: string }> },
|
|
194
|
+
) => Promise<void>;
|
|
195
|
+
} {
|
|
196
|
+
const maxContinuations = config?.maxContinuations ?? 5;
|
|
197
|
+
const cooldownMs = config?.cooldownMs ?? 3000;
|
|
198
|
+
const autoEnable = config?.autoEnable ?? false;
|
|
199
|
+
const autoEnableThreshold = config?.autoEnableThreshold ?? 4;
|
|
200
|
+
const requestSignatureBySession = new Map<string, string>();
|
|
201
|
+
|
|
202
|
+
const state: ContinuationState = {
|
|
203
|
+
enabled: false,
|
|
204
|
+
consecutiveContinuations: 0,
|
|
205
|
+
pendingTimer: null,
|
|
206
|
+
pendingTimerSessionId: null,
|
|
207
|
+
suppressUntil: 0,
|
|
208
|
+
orchestratorSessionIds: new Set<string>(),
|
|
209
|
+
sawChatMessage: false,
|
|
210
|
+
isAutoInjecting: false,
|
|
211
|
+
notifyingSessionIds: new Set<string>(),
|
|
212
|
+
notificationBusyUntilBySession: new Map<string, number>(),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const hygiene = createTodoHygiene({
|
|
216
|
+
getTodoState: async (sessionID) => {
|
|
217
|
+
const result = await ctx.client.session.todo({
|
|
218
|
+
path: { id: sessionID },
|
|
219
|
+
});
|
|
220
|
+
const todos = result.data as TodoItem[];
|
|
221
|
+
const openTodos = todos.filter(
|
|
222
|
+
(todo) => !TERMINAL_TODO_STATUSES.includes(todo.status),
|
|
223
|
+
);
|
|
224
|
+
return {
|
|
225
|
+
hasOpenTodos: openTodos.length > 0,
|
|
226
|
+
openCount: openTodos.length,
|
|
227
|
+
inProgressCount: openTodos.filter(
|
|
228
|
+
(todo) => todo.status === 'in_progress',
|
|
229
|
+
).length,
|
|
230
|
+
pendingCount: openTodos.filter((todo) => todo.status === 'pending')
|
|
231
|
+
.length,
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
shouldInject: (sessionID) => isOrchestratorSession(sessionID),
|
|
235
|
+
log: (message, meta) => log(`[${HOOK_NAME}] ${message}`, meta),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
function inferSessionID(
|
|
239
|
+
messages: ChatTransformMessage[],
|
|
240
|
+
index: number,
|
|
241
|
+
): string | undefined {
|
|
242
|
+
const direct = messages[index]?.info.sessionID;
|
|
243
|
+
if (direct) {
|
|
244
|
+
return direct;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
248
|
+
const sessionID = messages[i]?.info.sessionID;
|
|
249
|
+
if (sessionID) {
|
|
250
|
+
return sessionID;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (let i = index + 1; i < messages.length; i++) {
|
|
255
|
+
const sessionID = messages[i]?.info.sessionID;
|
|
256
|
+
if (sessionID) {
|
|
257
|
+
return sessionID;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (state.orchestratorSessionIds.size === 1) {
|
|
262
|
+
return Array.from(state.orchestratorSessionIds)[0];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isExternalUserMessage(message: ChatTransformMessage): boolean {
|
|
269
|
+
if (message.info.role !== 'user') {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const visibleText = message.parts
|
|
274
|
+
.filter(
|
|
275
|
+
(part) =>
|
|
276
|
+
part.type === 'text' &&
|
|
277
|
+
typeof part.text === 'string' &&
|
|
278
|
+
!part.text.includes(SLIM_INTERNAL_INITIATOR_MARKER),
|
|
279
|
+
)
|
|
280
|
+
.map((part) => part.text?.trim() ?? '')
|
|
281
|
+
.filter(Boolean)
|
|
282
|
+
.join('\n');
|
|
283
|
+
const hasNonTextPart = message.parts.some((part) => part.type !== 'text');
|
|
284
|
+
|
|
285
|
+
return !(
|
|
286
|
+
!visibleText &&
|
|
287
|
+
!hasNonTextPart &&
|
|
288
|
+
message.parts.some(
|
|
289
|
+
(part) =>
|
|
290
|
+
part.type === 'text' &&
|
|
291
|
+
typeof part.text === 'string' &&
|
|
292
|
+
part.text.includes(SLIM_INTERNAL_INITIATOR_MARKER),
|
|
293
|
+
)
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function getLastExternalUserMessage(
|
|
298
|
+
messages: ChatTransformMessage[],
|
|
299
|
+
): LastExternalUserMessage | null {
|
|
300
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
301
|
+
const message = messages[i];
|
|
302
|
+
if (!isExternalUserMessage(message)) {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const sessionID = inferSessionID(messages, i);
|
|
307
|
+
|
|
308
|
+
const partSignature = message.parts
|
|
309
|
+
.map((part) => {
|
|
310
|
+
if (part.type === 'text' && typeof part.text === 'string') {
|
|
311
|
+
const text = stripTodoHygieneInstruction(part.text);
|
|
312
|
+
return `${part.type}:${text.includes(SLIM_INTERNAL_INITIATOR_MARKER) ? '<internal>' : text.trim()}`;
|
|
313
|
+
}
|
|
314
|
+
return part.type ?? 'unknown';
|
|
315
|
+
})
|
|
316
|
+
.join('|');
|
|
317
|
+
const ordinal = messages
|
|
318
|
+
.slice(0, i + 1)
|
|
319
|
+
.filter((item) => isExternalUserMessage(item)).length;
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
sessionID,
|
|
323
|
+
agent: message.info.agent,
|
|
324
|
+
message,
|
|
325
|
+
signature: message.info.id
|
|
326
|
+
? `${message.info.id}:${partSignature}`
|
|
327
|
+
: `${ordinal}:${partSignature}`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function handleMessagesTransform(output: {
|
|
335
|
+
messages: ChatTransformMessage[];
|
|
336
|
+
}): Promise<void> {
|
|
337
|
+
const lastUserMessage = getLastExternalUserMessage(output.messages);
|
|
338
|
+
if (!lastUserMessage) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (lastUserMessage.agent && lastUserMessage.agent !== 'orchestrator') {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!lastUserMessage.sessionID) {
|
|
347
|
+
for (const sessionID of state.orchestratorSessionIds) {
|
|
348
|
+
requestSignatureBySession.delete(sessionID);
|
|
349
|
+
hygiene.handleRequestStart({ sessionID });
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const knownOrchestrator = isOrchestratorSession(lastUserMessage.sessionID);
|
|
355
|
+
if (lastUserMessage.agent === 'orchestrator') {
|
|
356
|
+
registerOrchestratorSession(lastUserMessage.sessionID);
|
|
357
|
+
} else if (!knownOrchestrator) {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (
|
|
362
|
+
requestSignatureBySession.get(lastUserMessage.sessionID) ===
|
|
363
|
+
lastUserMessage.signature
|
|
364
|
+
) {
|
|
365
|
+
const reminder = hygiene.getPendingReminder(lastUserMessage.sessionID);
|
|
366
|
+
if (reminder) {
|
|
367
|
+
appendTodoHygieneInstruction(lastUserMessage.message, reminder);
|
|
368
|
+
} else {
|
|
369
|
+
stripTodoHygieneInstructionFromMessage(lastUserMessage.message);
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
requestSignatureBySession.set(
|
|
375
|
+
lastUserMessage.sessionID,
|
|
376
|
+
lastUserMessage.signature,
|
|
377
|
+
);
|
|
378
|
+
stripTodoHygieneInstructionFromMessage(lastUserMessage.message);
|
|
379
|
+
hygiene.handleRequestStart({ sessionID: lastUserMessage.sessionID });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function markNotificationStarted(sessionID: string): void {
|
|
383
|
+
state.notifyingSessionIds.add(sessionID);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function markNotificationFinished(sessionID: string): void {
|
|
387
|
+
state.notifyingSessionIds.delete(sessionID);
|
|
388
|
+
state.notificationBusyUntilBySession.set(
|
|
389
|
+
sessionID,
|
|
390
|
+
Date.now() + NOTIFICATION_BUSY_GRACE_MS,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function clearNotificationState(sessionID: string): void {
|
|
395
|
+
state.notifyingSessionIds.delete(sessionID);
|
|
396
|
+
state.notificationBusyUntilBySession.delete(sessionID);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function isNotificationBusy(sessionID: string): boolean {
|
|
400
|
+
if (state.notifyingSessionIds.has(sessionID)) {
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const until = state.notificationBusyUntilBySession.get(sessionID) ?? 0;
|
|
405
|
+
if (until <= Date.now()) {
|
|
406
|
+
state.notificationBusyUntilBySession.delete(sessionID);
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function isOrchestratorSession(sessionID: string): boolean {
|
|
413
|
+
return state.orchestratorSessionIds.has(sessionID);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function registerOrchestratorSession(sessionID: string): void {
|
|
417
|
+
state.orchestratorSessionIds.add(sessionID);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function handleChatMessage(input: {
|
|
421
|
+
sessionID: string;
|
|
422
|
+
agent?: string;
|
|
423
|
+
}): void {
|
|
424
|
+
if (!input.agent) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
state.sawChatMessage = true;
|
|
429
|
+
if (input.agent === 'orchestrator') {
|
|
430
|
+
registerOrchestratorSession(input.sessionID);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const autoContinue = tool({
|
|
435
|
+
description:
|
|
436
|
+
'Toggle auto-continuation for incomplete todos. When enabled, the orchestrator will automatically continue working through its todo list when it stops with incomplete items.',
|
|
437
|
+
args: { enabled: tool.schema.boolean() },
|
|
438
|
+
execute: async (args) => {
|
|
439
|
+
const enabled = args.enabled;
|
|
440
|
+
state.enabled = enabled;
|
|
441
|
+
state.consecutiveContinuations = 0;
|
|
442
|
+
|
|
443
|
+
if (enabled) {
|
|
444
|
+
state.suppressUntil = 0;
|
|
445
|
+
log(`[${HOOK_NAME}] Auto-continue enabled`, { maxContinuations });
|
|
446
|
+
return `Auto-continue enabled. Will auto-continue for up to ${maxContinuations} consecutive injections.`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Cancel any pending timer on disable
|
|
450
|
+
cancelPendingTimer(state);
|
|
451
|
+
log(`[${HOOK_NAME}] Auto-continue disabled`);
|
|
452
|
+
return 'Auto-continue disabled.';
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
async function handleEvent(input: {
|
|
457
|
+
event: { type: string; properties?: Record<string, unknown> };
|
|
458
|
+
}): Promise<void> {
|
|
459
|
+
const { event } = input;
|
|
460
|
+
const properties = event.properties ?? {};
|
|
461
|
+
|
|
462
|
+
hygiene.handleEvent({
|
|
463
|
+
type: event.type,
|
|
464
|
+
properties: {
|
|
465
|
+
info: properties.info as { id?: string } | undefined,
|
|
466
|
+
sessionID: properties.sessionID as string | undefined,
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
if (
|
|
471
|
+
event.type === 'session.idle' ||
|
|
472
|
+
(event.type === 'session.status' &&
|
|
473
|
+
(properties.status as { type?: string } | undefined)?.type === 'idle')
|
|
474
|
+
) {
|
|
475
|
+
const sessionID = properties.sessionID as string;
|
|
476
|
+
if (!sessionID) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
log(`[${HOOK_NAME}] Session idle`, { sessionID });
|
|
481
|
+
|
|
482
|
+
// Backward compatibility: if no chat.message has identified the
|
|
483
|
+
// orchestrator yet, fall back to the first idle session.
|
|
484
|
+
if (!state.sawChatMessage && state.orchestratorSessionIds.size === 0) {
|
|
485
|
+
registerOrchestratorSession(sessionID);
|
|
486
|
+
log(`[${HOOK_NAME}] Tracked orchestrator session`, {
|
|
487
|
+
sessionID,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Gate: session is orchestrator (needed before auto-enable check)
|
|
492
|
+
if (!isOrchestratorSession(sessionID)) {
|
|
493
|
+
log(`[${HOOK_NAME}] Skipped: not orchestrator session`, {
|
|
494
|
+
sessionID,
|
|
495
|
+
});
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Auto-enable check: if configured, not yet enabled, and enough
|
|
500
|
+
// todos exist, automatically enable auto-continue.
|
|
501
|
+
if (autoEnable && !state.enabled) {
|
|
502
|
+
try {
|
|
503
|
+
const todosResult = await ctx.client.session.todo({
|
|
504
|
+
path: { id: sessionID },
|
|
505
|
+
});
|
|
506
|
+
const todos = todosResult.data as TodoItem[];
|
|
507
|
+
const incompleteCount = todos.filter(
|
|
508
|
+
(t) => !TERMINAL_TODO_STATUSES.includes(t.status),
|
|
509
|
+
).length;
|
|
510
|
+
if (incompleteCount >= autoEnableThreshold) {
|
|
511
|
+
state.enabled = true;
|
|
512
|
+
state.consecutiveContinuations = 0;
|
|
513
|
+
state.suppressUntil = 0;
|
|
514
|
+
log(
|
|
515
|
+
`[${HOOK_NAME}] Auto-enabled: ${incompleteCount} incomplete todos >= threshold ${autoEnableThreshold}`,
|
|
516
|
+
{ sessionID },
|
|
517
|
+
);
|
|
518
|
+
} else {
|
|
519
|
+
log(
|
|
520
|
+
`[${HOOK_NAME}] Auto-enable skipped: ${incompleteCount} incomplete todos < threshold ${autoEnableThreshold}`,
|
|
521
|
+
{ sessionID },
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
} catch (error) {
|
|
525
|
+
log(
|
|
526
|
+
`[${HOOK_NAME}] Warning: failed to fetch todos for auto-enable check`,
|
|
527
|
+
{
|
|
528
|
+
sessionID,
|
|
529
|
+
error: error instanceof Error ? error.message : String(error),
|
|
530
|
+
},
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Safety gate 1: enabled
|
|
536
|
+
if (!state.enabled) {
|
|
537
|
+
log(`[${HOOK_NAME}] Skipped: auto-continue not enabled`, {
|
|
538
|
+
sessionID,
|
|
539
|
+
});
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Safety gate 2: incomplete todos exist
|
|
544
|
+
let hasIncompleteTodos = false;
|
|
545
|
+
let incompleteCount = 0;
|
|
546
|
+
try {
|
|
547
|
+
const todosResult = await ctx.client.session.todo({
|
|
548
|
+
path: { id: sessionID },
|
|
549
|
+
});
|
|
550
|
+
const todos = todosResult.data as TodoItem[];
|
|
551
|
+
incompleteCount = todos.filter(
|
|
552
|
+
(t) => !TERMINAL_TODO_STATUSES.includes(t.status),
|
|
553
|
+
).length;
|
|
554
|
+
hasIncompleteTodos = incompleteCount > 0;
|
|
555
|
+
log(`[${HOOK_NAME}] Fetched todos`, {
|
|
556
|
+
sessionID,
|
|
557
|
+
hasIncompleteTodos,
|
|
558
|
+
total: todos.length,
|
|
559
|
+
});
|
|
560
|
+
} catch (error) {
|
|
561
|
+
log(`[${HOOK_NAME}] Warning: failed to fetch todos`, {
|
|
562
|
+
sessionID,
|
|
563
|
+
error: error instanceof Error ? error.message : String(error),
|
|
564
|
+
});
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (!hasIncompleteTodos) {
|
|
569
|
+
log(`[${HOOK_NAME}] Skipped: no incomplete todos`, { sessionID });
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Safety gate 3: last assistant message is not a question
|
|
574
|
+
let lastAssistantIsQuestion = false;
|
|
575
|
+
try {
|
|
576
|
+
const messagesResult = await ctx.client.session.messages({
|
|
577
|
+
path: { id: sessionID },
|
|
578
|
+
});
|
|
579
|
+
const messages = messagesResult.data as Message[];
|
|
580
|
+
const lastAssistantMessage = messages
|
|
581
|
+
.slice()
|
|
582
|
+
.reverse()
|
|
583
|
+
.find((m) => m.info?.role === 'assistant');
|
|
584
|
+
if (lastAssistantMessage?.parts) {
|
|
585
|
+
const lastText = lastAssistantMessage.parts
|
|
586
|
+
.map((p) => p.text ?? '')
|
|
587
|
+
.join(' ');
|
|
588
|
+
lastAssistantIsQuestion = isQuestion(lastText);
|
|
589
|
+
}
|
|
590
|
+
log(`[${HOOK_NAME}] Fetched messages`, {
|
|
591
|
+
sessionID,
|
|
592
|
+
lastAssistantIsQuestion,
|
|
593
|
+
});
|
|
594
|
+
} catch (error) {
|
|
595
|
+
log(`[${HOOK_NAME}] Warning: failed to fetch messages`, {
|
|
596
|
+
sessionID,
|
|
597
|
+
error: error instanceof Error ? error.message : String(error),
|
|
598
|
+
});
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (lastAssistantIsQuestion) {
|
|
603
|
+
log(`[${HOOK_NAME}] Skipped: last message is question`, {
|
|
604
|
+
sessionID,
|
|
605
|
+
});
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Safety gate 4: below max continuations
|
|
610
|
+
if (state.consecutiveContinuations >= maxContinuations) {
|
|
611
|
+
log(`[${HOOK_NAME}] Skipped: max continuations reached`, {
|
|
612
|
+
sessionID,
|
|
613
|
+
consecutive: state.consecutiveContinuations,
|
|
614
|
+
max: maxContinuations,
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Safety gate 5: not in suppress window
|
|
620
|
+
const now = Date.now();
|
|
621
|
+
if (now < state.suppressUntil) {
|
|
622
|
+
log(`[${HOOK_NAME}] Skipped: in suppress window`, {
|
|
623
|
+
sessionID,
|
|
624
|
+
suppressUntil: state.suppressUntil,
|
|
625
|
+
});
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Safety gate 6: no pending timer AND no injection in flight
|
|
630
|
+
if (state.pendingTimer !== null || state.isAutoInjecting) {
|
|
631
|
+
log(`[${HOOK_NAME}] Skipped: timer pending or injection in flight`, {
|
|
632
|
+
sessionID,
|
|
633
|
+
});
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Schedule continuation
|
|
638
|
+
log(`[${HOOK_NAME}] Scheduling continuation`, {
|
|
639
|
+
sessionID,
|
|
640
|
+
delayMs: cooldownMs,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// Show countdown notification (noReply = agent doesn't respond)
|
|
644
|
+
markNotificationStarted(sessionID);
|
|
645
|
+
ctx.client.session
|
|
646
|
+
.prompt({
|
|
647
|
+
path: { id: sessionID },
|
|
648
|
+
body: {
|
|
649
|
+
noReply: true,
|
|
650
|
+
parts: [
|
|
651
|
+
{
|
|
652
|
+
type: 'text',
|
|
653
|
+
text: [
|
|
654
|
+
`⎔ Auto-continue: ${incompleteCount} incomplete todos remaining - resuming in ${cooldownMs / 1000}s - Esc×2 to cancel`,
|
|
655
|
+
'',
|
|
656
|
+
'[system status: continue without acknowledging this notification]',
|
|
657
|
+
].join('\n'),
|
|
658
|
+
},
|
|
659
|
+
],
|
|
660
|
+
},
|
|
661
|
+
})
|
|
662
|
+
.catch(() => {
|
|
663
|
+
/* best-effort notification */
|
|
664
|
+
})
|
|
665
|
+
.finally(() => {
|
|
666
|
+
markNotificationFinished(sessionID);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
state.pendingTimerSessionId = sessionID;
|
|
670
|
+
state.pendingTimer = setTimeout(async () => {
|
|
671
|
+
state.pendingTimer = null;
|
|
672
|
+
state.pendingTimerSessionId = null;
|
|
673
|
+
clearNotificationState(sessionID);
|
|
674
|
+
|
|
675
|
+
// Guard: may have been disabled during cooldown
|
|
676
|
+
if (!state.enabled) {
|
|
677
|
+
log(`[${HOOK_NAME}] Cancelled: disabled during cooldown`, {
|
|
678
|
+
sessionID,
|
|
679
|
+
});
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
state.isAutoInjecting = true;
|
|
684
|
+
try {
|
|
685
|
+
await ctx.client.session.prompt({
|
|
686
|
+
path: { id: sessionID },
|
|
687
|
+
body: {
|
|
688
|
+
parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)],
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
state.consecutiveContinuations++;
|
|
692
|
+
log(`[${HOOK_NAME}] Continuation injected`, {
|
|
693
|
+
sessionID,
|
|
694
|
+
consecutive: state.consecutiveContinuations,
|
|
695
|
+
});
|
|
696
|
+
} catch (error) {
|
|
697
|
+
log(`[${HOOK_NAME}] Error: failed to inject continuation`, {
|
|
698
|
+
sessionID,
|
|
699
|
+
error: error instanceof Error ? error.message : String(error),
|
|
700
|
+
});
|
|
701
|
+
} finally {
|
|
702
|
+
state.isAutoInjecting = false;
|
|
703
|
+
}
|
|
704
|
+
}, cooldownMs);
|
|
705
|
+
} else if (event.type === 'session.status') {
|
|
706
|
+
const status = properties.status as { type: string };
|
|
707
|
+
const sessionID = properties.sessionID as string;
|
|
708
|
+
if (status?.type === 'busy') {
|
|
709
|
+
const isOrchestrator = isOrchestratorSession(sessionID);
|
|
710
|
+
const isNotification = isNotificationBusy(sessionID);
|
|
711
|
+
|
|
712
|
+
// Only cancel timer for orchestrator session - sub-agents going
|
|
713
|
+
// busy must not silently kill the orchestrator's continuation.
|
|
714
|
+
if (
|
|
715
|
+
isOrchestrator &&
|
|
716
|
+
!isNotification &&
|
|
717
|
+
state.pendingTimerSessionId === sessionID
|
|
718
|
+
) {
|
|
719
|
+
cancelPendingTimer(state);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Only reset consecutive counter for user-initiated activity,
|
|
723
|
+
// not for our own auto-injection prompt. Scope to orchestrator only.
|
|
724
|
+
if (
|
|
725
|
+
!state.isAutoInjecting &&
|
|
726
|
+
!isNotification &&
|
|
727
|
+
isOrchestrator &&
|
|
728
|
+
state.consecutiveContinuations > 0
|
|
729
|
+
) {
|
|
730
|
+
state.consecutiveContinuations = 0;
|
|
731
|
+
log(`[${HOOK_NAME}] Reset consecutive count on user activity`, {
|
|
732
|
+
sessionID,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
} else if (event.type === 'session.error') {
|
|
737
|
+
const error = properties.error as { name?: string };
|
|
738
|
+
const sessionID = properties.sessionID as string;
|
|
739
|
+
const errorName = error?.name;
|
|
740
|
+
const isOrchestrator = isOrchestratorSession(sessionID);
|
|
741
|
+
if (
|
|
742
|
+
isOrchestrator &&
|
|
743
|
+
(errorName === 'MessageAbortedError' || errorName === 'AbortError')
|
|
744
|
+
) {
|
|
745
|
+
state.suppressUntil = Date.now() + SUPPRESS_AFTER_ABORT_MS;
|
|
746
|
+
log(`[${HOOK_NAME}] Suppressed continuation after abort`, {
|
|
747
|
+
sessionID,
|
|
748
|
+
errorName,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
if (isOrchestrator) {
|
|
752
|
+
cancelPendingTimer(state);
|
|
753
|
+
log(`[${HOOK_NAME}] Cancelled pending timer on error`, {
|
|
754
|
+
sessionID,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
} else if (event.type === 'session.deleted') {
|
|
758
|
+
// OpenCode sends sessionID in two shapes:
|
|
759
|
+
// properties.info.id (from session store) or properties.sessionID (from event)
|
|
760
|
+
const deletedSessionId =
|
|
761
|
+
(properties.info as { id?: string })?.id ??
|
|
762
|
+
(properties.sessionID as string);
|
|
763
|
+
|
|
764
|
+
if (deletedSessionId && isOrchestratorSession(deletedSessionId)) {
|
|
765
|
+
requestSignatureBySession.delete(deletedSessionId);
|
|
766
|
+
if (state.pendingTimerSessionId === deletedSessionId) {
|
|
767
|
+
cancelPendingTimer(state);
|
|
768
|
+
log(`[${HOOK_NAME}] Cancelled pending timer on orchestrator delete`, {
|
|
769
|
+
sessionID: deletedSessionId,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
state.orchestratorSessionIds.delete(deletedSessionId);
|
|
774
|
+
clearNotificationState(deletedSessionId);
|
|
775
|
+
if (state.orchestratorSessionIds.size === 0) {
|
|
776
|
+
resetState(state);
|
|
777
|
+
state.sawChatMessage = false;
|
|
778
|
+
}
|
|
779
|
+
log(`[${HOOK_NAME}] Reset orchestrator session on delete`, {
|
|
780
|
+
sessionID: deletedSessionId,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function handleCommandExecuteBefore(
|
|
787
|
+
input: {
|
|
788
|
+
command: string;
|
|
789
|
+
sessionID: string;
|
|
790
|
+
arguments: string;
|
|
791
|
+
},
|
|
792
|
+
output: { parts: Array<{ type: string; text?: string }> },
|
|
793
|
+
): Promise<void> {
|
|
794
|
+
if (input.command !== COMMAND_NAME) {
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Seed orchestrator session from slash command (more reliable than
|
|
799
|
+
// first-idle heuristic - slash commands only fire in main chat)
|
|
800
|
+
registerOrchestratorSession(input.sessionID);
|
|
801
|
+
|
|
802
|
+
// Clear template text - hook handles everything directly
|
|
803
|
+
output.parts.length = 0;
|
|
804
|
+
|
|
805
|
+
// Accept explicit on/off argument, toggle only when no arg
|
|
806
|
+
const arg = input.arguments.trim().toLowerCase();
|
|
807
|
+
let newEnabled: boolean;
|
|
808
|
+
if (arg === 'on') {
|
|
809
|
+
newEnabled = true;
|
|
810
|
+
} else if (arg === 'off') {
|
|
811
|
+
newEnabled = false;
|
|
812
|
+
} else {
|
|
813
|
+
newEnabled = !state.enabled;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
state.enabled = newEnabled;
|
|
817
|
+
state.consecutiveContinuations = 0;
|
|
818
|
+
|
|
819
|
+
if (!newEnabled) {
|
|
820
|
+
// Cancel any pending timer on disable
|
|
821
|
+
cancelPendingTimer(state);
|
|
822
|
+
output.parts.push(
|
|
823
|
+
createInternalAgentTextPart(
|
|
824
|
+
'[Auto-continue: disabled by user command.]',
|
|
825
|
+
),
|
|
826
|
+
);
|
|
827
|
+
log(`[${HOOK_NAME}] Disabled via /${COMMAND_NAME} command`);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Clear suppress window on explicit re-enable
|
|
832
|
+
state.suppressUntil = 0;
|
|
833
|
+
|
|
834
|
+
log(`[${HOOK_NAME}] Enabled via /${COMMAND_NAME} command`, {
|
|
835
|
+
maxContinuations,
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
// Check for incomplete todos to decide on immediate continuation
|
|
839
|
+
let hasIncompleteTodos = false;
|
|
840
|
+
try {
|
|
841
|
+
const todosResult = await ctx.client.session.todo({
|
|
842
|
+
path: { id: input.sessionID },
|
|
843
|
+
});
|
|
844
|
+
const todos = todosResult.data as TodoItem[];
|
|
845
|
+
hasIncompleteTodos = todos.some(
|
|
846
|
+
(t) => !TERMINAL_TODO_STATUSES.includes(t.status),
|
|
847
|
+
);
|
|
848
|
+
} catch (error) {
|
|
849
|
+
log(`[${HOOK_NAME}] Warning: failed to fetch todos in command hook`, {
|
|
850
|
+
sessionID: input.sessionID,
|
|
851
|
+
error: error instanceof Error ? error.message : String(error),
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
if (hasIncompleteTodos) {
|
|
856
|
+
output.parts.push(
|
|
857
|
+
createInternalAgentTextPart(
|
|
858
|
+
`${CONTINUATION_PROMPT} [Auto-continue enabled: up to ${maxContinuations} continuations.]`,
|
|
859
|
+
),
|
|
860
|
+
);
|
|
861
|
+
} else {
|
|
862
|
+
output.parts.push(
|
|
863
|
+
createInternalAgentTextPart(
|
|
864
|
+
`[Auto-continue: enabled for up to ${maxContinuations} continuations. No incomplete todos right now.]`,
|
|
865
|
+
),
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return {
|
|
871
|
+
tool: { auto_continue: autoContinue },
|
|
872
|
+
handleToolExecuteAfter: hygiene.handleToolExecuteAfter,
|
|
873
|
+
handleMessagesTransform,
|
|
874
|
+
handleEvent,
|
|
875
|
+
handleChatMessage,
|
|
876
|
+
handleCommandExecuteBefore,
|
|
877
|
+
};
|
|
878
|
+
}
|