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.
Files changed (302) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +452 -0
  3. package/dist/agents/descriptions.d.ts +6 -0
  4. package/dist/agents/designer.d.ts +2 -0
  5. package/dist/agents/explorer.d.ts +2 -0
  6. package/dist/agents/fixer.d.ts +2 -0
  7. package/dist/agents/index.d.ts +22 -0
  8. package/dist/agents/interpreter.d.ts +2 -0
  9. package/dist/agents/librarian.d.ts +2 -0
  10. package/dist/agents/oracle.d.ts +2 -0
  11. package/dist/agents/orchestrator.d.ts +27 -0
  12. package/dist/agents/overrides.d.ts +18 -0
  13. package/dist/agents/prompt-blocks.d.ts +97 -0
  14. package/dist/agents/steward.d.ts +3 -0
  15. package/dist/cli/config-io.d.ts +24 -0
  16. package/dist/cli/config-manager.d.ts +4 -0
  17. package/dist/cli/index.d.ts +2 -0
  18. package/dist/cli/index.js +1006 -0
  19. package/dist/cli/install.d.ts +2 -0
  20. package/dist/cli/mcps.d.ts +13 -0
  21. package/dist/cli/model-key-normalization.d.ts +1 -0
  22. package/dist/cli/paths.d.ts +35 -0
  23. package/dist/cli/providers.d.ts +137 -0
  24. package/dist/cli/skills.d.ts +22 -0
  25. package/dist/cli/system.d.ts +5 -0
  26. package/dist/cli/types.d.ts +38 -0
  27. package/dist/config/constants.d.ts +12 -0
  28. package/dist/config/index.d.ts +4 -0
  29. package/dist/config/loader.d.ts +40 -0
  30. package/dist/config/runtime-preset.d.ts +12 -0
  31. package/dist/config/schema.d.ts +281 -0
  32. package/dist/config/utils.d.ts +10 -0
  33. package/dist/discovery/local/types.d.ts +79 -0
  34. package/dist/discovery/local.d.ts +73 -0
  35. package/dist/discovery/mcp-servers.d.ts +88 -0
  36. package/dist/discovery/skills.d.ts +94 -0
  37. package/dist/hooks/apply-patch/codec.d.ts +7 -0
  38. package/dist/hooks/apply-patch/errors.d.ts +25 -0
  39. package/dist/hooks/apply-patch/execution-context.d.ts +27 -0
  40. package/dist/hooks/apply-patch/index.d.ts +15 -0
  41. package/dist/hooks/apply-patch/matching.d.ts +26 -0
  42. package/dist/hooks/apply-patch/operations.d.ts +3 -0
  43. package/dist/hooks/apply-patch/patch.d.ts +2 -0
  44. package/dist/hooks/apply-patch/prepared-changes.d.ts +17 -0
  45. package/dist/hooks/apply-patch/resolution.d.ts +19 -0
  46. package/dist/hooks/apply-patch/rewrite.d.ts +7 -0
  47. package/dist/hooks/apply-patch/test-helpers.d.ts +6 -0
  48. package/dist/hooks/apply-patch/types.d.ts +80 -0
  49. package/dist/hooks/auto-update-checker/cache.d.ts +11 -0
  50. package/dist/hooks/auto-update-checker/checker.d.ts +32 -0
  51. package/dist/hooks/auto-update-checker/constants.d.ts +11 -0
  52. package/dist/hooks/auto-update-checker/index.d.ts +18 -0
  53. package/dist/hooks/auto-update-checker/types.d.ts +22 -0
  54. package/dist/hooks/chat-headers.d.ts +16 -0
  55. package/dist/hooks/context-pressure-reminder/index.d.ts +33 -0
  56. package/dist/hooks/delegate-task-retry/guidance.d.ts +2 -0
  57. package/dist/hooks/delegate-task-retry/hook.d.ts +8 -0
  58. package/dist/hooks/delegate-task-retry/index.d.ts +4 -0
  59. package/dist/hooks/delegate-task-retry/patterns.d.ts +11 -0
  60. package/dist/hooks/filter-available-skills/index.d.ts +32 -0
  61. package/dist/hooks/foreground-fallback/index.d.ts +72 -0
  62. package/dist/hooks/image-hook.d.ts +5 -0
  63. package/dist/hooks/index.d.ts +14 -0
  64. package/dist/hooks/json-error-recovery/hook.d.ts +18 -0
  65. package/dist/hooks/json-error-recovery/index.d.ts +1 -0
  66. package/dist/hooks/phase-reminder/index.d.ts +26 -0
  67. package/dist/hooks/post-file-tool-nudge/index.d.ts +19 -0
  68. package/dist/hooks/task-session-manager/index.d.ts +52 -0
  69. package/dist/hooks/todo-continuation/index.d.ts +53 -0
  70. package/dist/hooks/todo-continuation/todo-hygiene.d.ts +35 -0
  71. package/dist/index.d.ts +5 -0
  72. package/dist/index.js +31782 -0
  73. package/dist/mcp/context7.d.ts +6 -0
  74. package/dist/mcp/grep-app.d.ts +6 -0
  75. package/dist/mcp/index.d.ts +13 -0
  76. package/dist/mcp/types.d.ts +12 -0
  77. package/dist/mcp/websearch.d.ts +9 -0
  78. package/dist/skills/registry.d.ts +29 -0
  79. package/dist/subscriptions/accounts-store.d.ts +57 -0
  80. package/dist/subscriptions/index.d.ts +13 -0
  81. package/dist/subscriptions/neuralwatt-scraper.d.ts +14 -0
  82. package/dist/subscriptions/opencode-go-scraper.d.ts +27 -0
  83. package/dist/subscriptions/types.d.ts +115 -0
  84. package/dist/subscriptions/usage-service.d.ts +74 -0
  85. package/dist/tools/ast-grep/cli.d.ts +15 -0
  86. package/dist/tools/ast-grep/constants.d.ts +25 -0
  87. package/dist/tools/ast-grep/downloader.d.ts +5 -0
  88. package/dist/tools/ast-grep/index.d.ts +10 -0
  89. package/dist/tools/ast-grep/tools.d.ts +3 -0
  90. package/dist/tools/ast-grep/types.d.ts +30 -0
  91. package/dist/tools/ast-grep/utils.d.ts +4 -0
  92. package/dist/tools/delegate.d.ts +14 -0
  93. package/dist/tools/index.d.ts +5 -0
  94. package/dist/tools/preset-manager.d.ts +27 -0
  95. package/dist/tools/smartfetch/binary.d.ts +3 -0
  96. package/dist/tools/smartfetch/cache.d.ts +6 -0
  97. package/dist/tools/smartfetch/constants.d.ts +12 -0
  98. package/dist/tools/smartfetch/index.d.ts +3 -0
  99. package/dist/tools/smartfetch/network.d.ts +38 -0
  100. package/dist/tools/smartfetch/secondary-model.d.ts +28 -0
  101. package/dist/tools/smartfetch/tool.d.ts +3 -0
  102. package/dist/tools/smartfetch/types.d.ts +122 -0
  103. package/dist/tools/smartfetch/utils.d.ts +18 -0
  104. package/dist/tui-state.d.ts +168 -0
  105. package/dist/tui.d.ts +37 -0
  106. package/dist/tui.js +1896 -0
  107. package/dist/utils/agent-variant.d.ts +63 -0
  108. package/dist/utils/compat.d.ts +30 -0
  109. package/dist/utils/env.d.ts +1 -0
  110. package/dist/utils/index.d.ts +9 -0
  111. package/dist/utils/internal-initiator.d.ts +6 -0
  112. package/dist/utils/logger.d.ts +8 -0
  113. package/dist/utils/polling.d.ts +21 -0
  114. package/dist/utils/session-manager.d.ts +55 -0
  115. package/dist/utils/session.d.ts +90 -0
  116. package/dist/utils/subagent-depth.d.ts +35 -0
  117. package/dist/utils/system-collapse.d.ts +6 -0
  118. package/dist/utils/task.d.ts +4 -0
  119. package/dist/utils/zip-extractor.d.ts +1 -0
  120. package/index.ts +1 -0
  121. package/opencode-dux.schema.json +634 -0
  122. package/package.json +103 -0
  123. package/src/agents/descriptions.ts +55 -0
  124. package/src/agents/designer.test.ts +86 -0
  125. package/src/agents/designer.ts +154 -0
  126. package/src/agents/display-name.test.ts +186 -0
  127. package/src/agents/explorer.test.ts +79 -0
  128. package/src/agents/explorer.ts +144 -0
  129. package/src/agents/fixer.test.ts +79 -0
  130. package/src/agents/fixer.ts +145 -0
  131. package/src/agents/index.test.ts +472 -0
  132. package/src/agents/index.ts +248 -0
  133. package/src/agents/interpreter.ts +136 -0
  134. package/src/agents/librarian.test.ts +80 -0
  135. package/src/agents/librarian.ts +145 -0
  136. package/src/agents/oracle.test.ts +89 -0
  137. package/src/agents/oracle.ts +184 -0
  138. package/src/agents/orchestrator.test.ts +116 -0
  139. package/src/agents/orchestrator.ts +574 -0
  140. package/src/agents/overrides.ts +95 -0
  141. package/src/agents/prompt-blocks.test.ts +114 -0
  142. package/src/agents/prompt-blocks.ts +640 -0
  143. package/src/agents/steward.ts +146 -0
  144. package/src/cli/config-io.test.ts +536 -0
  145. package/src/cli/config-io.ts +473 -0
  146. package/src/cli/config-manager.test.ts +141 -0
  147. package/src/cli/config-manager.ts +4 -0
  148. package/src/cli/index.ts +88 -0
  149. package/src/cli/install.ts +282 -0
  150. package/src/cli/mcps.test.ts +62 -0
  151. package/src/cli/mcps.ts +39 -0
  152. package/src/cli/model-key-normalization.test.ts +21 -0
  153. package/src/cli/model-key-normalization.ts +60 -0
  154. package/src/cli/paths.test.ts +167 -0
  155. package/src/cli/paths.ts +144 -0
  156. package/src/cli/providers.test.ts +118 -0
  157. package/src/cli/providers.ts +141 -0
  158. package/src/cli/skills.test.ts +111 -0
  159. package/src/cli/skills.ts +103 -0
  160. package/src/cli/system.test.ts +91 -0
  161. package/src/cli/system.ts +180 -0
  162. package/src/cli/types.ts +43 -0
  163. package/src/config/constants.ts +58 -0
  164. package/src/config/index.ts +4 -0
  165. package/src/config/loader.test.ts +1194 -0
  166. package/src/config/loader.ts +269 -0
  167. package/src/config/model-resolution.test.ts +176 -0
  168. package/src/config/runtime-preset.test.ts +61 -0
  169. package/src/config/runtime-preset.ts +37 -0
  170. package/src/config/schema.ts +248 -0
  171. package/src/config/utils.test.ts +41 -0
  172. package/src/config/utils.ts +23 -0
  173. package/src/discovery/local/types.ts +85 -0
  174. package/src/discovery/local.ts +322 -0
  175. package/src/discovery/mcp-servers.ts +804 -0
  176. package/src/discovery/skills.ts +959 -0
  177. package/src/hooks/apply-patch/codec.test.ts +184 -0
  178. package/src/hooks/apply-patch/codec.ts +352 -0
  179. package/src/hooks/apply-patch/errors.ts +117 -0
  180. package/src/hooks/apply-patch/execution-context.ts +432 -0
  181. package/src/hooks/apply-patch/hook.test.ts +768 -0
  182. package/src/hooks/apply-patch/index.ts +126 -0
  183. package/src/hooks/apply-patch/matching.test.ts +215 -0
  184. package/src/hooks/apply-patch/matching.ts +586 -0
  185. package/src/hooks/apply-patch/operations.test.ts +1535 -0
  186. package/src/hooks/apply-patch/operations.ts +3 -0
  187. package/src/hooks/apply-patch/patch.ts +9 -0
  188. package/src/hooks/apply-patch/prepared-changes.ts +400 -0
  189. package/src/hooks/apply-patch/resolution.test.ts +420 -0
  190. package/src/hooks/apply-patch/resolution.ts +437 -0
  191. package/src/hooks/apply-patch/rewrite.ts +496 -0
  192. package/src/hooks/apply-patch/test-helpers.ts +52 -0
  193. package/src/hooks/apply-patch/types.ts +111 -0
  194. package/src/hooks/auto-update-checker/cache.test.ts +179 -0
  195. package/src/hooks/auto-update-checker/cache.ts +188 -0
  196. package/src/hooks/auto-update-checker/checker.test.ts +159 -0
  197. package/src/hooks/auto-update-checker/checker.ts +308 -0
  198. package/src/hooks/auto-update-checker/constants.ts +33 -0
  199. package/src/hooks/auto-update-checker/index.test.ts +282 -0
  200. package/src/hooks/auto-update-checker/index.ts +225 -0
  201. package/src/hooks/auto-update-checker/types.ts +26 -0
  202. package/src/hooks/chat-headers.test.ts +236 -0
  203. package/src/hooks/chat-headers.ts +97 -0
  204. package/src/hooks/context-pressure-reminder/index.test.ts +179 -0
  205. package/src/hooks/context-pressure-reminder/index.ts +137 -0
  206. package/src/hooks/delegate-task-retry/guidance.ts +41 -0
  207. package/src/hooks/delegate-task-retry/hook.ts +23 -0
  208. package/src/hooks/delegate-task-retry/index.test.ts +38 -0
  209. package/src/hooks/delegate-task-retry/index.ts +7 -0
  210. package/src/hooks/delegate-task-retry/patterns.ts +79 -0
  211. package/src/hooks/filter-available-skills/index.test.ts +297 -0
  212. package/src/hooks/filter-available-skills/index.ts +160 -0
  213. package/src/hooks/foreground-fallback/index.test.ts +624 -0
  214. package/src/hooks/foreground-fallback/index.ts +374 -0
  215. package/src/hooks/image-hook.ts +6 -0
  216. package/src/hooks/index.ts +17 -0
  217. package/src/hooks/json-error-recovery/hook.ts +73 -0
  218. package/src/hooks/json-error-recovery/index.test.ts +111 -0
  219. package/src/hooks/json-error-recovery/index.ts +6 -0
  220. package/src/hooks/phase-reminder/index.test.ts +74 -0
  221. package/src/hooks/phase-reminder/index.ts +85 -0
  222. package/src/hooks/post-file-tool-nudge/index.test.ts +94 -0
  223. package/src/hooks/post-file-tool-nudge/index.ts +63 -0
  224. package/src/hooks/task-session-manager/index.test.ts +833 -0
  225. package/src/hooks/task-session-manager/index.ts +434 -0
  226. package/src/hooks/todo-continuation/index.test.ts +3026 -0
  227. package/src/hooks/todo-continuation/index.ts +878 -0
  228. package/src/hooks/todo-continuation/todo-hygiene.test.ts +204 -0
  229. package/src/hooks/todo-continuation/todo-hygiene.ts +207 -0
  230. package/src/index.ts +1672 -0
  231. package/src/mcp/context7.ts +14 -0
  232. package/src/mcp/grep-app.ts +11 -0
  233. package/src/mcp/index.test.ts +96 -0
  234. package/src/mcp/index.ts +66 -0
  235. package/src/mcp/types.ts +16 -0
  236. package/src/mcp/websearch.ts +47 -0
  237. package/src/skills/codemap/README.md +60 -0
  238. package/src/skills/codemap/SKILL.md +174 -0
  239. package/src/skills/codemap/scripts/codemap.mjs +483 -0
  240. package/src/skills/codemap/scripts/codemap.test.ts +129 -0
  241. package/src/skills/registry.ts +218 -0
  242. package/src/skills/simplify/README.md +19 -0
  243. package/src/skills/simplify/SKILL.md +138 -0
  244. package/src/subscriptions/accounts-store.test.ts +236 -0
  245. package/src/subscriptions/accounts-store.ts +184 -0
  246. package/src/subscriptions/index.ts +30 -0
  247. package/src/subscriptions/neuralwatt-scraper.ts +108 -0
  248. package/src/subscriptions/opencode-go-scraper.ts +301 -0
  249. package/src/subscriptions/types.ts +145 -0
  250. package/src/subscriptions/usage-service.test.ts +202 -0
  251. package/src/subscriptions/usage-service.ts +651 -0
  252. package/src/tools/ast-grep/cli.ts +257 -0
  253. package/src/tools/ast-grep/constants.ts +214 -0
  254. package/src/tools/ast-grep/downloader.ts +131 -0
  255. package/src/tools/ast-grep/index.ts +24 -0
  256. package/src/tools/ast-grep/tools.ts +117 -0
  257. package/src/tools/ast-grep/types.ts +51 -0
  258. package/src/tools/ast-grep/utils.ts +126 -0
  259. package/src/tools/delegate-handoff.test.ts +18 -0
  260. package/src/tools/delegate.ts +508 -0
  261. package/src/tools/index.ts +8 -0
  262. package/src/tools/preset-manager.test.ts +795 -0
  263. package/src/tools/preset-manager.ts +332 -0
  264. package/src/tools/smartfetch/binary.ts +58 -0
  265. package/src/tools/smartfetch/cache.test.ts +34 -0
  266. package/src/tools/smartfetch/cache.ts +112 -0
  267. package/src/tools/smartfetch/constants.ts +29 -0
  268. package/src/tools/smartfetch/index.ts +8 -0
  269. package/src/tools/smartfetch/network.test.ts +178 -0
  270. package/src/tools/smartfetch/network.ts +614 -0
  271. package/src/tools/smartfetch/secondary-model.test.ts +85 -0
  272. package/src/tools/smartfetch/secondary-model.ts +276 -0
  273. package/src/tools/smartfetch/tool.test.ts +60 -0
  274. package/src/tools/smartfetch/tool.ts +832 -0
  275. package/src/tools/smartfetch/types.ts +135 -0
  276. package/src/tools/smartfetch/utils.test.ts +24 -0
  277. package/src/tools/smartfetch/utils.ts +456 -0
  278. package/src/tui-state.test.ts +867 -0
  279. package/src/tui-state.ts +1255 -0
  280. package/src/tui.test.ts +336 -0
  281. package/src/tui.ts +1539 -0
  282. package/src/utils/agent-variant.test.ts +244 -0
  283. package/src/utils/agent-variant.ts +187 -0
  284. package/src/utils/compat.ts +91 -0
  285. package/src/utils/env.ts +12 -0
  286. package/src/utils/index.ts +9 -0
  287. package/src/utils/internal-initiator.ts +28 -0
  288. package/src/utils/logger.test.ts +220 -0
  289. package/src/utils/logger.ts +136 -0
  290. package/src/utils/polling.test.ts +191 -0
  291. package/src/utils/polling.ts +67 -0
  292. package/src/utils/session-manager.test.ts +173 -0
  293. package/src/utils/session-manager.ts +356 -0
  294. package/src/utils/session.test.ts +110 -0
  295. package/src/utils/session.ts +389 -0
  296. package/src/utils/subagent-depth.test.ts +170 -0
  297. package/src/utils/subagent-depth.ts +75 -0
  298. package/src/utils/system-collapse.test.ts +86 -0
  299. package/src/utils/system-collapse.ts +24 -0
  300. package/src/utils/task.test.ts +24 -0
  301. package/src/utils/task.ts +20 -0
  302. 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
+ }