@hiai-gg/hiai-opencode 0.1.1 → 0.1.3

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 (1226) hide show
  1. package/.env.example +68 -57
  2. package/AGENTS.md +383 -281
  3. package/ARCHITECTURE.md +280 -281
  4. package/LICENSE.md +59 -59
  5. package/README.md +421 -301
  6. package/assets/mcp/mempalace.mjs +196 -153
  7. package/assets/mcp/playwright.mjs +76 -0
  8. package/assets/mcp/rag.mjs +236 -236
  9. package/assets/runtime/npm-package-runner.mjs +54 -54
  10. package/config/hiai-opencode.schema.json +82 -82
  11. package/config/opencode.json +4 -4
  12. package/dist/config/defaults.d.ts +3 -0
  13. package/dist/config/platform-schema.d.ts +2 -0
  14. package/dist/index.js +462 -417
  15. package/dist/mcp/registry.d.ts +14 -0
  16. package/dist/mcp/types.d.ts +6 -0
  17. package/hiai-opencode.json +58 -58
  18. package/package.json +86 -91
  19. package/skills/api-and-interface-design/SKILL.md +294 -294
  20. package/skills/brainstorming/SKILL.md +164 -164
  21. package/skills/brainstorming/scripts/frame-template.html +214 -214
  22. package/skills/brainstorming/scripts/helper.js +88 -88
  23. package/skills/brainstorming/scripts/server.cjs +354 -354
  24. package/skills/brainstorming/scripts/start-server.sh +148 -148
  25. package/skills/brainstorming/scripts/stop-server.sh +56 -56
  26. package/skills/brainstorming/spec-document-reviewer-prompt.md +49 -49
  27. package/skills/brainstorming/visual-companion.md +287 -287
  28. package/skills/browser-testing-with-devtools/SKILL.md +302 -302
  29. package/skills/ci-cd-and-automation/SKILL.md +390 -390
  30. package/skills/code-review-and-quality/SKILL.md +347 -347
  31. package/skills/code-simplification/SKILL.md +331 -331
  32. package/skills/context-engineering/SKILL.md +289 -289
  33. package/skills/deprecation-and-migration/SKILL.md +206 -206
  34. package/skills/dispatching-parallel-agents/SKILL.md +182 -182
  35. package/skills/documentation-and-adrs/SKILL.md +278 -278
  36. package/skills/executing-plans/SKILL.md +70 -70
  37. package/skills/finishing-a-development-branch/SKILL.md +200 -200
  38. package/skills/frontend-ui-engineering/SKILL.md +322 -322
  39. package/skills/git-workflow-and-versioning/SKILL.md +300 -300
  40. package/skills/incremental-implementation/SKILL.md +241 -241
  41. package/skills/performance-optimization/SKILL.md +350 -350
  42. package/skills/receiving-code-review/SKILL.md +213 -213
  43. package/skills/requesting-code-review/SKILL.md +105 -105
  44. package/skills/requesting-code-review/code-reviewer.md +146 -146
  45. package/skills/security-and-hardening/SKILL.md +349 -349
  46. package/skills/shipping-and-launch/SKILL.md +309 -309
  47. package/skills/source-driven-development/SKILL.md +194 -194
  48. package/skills/spec-driven-development/SKILL.md +200 -200
  49. package/skills/subagent-driven-development/SKILL.md +277 -277
  50. package/skills/subagent-driven-development/code-quality-reviewer-prompt.md +26 -26
  51. package/skills/subagent-driven-development/implementer-prompt.md +113 -113
  52. package/skills/subagent-driven-development/spec-reviewer-prompt.md +61 -61
  53. package/skills/systematic-debugging/CREATION-LOG.md +119 -119
  54. package/skills/systematic-debugging/SKILL.md +596 -596
  55. package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
  56. package/skills/systematic-debugging/condition-based-waiting.md +115 -115
  57. package/skills/systematic-debugging/defense-in-depth.md +122 -122
  58. package/skills/systematic-debugging/find-polluter.sh +63 -63
  59. package/skills/systematic-debugging/root-cause-tracing.md +169 -169
  60. package/skills/systematic-debugging/test-academic.md +14 -14
  61. package/skills/systematic-debugging/test-pressure-1.md +58 -58
  62. package/skills/systematic-debugging/test-pressure-2.md +68 -68
  63. package/skills/systematic-debugging/test-pressure-3.md +69 -69
  64. package/skills/test-driven-development/SKILL.md +379 -379
  65. package/skills/using-agent-skills/SKILL.md +174 -174
  66. package/skills/using-git-worktrees/SKILL.md +218 -218
  67. package/skills/using-superpowers/SKILL.md +117 -117
  68. package/skills/using-superpowers/references/codex-tools.md +100 -100
  69. package/skills/using-superpowers/references/copilot-tools.md +52 -52
  70. package/skills/using-superpowers/references/gemini-tools.md +33 -33
  71. package/skills/verification-before-completion/SKILL.md +139 -139
  72. package/skills/writing-plans/SKILL.md +152 -152
  73. package/skills/writing-plans/plan-document-reviewer-prompt.md +49 -49
  74. package/skills/writing-skills/SKILL.md +655 -655
  75. package/skills/writing-skills/anthropic-best-practices.md +1150 -1150
  76. package/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +189 -189
  77. package/skills/writing-skills/graphviz-conventions.dot +171 -171
  78. package/skills/writing-skills/persuasion-principles.md +187 -187
  79. package/skills/writing-skills/render-graphs.js +168 -168
  80. package/skills/writing-skills/testing-skills-with-subagents.md +384 -384
  81. package/src/AGENTS.md +41 -41
  82. package/src/agents/AGENTS.md +74 -74
  83. package/src/agents/agent-builder.ts +50 -50
  84. package/src/agents/bob/AGENTS.md +29 -29
  85. package/src/agents/bob/default.ts +128 -128
  86. package/src/agents/bob/gemini.ts +237 -237
  87. package/src/agents/bob/gpt-pro.ts +430 -430
  88. package/src/agents/bob/index.ts +19 -19
  89. package/src/agents/bob.ts +528 -528
  90. package/src/agents/builtin-agents/agent-overrides.ts +75 -75
  91. package/src/agents/builtin-agents/available-skills.ts +35 -35
  92. package/src/agents/builtin-agents/bob-agent.ts +96 -96
  93. package/src/agents/builtin-agents/coder-agent.ts +98 -98
  94. package/src/agents/builtin-agents/environment-context.ts +16 -16
  95. package/src/agents/builtin-agents/general-agents.ts +122 -122
  96. package/src/agents/builtin-agents/guard-agent.ts +66 -66
  97. package/src/agents/builtin-agents/model-resolution.ts +31 -31
  98. package/src/agents/builtin-agents/resolve-file-uri.ts +42 -42
  99. package/src/agents/builtin-agents.ts +194 -194
  100. package/src/agents/coder/AGENTS.md +34 -34
  101. package/src/agents/coder/agent.ts +162 -162
  102. package/src/agents/coder/gpt-codex.ts +404 -404
  103. package/src/agents/coder/gpt-pro.ts +319 -319
  104. package/src/agents/coder/gpt.ts +253 -253
  105. package/src/agents/coder/index.ts +8 -8
  106. package/src/agents/critic/agent.ts +105 -105
  107. package/src/agents/custom-agent-summaries.ts +61 -61
  108. package/src/agents/dynamic-agent-category-skills-guide.ts +138 -138
  109. package/src/agents/dynamic-agent-core-sections.ts +237 -237
  110. package/src/agents/dynamic-agent-policy-sections.ts +182 -182
  111. package/src/agents/dynamic-agent-prompt-builder.ts +31 -31
  112. package/src/agents/dynamic-agent-prompt-types.ts +24 -24
  113. package/src/agents/dynamic-agent-tool-categorization.ts +45 -45
  114. package/src/agents/env-context.ts +16 -16
  115. package/src/agents/gpt-apply-patch-guard.ts +7 -7
  116. package/src/agents/guard/agent.ts +146 -146
  117. package/src/agents/guard/default-prompt-sections.ts +305 -305
  118. package/src/agents/guard/default.ts +22 -22
  119. package/src/agents/guard/gemini-prompt-sections.ts +293 -293
  120. package/src/agents/guard/gemini.ts +22 -22
  121. package/src/agents/guard/gpt-prompt-sections.ts +296 -296
  122. package/src/agents/guard/gpt.ts +22 -22
  123. package/src/agents/guard/index.ts +2 -2
  124. package/src/agents/guard/prompt-section-builder.ts +104 -104
  125. package/src/agents/guard/shared-prompt.ts +172 -172
  126. package/src/agents/index.ts +5 -5
  127. package/src/agents/platform-adapter.ts +236 -236
  128. package/src/agents/platform-manager.ts +57 -57
  129. package/src/agents/prompt-library/identity.ts +14 -14
  130. package/src/agents/prompt-library/index.ts +7 -7
  131. package/src/agents/prompt-library/intent-gate.ts +149 -149
  132. package/src/agents/prompt-library/orchestration.ts +60 -60
  133. package/src/agents/prompt-library/platform.ts +36 -36
  134. package/src/agents/prompt-library/specialized.ts +39 -39
  135. package/src/agents/prompt-library/strategy.ts +80 -80
  136. package/src/agents/prompt-library/todo-discipline.ts +22 -22
  137. package/src/agents/quality-guardian.ts +76 -76
  138. package/src/agents/researcher.ts +73 -73
  139. package/src/agents/strategist/AGENTS.md +37 -37
  140. package/src/agents/strategist/behavioral-summary.ts +79 -79
  141. package/src/agents/strategist/gemini.ts +333 -333
  142. package/src/agents/strategist/gpt.ts +460 -460
  143. package/src/agents/strategist/high-accuracy-mode.ts +78 -78
  144. package/src/agents/strategist/identity-constraints.ts +336 -336
  145. package/src/agents/strategist/index.ts +6 -6
  146. package/src/agents/strategist/interview-mode.ts +335 -335
  147. package/src/agents/strategist/plan-generation.ts +213 -213
  148. package/src/agents/strategist/plan-template.ts +325 -325
  149. package/src/agents/strategist/system-prompt.ts +68 -68
  150. package/src/agents/sub/agent.ts +141 -141
  151. package/src/agents/sub/default.ts +52 -52
  152. package/src/agents/sub/gemini.ts +194 -194
  153. package/src/agents/sub/gpt-codex.ts +156 -156
  154. package/src/agents/sub/gpt-pro.ts +161 -161
  155. package/src/agents/sub/gpt.ts +157 -157
  156. package/src/agents/sub/index.ts +13 -13
  157. package/src/agents/types.ts +144 -144
  158. package/src/agents/ui.ts +58 -58
  159. package/src/config/data/model-capabilities.json +40690 -40690
  160. package/src/config/defaults.ts +89 -146
  161. package/src/config/hiai-opencode.schema.json +12 -12
  162. package/src/config/index.ts +67 -67
  163. package/src/config/loader.test.ts +65 -65
  164. package/src/config/loader.ts +186 -183
  165. package/src/config/models.ts +32 -32
  166. package/src/config/platform-schema.ts +193 -192
  167. package/src/config/schema/agent-definitions.ts +5 -5
  168. package/src/config/schema/agent-names.ts +66 -66
  169. package/src/config/schema/agent-overrides.ts +95 -95
  170. package/src/config/schema/babysitting.ts +7 -7
  171. package/src/config/schema/background-task.ts +29 -29
  172. package/src/config/schema/bob-agent.ts +11 -11
  173. package/src/config/schema/bob.ts +17 -17
  174. package/src/config/schema/browser-automation.ts +24 -24
  175. package/src/config/schema/categories.ts +45 -45
  176. package/src/config/schema/claude-code.ts +13 -13
  177. package/src/config/schema/commands.ts +14 -14
  178. package/src/config/schema/comment-checker.ts +8 -8
  179. package/src/config/schema/dynamic-context-pruning.ts +53 -53
  180. package/src/config/schema/experimental.ts +27 -27
  181. package/src/config/schema/fallback-models.ts +31 -31
  182. package/src/config/schema/fast-apply.ts +14 -14
  183. package/src/config/schema/git-env-prefix.ts +28 -28
  184. package/src/config/schema/git-master.ts +14 -14
  185. package/src/config/schema/hooks.ts +61 -61
  186. package/src/config/schema/index.ts +52 -52
  187. package/src/config/schema/internal/permission.ts +20 -20
  188. package/src/config/schema/model-capabilities.ts +10 -10
  189. package/src/config/schema/notification.ts +8 -8
  190. package/src/config/schema/oh-my-opencode-config.ts +90 -90
  191. package/src/config/schema/openclaw.ts +50 -50
  192. package/src/config/schema/ralph-loop.ts +11 -11
  193. package/src/config/schema/runtime-fallback.ts +18 -18
  194. package/src/config/schema/skills.ts +39 -39
  195. package/src/config/schema/start-work.ts +7 -7
  196. package/src/config/schema/tmux.ts +28 -28
  197. package/src/config/schema/websearch.ts +15 -15
  198. package/src/config/types.ts +174 -174
  199. package/src/create-hooks.ts +93 -93
  200. package/src/create-managers.ts +116 -116
  201. package/src/create-runtime-tmux-config.ts +18 -18
  202. package/src/create-tools.ts +53 -53
  203. package/src/features/background-agent/AGENTS.md +56 -56
  204. package/src/features/background-agent/abort-with-timeout.ts +35 -35
  205. package/src/features/background-agent/background-task-notification-template.ts +74 -74
  206. package/src/features/background-agent/compaction-aware-message-resolver.ts +164 -164
  207. package/src/features/background-agent/concurrency.ts +137 -137
  208. package/src/features/background-agent/constants.ts +58 -58
  209. package/src/features/background-agent/duration-formatter.ts +14 -14
  210. package/src/features/background-agent/error-classifier.ts +83 -83
  211. package/src/features/background-agent/fallback-retry-handler.ts +134 -134
  212. package/src/features/background-agent/index.ts +2 -2
  213. package/src/features/background-agent/loop-detector.ts +102 -102
  214. package/src/features/background-agent/manager.ts +2220 -2220
  215. package/src/features/background-agent/opencode-client.ts +3 -3
  216. package/src/features/background-agent/process-cleanup.ts +98 -98
  217. package/src/features/background-agent/remove-task-toast-tracking.ts +8 -8
  218. package/src/features/background-agent/session-existence.ts +57 -57
  219. package/src/features/background-agent/session-idle-event-handler.ts +93 -93
  220. package/src/features/background-agent/session-status-classifier.ts +20 -20
  221. package/src/features/background-agent/spawner/parent-directory-resolver.ts +24 -24
  222. package/src/features/background-agent/spawner.ts +327 -327
  223. package/src/features/background-agent/state.ts +199 -199
  224. package/src/features/background-agent/subagent-spawn-limits.ts +97 -97
  225. package/src/features/background-agent/task-history.ts +79 -79
  226. package/src/features/background-agent/task-poller.ts +225 -225
  227. package/src/features/background-agent/types.ts +100 -100
  228. package/src/features/boulder-state/constants.ts +13 -13
  229. package/src/features/boulder-state/index.ts +4 -4
  230. package/src/features/boulder-state/storage.ts +336 -336
  231. package/src/features/boulder-state/top-level-task.ts +78 -78
  232. package/src/features/boulder-state/types.ts +61 -61
  233. package/src/features/builtin-commands/commands.ts +143 -143
  234. package/src/features/builtin-commands/index.ts +2 -2
  235. package/src/features/builtin-commands/templates/handoff.ts +177 -177
  236. package/src/features/builtin-commands/templates/init-deep.ts +305 -305
  237. package/src/features/builtin-commands/templates/ralph-loop.ts +66 -66
  238. package/src/features/builtin-commands/templates/refactor.ts +619 -619
  239. package/src/features/builtin-commands/templates/remove-ai-slops.ts +96 -96
  240. package/src/features/builtin-commands/templates/start-work.ts +128 -128
  241. package/src/features/builtin-commands/templates/stop-continuation.ts +13 -13
  242. package/src/features/builtin-commands/types.ts +9 -9
  243. package/src/features/builtin-skills/index.ts +2 -2
  244. package/src/features/builtin-skills/materialize.ts +338 -338
  245. package/src/features/builtin-skills/skills/ai-slop-remover.ts +145 -145
  246. package/src/features/builtin-skills/skills/dev-browser.ts +221 -221
  247. package/src/features/builtin-skills/skills/frontend-ui-ux.ts +79 -79
  248. package/src/features/builtin-skills/skills/git-master-sections/commit-workflow.ts +509 -509
  249. package/src/features/builtin-skills/skills/git-master-sections/history-search-workflow.ts +229 -229
  250. package/src/features/builtin-skills/skills/git-master-sections/overview.ts +64 -64
  251. package/src/features/builtin-skills/skills/git-master-sections/quick-reference.ts +86 -86
  252. package/src/features/builtin-skills/skills/git-master-sections/rebase-workflow.ts +181 -181
  253. package/src/features/builtin-skills/skills/git-master-skill-metadata.ts +4 -4
  254. package/src/features/builtin-skills/skills/git-master.ts +28 -28
  255. package/src/features/builtin-skills/skills/index.ts +7 -7
  256. package/src/features/builtin-skills/skills/playwright-cli.ts +268 -268
  257. package/src/features/builtin-skills/skills/playwright.ts +466 -466
  258. package/src/features/builtin-skills/skills/review-work.ts +536 -536
  259. package/src/features/builtin-skills/skills.ts +39 -39
  260. package/src/features/builtin-skills/types.ts +16 -16
  261. package/src/features/claude-code-agent-loader/agent-definitions-loader.ts +87 -87
  262. package/src/features/claude-code-agent-loader/claude-model-mapper.ts +53 -53
  263. package/src/features/claude-code-agent-loader/index.ts +5 -5
  264. package/src/features/claude-code-agent-loader/json-agent-loader.ts +53 -53
  265. package/src/features/claude-code-agent-loader/loader.ts +86 -86
  266. package/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts +125 -125
  267. package/src/features/claude-code-agent-loader/types.ts +31 -31
  268. package/src/features/claude-code-command-loader/index.ts +2 -2
  269. package/src/features/claude-code-command-loader/loader.ts +169 -169
  270. package/src/features/claude-code-command-loader/types.ts +46 -46
  271. package/src/features/claude-code-mcp-loader/configure-allowed-env-vars.ts +48 -48
  272. package/src/features/claude-code-mcp-loader/env-expander.ts +51 -51
  273. package/src/features/claude-code-mcp-loader/index.ts +12 -12
  274. package/src/features/claude-code-mcp-loader/loader.ts +156 -156
  275. package/src/features/claude-code-mcp-loader/scope-filter.ts +17 -17
  276. package/src/features/claude-code-mcp-loader/transformer.ts +57 -57
  277. package/src/features/claude-code-mcp-loader/types.ts +51 -51
  278. package/src/features/claude-code-plugin-loader/agent-loader.ts +59 -59
  279. package/src/features/claude-code-plugin-loader/command-loader.ts +53 -53
  280. package/src/features/claude-code-plugin-loader/discovery.ts +251 -251
  281. package/src/features/claude-code-plugin-loader/hook-loader.ts +26 -26
  282. package/src/features/claude-code-plugin-loader/index.ts +10 -10
  283. package/src/features/claude-code-plugin-loader/loader.ts +134 -134
  284. package/src/features/claude-code-plugin-loader/mcp-server-loader.ts +59 -59
  285. package/src/features/claude-code-plugin-loader/plugin-path-resolver.ts +23 -23
  286. package/src/features/claude-code-plugin-loader/scope-filter.ts +29 -29
  287. package/src/features/claude-code-plugin-loader/skill-loader.ts +62 -62
  288. package/src/features/claude-code-plugin-loader/types.ts +255 -255
  289. package/src/features/claude-code-session-state/index.ts +1 -1
  290. package/src/features/claude-code-session-state/state.ts +154 -154
  291. package/src/features/claude-tasks/session-storage.ts +52 -52
  292. package/src/features/claude-tasks/storage.ts +169 -169
  293. package/src/features/claude-tasks/types.ts +20 -20
  294. package/src/features/context-injector/collector.ts +91 -91
  295. package/src/features/context-injector/index.ts +14 -14
  296. package/src/features/context-injector/injector.ts +167 -167
  297. package/src/features/context-injector/types.ts +91 -91
  298. package/src/features/hook-message-injector/constants.ts +1 -1
  299. package/src/features/hook-message-injector/index.ts +11 -11
  300. package/src/features/hook-message-injector/injector.ts +437 -437
  301. package/src/features/hook-message-injector/types.ts +49 -49
  302. package/src/features/mcp-oauth/AGENTS.md +54 -54
  303. package/src/features/mcp-oauth/callback-server.ts +106 -106
  304. package/src/features/mcp-oauth/dcr.ts +98 -98
  305. package/src/features/mcp-oauth/discovery.ts +134 -134
  306. package/src/features/mcp-oauth/oauth-authorization-flow.ts +150 -150
  307. package/src/features/mcp-oauth/provider.ts +215 -215
  308. package/src/features/mcp-oauth/refresh-mutex.ts +58 -58
  309. package/src/features/mcp-oauth/resource-indicator.ts +16 -16
  310. package/src/features/mcp-oauth/schema.ts +8 -8
  311. package/src/features/mcp-oauth/step-up.ts +79 -79
  312. package/src/features/mcp-oauth/storage.ts +155 -155
  313. package/src/features/opencode-skill-loader/AGENTS.md +59 -59
  314. package/src/features/opencode-skill-loader/allowed-tools-parser.ts +9 -9
  315. package/src/features/opencode-skill-loader/async-loader.ts +213 -213
  316. package/src/features/opencode-skill-loader/blocking.ts +62 -62
  317. package/src/features/opencode-skill-loader/config-source-discovery.ts +114 -114
  318. package/src/features/opencode-skill-loader/discover-worker.ts +56 -56
  319. package/src/features/opencode-skill-loader/git-master-template-injection.ts +150 -150
  320. package/src/features/opencode-skill-loader/index.ts +17 -17
  321. package/src/features/opencode-skill-loader/loaded-skill-from-path.ts +73 -73
  322. package/src/features/opencode-skill-loader/loaded-skill-template-extractor.ts +16 -16
  323. package/src/features/opencode-skill-loader/loader.ts +172 -172
  324. package/src/features/opencode-skill-loader/merger/builtin-skill-converter.ts +26 -26
  325. package/src/features/opencode-skill-loader/merger/config-skill-entry-loader.ts +117 -117
  326. package/src/features/opencode-skill-loader/merger/scope-priority.ts +10 -10
  327. package/src/features/opencode-skill-loader/merger/skill-definition-merger.ts +31 -31
  328. package/src/features/opencode-skill-loader/merger/skills-config-normalizer.ts +19 -19
  329. package/src/features/opencode-skill-loader/merger.ts +96 -96
  330. package/src/features/opencode-skill-loader/skill-content.ts +11 -11
  331. package/src/features/opencode-skill-loader/skill-deduplication.ts +13 -13
  332. package/src/features/opencode-skill-loader/skill-definition-record.ts +11 -11
  333. package/src/features/opencode-skill-loader/skill-directory-loader.ts +112 -112
  334. package/src/features/opencode-skill-loader/skill-discovery.ts +76 -76
  335. package/src/features/opencode-skill-loader/skill-mcp-config.ts +45 -45
  336. package/src/features/opencode-skill-loader/skill-resolution-options.ts +9 -9
  337. package/src/features/opencode-skill-loader/skill-template-resolver.ts +97 -97
  338. package/src/features/opencode-skill-loader/types.ts +38 -38
  339. package/src/features/run-continuation-state/constants.ts +1 -1
  340. package/src/features/run-continuation-state/index.ts +3 -3
  341. package/src/features/run-continuation-state/storage.ts +80 -80
  342. package/src/features/run-continuation-state/types.ts +15 -15
  343. package/src/features/skill-mcp-manager/AGENTS.md +111 -111
  344. package/src/features/skill-mcp-manager/cleanup.ts +153 -153
  345. package/src/features/skill-mcp-manager/connection-type.ts +26 -26
  346. package/src/features/skill-mcp-manager/connection.ts +146 -146
  347. package/src/features/skill-mcp-manager/env-cleaner.ts +59 -59
  348. package/src/features/skill-mcp-manager/error-redaction.ts +47 -47
  349. package/src/features/skill-mcp-manager/http-client.ts +126 -126
  350. package/src/features/skill-mcp-manager/index.ts +2 -2
  351. package/src/features/skill-mcp-manager/manager.ts +178 -178
  352. package/src/features/skill-mcp-manager/oauth-handler.ts +160 -160
  353. package/src/features/skill-mcp-manager/stdio-client.ts +112 -112
  354. package/src/features/skill-mcp-manager/types.ts +96 -96
  355. package/src/features/task-toast-manager/index.ts +2 -2
  356. package/src/features/task-toast-manager/manager.ts +251 -251
  357. package/src/features/task-toast-manager/types.ts +29 -29
  358. package/src/features/tmux-subagent/action-executor-core.ts +82 -82
  359. package/src/features/tmux-subagent/action-executor.ts +137 -137
  360. package/src/features/tmux-subagent/cleanup.ts +42 -42
  361. package/src/features/tmux-subagent/decision-engine.ts +22 -22
  362. package/src/features/tmux-subagent/event-handlers.ts +6 -6
  363. package/src/features/tmux-subagent/grid-planning.ts +137 -137
  364. package/src/features/tmux-subagent/index.ts +16 -16
  365. package/src/features/tmux-subagent/manager.ts +969 -969
  366. package/src/features/tmux-subagent/oldest-agent-pane.ts +37 -37
  367. package/src/features/tmux-subagent/pane-split-availability.ts +77 -77
  368. package/src/features/tmux-subagent/pane-state-parser.ts +135 -135
  369. package/src/features/tmux-subagent/pane-state-querier.ts +76 -76
  370. package/src/features/tmux-subagent/polling-constants.ts +6 -6
  371. package/src/features/tmux-subagent/polling-manager.ts +167 -167
  372. package/src/features/tmux-subagent/polling.ts +183 -183
  373. package/src/features/tmux-subagent/session-created-event.ts +44 -44
  374. package/src/features/tmux-subagent/session-created-handler.ts +175 -175
  375. package/src/features/tmux-subagent/session-deleted-handler.ts +50 -50
  376. package/src/features/tmux-subagent/session-message-count.ts +3 -3
  377. package/src/features/tmux-subagent/session-ready-waiter.ts +44 -44
  378. package/src/features/tmux-subagent/session-status-parser.ts +17 -17
  379. package/src/features/tmux-subagent/spawn-action-decider.ts +147 -147
  380. package/src/features/tmux-subagent/spawn-target-finder.ts +146 -146
  381. package/src/features/tmux-subagent/tmux-grid-constants.ts +57 -57
  382. package/src/features/tmux-subagent/tracked-session-state.ts +29 -29
  383. package/src/features/tmux-subagent/types.ts +54 -54
  384. package/src/features/tool-metadata-store/index.ts +7 -7
  385. package/src/features/tool-metadata-store/store.ts +84 -84
  386. package/src/hooks/agent-usage-reminder/constants.ts +52 -52
  387. package/src/hooks/agent-usage-reminder/hook.ts +134 -134
  388. package/src/hooks/agent-usage-reminder/index.ts +1 -1
  389. package/src/hooks/agent-usage-reminder/storage.ts +42 -42
  390. package/src/hooks/agent-usage-reminder/types.ts +6 -6
  391. package/src/hooks/anthropic-context-window-limit-recovery/AGENTS.md +49 -49
  392. package/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts +87 -87
  393. package/src/hooks/anthropic-context-window-limit-recovery/client.ts +21 -21
  394. package/src/hooks/anthropic-context-window-limit-recovery/deduplication-recovery.ts +77 -77
  395. package/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.ts +199 -199
  396. package/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts +149 -149
  397. package/src/hooks/anthropic-context-window-limit-recovery/executor.ts +83 -83
  398. package/src/hooks/anthropic-context-window-limit-recovery/index.ts +8 -8
  399. package/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts +190 -190
  400. package/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts +40 -40
  401. package/src/hooks/anthropic-context-window-limit-recovery/parser.ts +209 -209
  402. package/src/hooks/anthropic-context-window-limit-recovery/pruning-deduplication.ts +189 -189
  403. package/src/hooks/anthropic-context-window-limit-recovery/pruning-tool-output-truncation.ts +142 -142
  404. package/src/hooks/anthropic-context-window-limit-recovery/pruning-types.ts +44 -44
  405. package/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test-support.ts +119 -119
  406. package/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts +193 -193
  407. package/src/hooks/anthropic-context-window-limit-recovery/recovery-strategy.ts +2 -2
  408. package/src/hooks/anthropic-context-window-limit-recovery/session-timeout-map.ts +20 -20
  409. package/src/hooks/anthropic-context-window-limit-recovery/state.ts +78 -78
  410. package/src/hooks/anthropic-context-window-limit-recovery/storage-paths.ts +6 -6
  411. package/src/hooks/anthropic-context-window-limit-recovery/storage.ts +18 -18
  412. package/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts +218 -218
  413. package/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts +196 -196
  414. package/src/hooks/anthropic-context-window-limit-recovery/tool-part-types.ts +38 -38
  415. package/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage-sdk.ts +123 -123
  416. package/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage.ts +119 -119
  417. package/src/hooks/anthropic-context-window-limit-recovery/types.ts +44 -44
  418. package/src/hooks/anthropic-effort/hook.ts +93 -93
  419. package/src/hooks/anthropic-effort/index.ts +1 -1
  420. package/src/hooks/auto-slash-command/constants.ts +12 -12
  421. package/src/hooks/auto-slash-command/detector.ts +88 -88
  422. package/src/hooks/auto-slash-command/executor.ts +165 -165
  423. package/src/hooks/auto-slash-command/hook.ts +238 -238
  424. package/src/hooks/auto-slash-command/index.ts +7 -7
  425. package/src/hooks/auto-slash-command/processed-command-store.ts +74 -74
  426. package/src/hooks/auto-slash-command/types.ts +42 -42
  427. package/src/hooks/background-notification/hook.ts +54 -54
  428. package/src/hooks/background-notification/index.ts +2 -2
  429. package/src/hooks/background-notification/types.ts +5 -5
  430. package/src/hooks/bash-file-read-guard.ts +44 -44
  431. package/src/hooks/category-skill-reminder/formatter.ts +37 -37
  432. package/src/hooks/category-skill-reminder/hook.ts +142 -142
  433. package/src/hooks/category-skill-reminder/index.ts +1 -1
  434. package/src/hooks/claude-code-hooks/AGENTS.md +41 -41
  435. package/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts +28 -28
  436. package/src/hooks/claude-code-hooks/config-loader.ts +151 -151
  437. package/src/hooks/claude-code-hooks/config.ts +147 -147
  438. package/src/hooks/claude-code-hooks/dispatch-hook.ts +27 -27
  439. package/src/hooks/claude-code-hooks/execute-http-hook.ts +116 -116
  440. package/src/hooks/claude-code-hooks/handlers/chat-message-handler.ts +140 -140
  441. package/src/hooks/claude-code-hooks/handlers/pre-compact-handler.ts +41 -41
  442. package/src/hooks/claude-code-hooks/handlers/session-event-handler.ts +137 -137
  443. package/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts +160 -160
  444. package/src/hooks/claude-code-hooks/handlers/tool-execute-before-handler.ts +93 -93
  445. package/src/hooks/claude-code-hooks/index.ts +1 -1
  446. package/src/hooks/claude-code-hooks/plugin-config.ts +12 -12
  447. package/src/hooks/claude-code-hooks/post-tool-use.ts +195 -195
  448. package/src/hooks/claude-code-hooks/pre-compact.ts +105 -105
  449. package/src/hooks/claude-code-hooks/pre-tool-use.ts +168 -168
  450. package/src/hooks/claude-code-hooks/session-hook-state.ts +17 -17
  451. package/src/hooks/claude-code-hooks/stop.ts +118 -118
  452. package/src/hooks/claude-code-hooks/todo.ts +76 -76
  453. package/src/hooks/claude-code-hooks/tool-input-cache.ts +82 -82
  454. package/src/hooks/claude-code-hooks/transcript.ts +248 -248
  455. package/src/hooks/claude-code-hooks/types.ts +214 -214
  456. package/src/hooks/claude-code-hooks/user-prompt-submit.ts +121 -121
  457. package/src/hooks/comment-checker/cli-runner.ts +127 -127
  458. package/src/hooks/comment-checker/cli.ts +269 -269
  459. package/src/hooks/comment-checker/downloader.ts +170 -170
  460. package/src/hooks/comment-checker/hook.ts +192 -192
  461. package/src/hooks/comment-checker/index.ts +1 -1
  462. package/src/hooks/comment-checker/pending-calls.ts +45 -45
  463. package/src/hooks/comment-checker/types.ts +33 -33
  464. package/src/hooks/compaction-context-injector/compaction-context-prompt.ts +56 -56
  465. package/src/hooks/compaction-context-injector/constants.ts +5 -5
  466. package/src/hooks/compaction-context-injector/hook.ts +164 -164
  467. package/src/hooks/compaction-context-injector/index.ts +1 -1
  468. package/src/hooks/compaction-context-injector/recovery-prompt-config.ts +77 -77
  469. package/src/hooks/compaction-context-injector/recovery.ts +163 -163
  470. package/src/hooks/compaction-context-injector/session-id.ts +8 -8
  471. package/src/hooks/compaction-context-injector/session-prompt-config-resolver.ts +120 -120
  472. package/src/hooks/compaction-context-injector/tail-monitor.ts +52 -52
  473. package/src/hooks/compaction-context-injector/types.ts +25 -25
  474. package/src/hooks/compaction-context-injector/validated-model.ts +47 -47
  475. package/src/hooks/compaction-todo-preserver/hook.ts +127 -127
  476. package/src/hooks/compaction-todo-preserver/index.ts +2 -2
  477. package/src/hooks/context-window-monitor.ts +113 -113
  478. package/src/hooks/delegate-task-retry/guidance.ts +45 -45
  479. package/src/hooks/delegate-task-retry/hook.ts +22 -22
  480. package/src/hooks/delegate-task-retry/index.ts +4 -4
  481. package/src/hooks/delegate-task-retry/patterns.ts +77 -77
  482. package/src/hooks/directory-agents-injector/constants.ts +7 -7
  483. package/src/hooks/directory-agents-injector/finder.ts +38 -38
  484. package/src/hooks/directory-agents-injector/hook.ts +80 -80
  485. package/src/hooks/directory-agents-injector/index.ts +1 -1
  486. package/src/hooks/directory-agents-injector/injector.ts +59 -59
  487. package/src/hooks/directory-agents-injector/storage.ts +8 -8
  488. package/src/hooks/directory-readme-injector/constants.ts +7 -7
  489. package/src/hooks/directory-readme-injector/finder.ts +33 -33
  490. package/src/hooks/directory-readme-injector/hook.ts +80 -80
  491. package/src/hooks/directory-readme-injector/index.ts +1 -1
  492. package/src/hooks/directory-readme-injector/injector.ts +59 -59
  493. package/src/hooks/directory-readme-injector/storage.ts +8 -8
  494. package/src/hooks/edit-error-recovery/hook.ts +58 -58
  495. package/src/hooks/edit-error-recovery/index.ts +5 -5
  496. package/src/hooks/empty-task-response-detector.ts +27 -27
  497. package/src/hooks/fast-apply/hook.ts +11 -11
  498. package/src/hooks/fast-apply/index.ts +1 -1
  499. package/src/hooks/fast-apply/ollama-client.ts +53 -53
  500. package/src/hooks/fast-apply/tool-execute-before-handler.ts +86 -86
  501. package/src/hooks/guard/AGENTS.md +64 -64
  502. package/src/hooks/guard/background-launch-session-tracking.ts +97 -97
  503. package/src/hooks/guard/bob-path.ts +8 -8
  504. package/src/hooks/guard/boulder-continuation-injector.ts +109 -109
  505. package/src/hooks/guard/boulder-session-lineage.ts +44 -44
  506. package/src/hooks/guard/event-handler.ts +104 -104
  507. package/src/hooks/guard/final-wave-approval-gate.ts +47 -47
  508. package/src/hooks/guard/final-wave-plan-state.ts +60 -60
  509. package/src/hooks/guard/guard-hook.ts +27 -27
  510. package/src/hooks/guard/hook-name.ts +1 -1
  511. package/src/hooks/guard/idle-event.ts +341 -341
  512. package/src/hooks/guard/index.ts +3 -3
  513. package/src/hooks/guard/is-abort-error.ts +20 -20
  514. package/src/hooks/guard/recent-model-resolver.ts +89 -89
  515. package/src/hooks/guard/resolve-active-boulder-session.ts +29 -29
  516. package/src/hooks/guard/session-last-agent.ts +153 -153
  517. package/src/hooks/guard/subagent-session-id.ts +54 -54
  518. package/src/hooks/guard/system-reminder-templates.ts +249 -249
  519. package/src/hooks/guard/task-context.ts +45 -45
  520. package/src/hooks/guard/tool-execute-after.ts +209 -209
  521. package/src/hooks/guard/tool-execute-before.ts +102 -102
  522. package/src/hooks/guard/tsconfig.json +9 -9
  523. package/src/hooks/guard/types.ts +45 -45
  524. package/src/hooks/guard/verification-reminders.ts +197 -197
  525. package/src/hooks/guard/write-edit-tool-policy.ts +5 -5
  526. package/src/hooks/hashline-edit-diff-enhancer/hook.ts +106 -106
  527. package/src/hooks/hashline-read-enhancer/hook.ts +193 -193
  528. package/src/hooks/hashline-read-enhancer/index.ts +1 -1
  529. package/src/hooks/index.ts +58 -58
  530. package/src/hooks/interactive-bash-session/constants.ts +13 -13
  531. package/src/hooks/interactive-bash-session/hook.ts +125 -125
  532. package/src/hooks/interactive-bash-session/index.ts +3 -3
  533. package/src/hooks/interactive-bash-session/interactive-bash-session-tracker.ts +119 -119
  534. package/src/hooks/interactive-bash-session/parser.ts +118 -118
  535. package/src/hooks/interactive-bash-session/state-manager.ts +35 -35
  536. package/src/hooks/interactive-bash-session/storage.ts +59 -59
  537. package/src/hooks/interactive-bash-session/tmux-command-parser.ts +125 -125
  538. package/src/hooks/interactive-bash-session/types.ts +11 -11
  539. package/src/hooks/json-error-recovery/hook.ts +58 -58
  540. package/src/hooks/json-error-recovery/index.ts +6 -6
  541. package/src/hooks/keyword-detector/AGENTS.md +57 -57
  542. package/src/hooks/keyword-detector/analyze/default.ts +28 -28
  543. package/src/hooks/keyword-detector/analyze/index.ts +1 -1
  544. package/src/hooks/keyword-detector/constants.ts +45 -45
  545. package/src/hooks/keyword-detector/detector.ts +53 -53
  546. package/src/hooks/keyword-detector/hook.ts +143 -143
  547. package/src/hooks/keyword-detector/index.ts +5 -5
  548. package/src/hooks/keyword-detector/search/default.ts +20 -20
  549. package/src/hooks/keyword-detector/search/index.ts +1 -1
  550. package/src/hooks/keyword-detector/types.ts +4 -4
  551. package/src/hooks/keyword-detector/ultrawork/default.ts +302 -302
  552. package/src/hooks/keyword-detector/ultrawork/gemini.ts +290 -290
  553. package/src/hooks/keyword-detector/ultrawork/gpt.ts +173 -173
  554. package/src/hooks/keyword-detector/ultrawork/index.ts +56 -56
  555. package/src/hooks/keyword-detector/ultrawork/planner.ts +140 -140
  556. package/src/hooks/keyword-detector/ultrawork/source-detector.ts +65 -65
  557. package/src/hooks/legacy-plugin-toast/auto-migrate-runner.ts +2 -2
  558. package/src/hooks/legacy-plugin-toast/auto-migrate.ts +64 -64
  559. package/src/hooks/legacy-plugin-toast/hook.ts +68 -68
  560. package/src/hooks/legacy-plugin-toast/index.ts +1 -1
  561. package/src/hooks/legacy-plugin-toast/plugin-entry-migrator.ts +1 -1
  562. package/src/hooks/model-fallback/chat-message-fallback-handler.ts +74 -74
  563. package/src/hooks/model-fallback/hook.ts +201 -201
  564. package/src/hooks/model-fallback/next-fallback.ts +84 -84
  565. package/src/hooks/no-bob-gpt/hook.ts +56 -56
  566. package/src/hooks/no-bob-gpt/index.ts +1 -1
  567. package/src/hooks/no-coder-non-gpt/hook.ts +67 -67
  568. package/src/hooks/no-coder-non-gpt/index.ts +1 -1
  569. package/src/hooks/non-interactive-env/constants.ts +70 -70
  570. package/src/hooks/non-interactive-env/detector.ts +19 -19
  571. package/src/hooks/non-interactive-env/index.ts +5 -5
  572. package/src/hooks/non-interactive-env/non-interactive-env-hook.ts +73 -73
  573. package/src/hooks/non-interactive-env/types.ts +3 -3
  574. package/src/hooks/preemptive-compaction-degradation-monitor.ts +212 -212
  575. package/src/hooks/preemptive-compaction-no-text-tail.ts +70 -70
  576. package/src/hooks/preemptive-compaction.ts +218 -218
  577. package/src/hooks/question-label-truncator/hook.ts +62 -62
  578. package/src/hooks/question-label-truncator/index.ts +1 -1
  579. package/src/hooks/ralph-loop/AGENTS.md +62 -62
  580. package/src/hooks/ralph-loop/command-arguments.ts +30 -30
  581. package/src/hooks/ralph-loop/completion-handler.ts +65 -65
  582. package/src/hooks/ralph-loop/completion-promise-detector-test-input.ts +23 -23
  583. package/src/hooks/ralph-loop/completion-promise-detector.ts +165 -165
  584. package/src/hooks/ralph-loop/constants.ts +7 -7
  585. package/src/hooks/ralph-loop/continuation-prompt-builder.ts +77 -77
  586. package/src/hooks/ralph-loop/continuation-prompt-injector.ts +91 -91
  587. package/src/hooks/ralph-loop/index.ts +6 -6
  588. package/src/hooks/ralph-loop/iteration-continuation.ts +64 -64
  589. package/src/hooks/ralph-loop/logician-verification-detector.ts +88 -88
  590. package/src/hooks/ralph-loop/loop-session-recovery.ts +33 -33
  591. package/src/hooks/ralph-loop/loop-state-controller.ts +178 -178
  592. package/src/hooks/ralph-loop/message-storage-directory.ts +1 -1
  593. package/src/hooks/ralph-loop/pending-verification-handler.ts +152 -152
  594. package/src/hooks/ralph-loop/ralph-loop-event-handler.ts +231 -231
  595. package/src/hooks/ralph-loop/ralph-loop-hook.ts +90 -90
  596. package/src/hooks/ralph-loop/session-event-handler.ts +56 -56
  597. package/src/hooks/ralph-loop/session-reset-strategy.ts +69 -69
  598. package/src/hooks/ralph-loop/storage.ts +164 -164
  599. package/src/hooks/ralph-loop/types.ts +25 -25
  600. package/src/hooks/ralph-loop/verification-failure-handler.ts +103 -103
  601. package/src/hooks/ralph-loop/with-timeout.ts +20 -20
  602. package/src/hooks/read-image-resizer/hook.ts +209 -209
  603. package/src/hooks/read-image-resizer/image-dimensions.ts +191 -191
  604. package/src/hooks/read-image-resizer/image-resizer.ts +191 -191
  605. package/src/hooks/read-image-resizer/index.ts +1 -1
  606. package/src/hooks/read-image-resizer/png-fallback-resizer.ts +359 -359
  607. package/src/hooks/read-image-resizer/types.ts +16 -16
  608. package/src/hooks/rules-injector/AGENTS.md +53 -53
  609. package/src/hooks/rules-injector/cache.ts +27 -27
  610. package/src/hooks/rules-injector/constants.ts +31 -31
  611. package/src/hooks/rules-injector/finder.ts +3 -3
  612. package/src/hooks/rules-injector/hook.ts +94 -94
  613. package/src/hooks/rules-injector/index.ts +2 -2
  614. package/src/hooks/rules-injector/injector.ts +189 -189
  615. package/src/hooks/rules-injector/matcher.ts +63 -63
  616. package/src/hooks/rules-injector/output-path.ts +22 -22
  617. package/src/hooks/rules-injector/parser.ts +211 -211
  618. package/src/hooks/rules-injector/project-root-finder.ts +36 -36
  619. package/src/hooks/rules-injector/rule-distance.ts +53 -53
  620. package/src/hooks/rules-injector/rule-file-finder.ts +139 -139
  621. package/src/hooks/rules-injector/rule-file-scanner.ts +55 -55
  622. package/src/hooks/rules-injector/storage.ts +59 -59
  623. package/src/hooks/rules-injector/types.ts +57 -57
  624. package/src/hooks/runtime-fallback/AGENTS.md +102 -102
  625. package/src/hooks/runtime-fallback/agent-resolver.ts +50 -50
  626. package/src/hooks/runtime-fallback/auto-retry-signal.ts +32 -32
  627. package/src/hooks/runtime-fallback/auto-retry.ts +228 -228
  628. package/src/hooks/runtime-fallback/chat-message-handler.ts +62 -62
  629. package/src/hooks/runtime-fallback/constants.ts +47 -47
  630. package/src/hooks/runtime-fallback/error-classifier.ts +183 -183
  631. package/src/hooks/runtime-fallback/event-handler.ts +213 -213
  632. package/src/hooks/runtime-fallback/fallback-bootstrap-model.ts +63 -63
  633. package/src/hooks/runtime-fallback/fallback-models.ts +86 -86
  634. package/src/hooks/runtime-fallback/fallback-retry-dispatcher.ts +55 -55
  635. package/src/hooks/runtime-fallback/fallback-state.ts +74 -74
  636. package/src/hooks/runtime-fallback/hook.ts +87 -87
  637. package/src/hooks/runtime-fallback/index.ts +2 -2
  638. package/src/hooks/runtime-fallback/last-user-retry-parts.ts +20 -20
  639. package/src/hooks/runtime-fallback/message-update-handler.ts +168 -168
  640. package/src/hooks/runtime-fallback/retry-model-payload.ts +30 -30
  641. package/src/hooks/runtime-fallback/session-messages.ts +38 -38
  642. package/src/hooks/runtime-fallback/session-status-handler.ts +126 -126
  643. package/src/hooks/runtime-fallback/types.ts +77 -77
  644. package/src/hooks/runtime-fallback/visible-assistant-response.ts +80 -80
  645. package/src/hooks/session-notification-content.ts +145 -145
  646. package/src/hooks/session-notification-formatting.ts +25 -25
  647. package/src/hooks/session-notification-scheduler.ts +188 -188
  648. package/src/hooks/session-notification-sender.ts +117 -117
  649. package/src/hooks/session-notification-utils.ts +80 -80
  650. package/src/hooks/session-notification.ts +219 -219
  651. package/src/hooks/session-recovery/AGENTS.md +59 -59
  652. package/src/hooks/session-recovery/constants.ts +5 -5
  653. package/src/hooks/session-recovery/detect-error-type.ts +102 -102
  654. package/src/hooks/session-recovery/hook.ts +166 -166
  655. package/src/hooks/session-recovery/index.ts +7 -7
  656. package/src/hooks/session-recovery/recover-empty-content-message-sdk.ts +201 -201
  657. package/src/hooks/session-recovery/recover-thinking-block-order.ts +137 -137
  658. package/src/hooks/session-recovery/recover-thinking-disabled-violation.ts +75 -75
  659. package/src/hooks/session-recovery/recover-tool-result-missing.ts +108 -108
  660. package/src/hooks/session-recovery/recover-unavailable-tool.ts +108 -108
  661. package/src/hooks/session-recovery/resume.ts +49 -49
  662. package/src/hooks/session-recovery/storage/empty-messages.ts +47 -47
  663. package/src/hooks/session-recovery/storage/empty-text.ts +118 -118
  664. package/src/hooks/session-recovery/storage/message-dir.ts +1 -1
  665. package/src/hooks/session-recovery/storage/messages-reader.ts +83 -83
  666. package/src/hooks/session-recovery/storage/orphan-thinking-search.ts +43 -43
  667. package/src/hooks/session-recovery/storage/part-content.ts +28 -28
  668. package/src/hooks/session-recovery/storage/part-id.ts +5 -5
  669. package/src/hooks/session-recovery/storage/parts-reader.ts +56 -56
  670. package/src/hooks/session-recovery/storage/text-part-injector.ts +63 -63
  671. package/src/hooks/session-recovery/storage/thinking-block-search.ts +42 -42
  672. package/src/hooks/session-recovery/storage/thinking-prepend.ts +223 -223
  673. package/src/hooks/session-recovery/storage/thinking-strip.ts +67 -67
  674. package/src/hooks/session-recovery/storage.ts +34 -34
  675. package/src/hooks/session-recovery/types.ts +101 -101
  676. package/src/hooks/session-todo-status.ts +20 -20
  677. package/src/hooks/shared/compaction-model-resolver.ts +34 -34
  678. package/src/hooks/shared/shared/compaction-model-resolver.ts +34 -34
  679. package/src/hooks/start-work/context-info-builder.ts +319 -319
  680. package/src/hooks/start-work/index.ts +4 -4
  681. package/src/hooks/start-work/parse-user-request.ts +32 -32
  682. package/src/hooks/start-work/start-work-hook.ts +135 -135
  683. package/src/hooks/start-work/worktree-block.ts +11 -11
  684. package/src/hooks/start-work/worktree-detector.ts +77 -77
  685. package/src/hooks/stop-continuation-guard/hook.ts +122 -122
  686. package/src/hooks/stop-continuation-guard/index.ts +2 -2
  687. package/src/hooks/strategist-md-only/agent-matcher.ts +5 -5
  688. package/src/hooks/strategist-md-only/agent-resolution.ts +70 -70
  689. package/src/hooks/strategist-md-only/constants.ts +78 -78
  690. package/src/hooks/strategist-md-only/hook.ts +82 -82
  691. package/src/hooks/strategist-md-only/index.ts +2 -2
  692. package/src/hooks/strategist-md-only/path-policy.ts +41 -41
  693. package/src/hooks/sub-notepad/constants.ts +29 -29
  694. package/src/hooks/sub-notepad/hook.ts +44 -44
  695. package/src/hooks/sub-notepad/index.ts +3 -3
  696. package/src/hooks/task-reminder/hook.ts +59 -59
  697. package/src/hooks/task-reminder/index.ts +1 -1
  698. package/src/hooks/task-resume-info/hook.ts +39 -39
  699. package/src/hooks/task-resume-info/index.ts +1 -1
  700. package/src/hooks/tasks-todowrite-disabler/constants.ts +30 -30
  701. package/src/hooks/tasks-todowrite-disabler/hook.ts +34 -34
  702. package/src/hooks/tasks-todowrite-disabler/index.ts +2 -2
  703. package/src/hooks/think-mode/detector.ts +59 -59
  704. package/src/hooks/think-mode/hook.ts +76 -76
  705. package/src/hooks/think-mode/index.ts +5 -5
  706. package/src/hooks/think-mode/switcher.ts +100 -100
  707. package/src/hooks/think-mode/types.ts +16 -16
  708. package/src/hooks/thinking-block-validator/hook.ts +181 -181
  709. package/src/hooks/thinking-block-validator/index.ts +1 -1
  710. package/src/hooks/todo-continuation-enforcer/AGENTS.md +65 -65
  711. package/src/hooks/todo-continuation-enforcer/abort-detection.ts +17 -17
  712. package/src/hooks/todo-continuation-enforcer/compaction-guard.ts +39 -39
  713. package/src/hooks/todo-continuation-enforcer/constants.ts +25 -25
  714. package/src/hooks/todo-continuation-enforcer/continuation-injection.ts +222 -222
  715. package/src/hooks/todo-continuation-enforcer/countdown.ts +86 -86
  716. package/src/hooks/todo-continuation-enforcer/handler.ts +99 -99
  717. package/src/hooks/todo-continuation-enforcer/idle-event.ts +225 -225
  718. package/src/hooks/todo-continuation-enforcer/index.ts +59 -59
  719. package/src/hooks/todo-continuation-enforcer/message-directory.ts +1 -1
  720. package/src/hooks/todo-continuation-enforcer/non-idle-events.ts +107 -107
  721. package/src/hooks/todo-continuation-enforcer/pending-question-detection.ts +40 -40
  722. package/src/hooks/todo-continuation-enforcer/resolve-message-info.ts +48 -48
  723. package/src/hooks/todo-continuation-enforcer/session-state.ts +283 -283
  724. package/src/hooks/todo-continuation-enforcer/stagnation-detection.ts +36 -36
  725. package/src/hooks/todo-continuation-enforcer/todo.ts +11 -11
  726. package/src/hooks/todo-continuation-enforcer/token-limit-detection.ts +38 -38
  727. package/src/hooks/todo-continuation-enforcer/types.ts +74 -74
  728. package/src/hooks/todo-description-override/description.ts +28 -28
  729. package/src/hooks/todo-description-override/hook.ts +14 -14
  730. package/src/hooks/todo-description-override/index.ts +1 -1
  731. package/src/hooks/tool-output-truncator.ts +66 -66
  732. package/src/hooks/tool-pair-validator/hook.ts +184 -184
  733. package/src/hooks/tool-pair-validator/index.ts +1 -1
  734. package/src/hooks/unstable-agent-babysitter/index.ts +9 -9
  735. package/src/hooks/unstable-agent-babysitter/task-message-analyzer.ts +110 -110
  736. package/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts +238 -238
  737. package/src/hooks/webfetch-redirect-guard/constants.ts +11 -11
  738. package/src/hooks/webfetch-redirect-guard/hook.ts +123 -123
  739. package/src/hooks/webfetch-redirect-guard/index.ts +1 -1
  740. package/src/hooks/webfetch-redirect-guard/redirect-resolution.ts +89 -89
  741. package/src/hooks/write-existing-file-guard/hook.ts +108 -108
  742. package/src/hooks/write-existing-file-guard/index.ts +1 -1
  743. package/src/hooks/write-existing-file-guard/session-read-permissions.ts +36 -36
  744. package/src/hooks/write-existing-file-guard/tool-execute-before-handler.ts +176 -176
  745. package/src/index.ts +285 -284
  746. package/src/internals/plugins/pty/LICENSE +21 -21
  747. package/src/internals/plugins/pty/constants.ts +7 -7
  748. package/src/internals/plugins/pty/plugin.ts +28 -28
  749. package/src/internals/plugins/pty/pty/buffer.ts +75 -75
  750. package/src/internals/plugins/pty/pty/formatters.ts +22 -22
  751. package/src/internals/plugins/pty/pty/manager.ts +175 -175
  752. package/src/internals/plugins/pty/pty/notification-manager.ts +75 -75
  753. package/src/internals/plugins/pty/pty/output-manager.ts +29 -29
  754. package/src/internals/plugins/pty/pty/permissions.ts +115 -115
  755. package/src/internals/plugins/pty/pty/session-lifecycle.ts +161 -161
  756. package/src/internals/plugins/pty/pty/tools/kill.ts +41 -41
  757. package/src/internals/plugins/pty/pty/tools/kill.txt +25 -25
  758. package/src/internals/plugins/pty/pty/tools/list.ts +25 -25
  759. package/src/internals/plugins/pty/pty/tools/list.txt +22 -22
  760. package/src/internals/plugins/pty/pty/tools/read.ts +234 -234
  761. package/src/internals/plugins/pty/pty/tools/read.txt +39 -39
  762. package/src/internals/plugins/pty/pty/tools/spawn.ts +71 -71
  763. package/src/internals/plugins/pty/pty/tools/spawn.txt +47 -47
  764. package/src/internals/plugins/pty/pty/tools/write.ts +96 -96
  765. package/src/internals/plugins/pty/pty/tools/write.txt +28 -28
  766. package/src/internals/plugins/pty/pty/types.ts +67 -67
  767. package/src/internals/plugins/pty/pty/utils.ts +21 -21
  768. package/src/internals/plugins/pty/pty/wildcard.ts +62 -62
  769. package/src/internals/plugins/pty/shared/constants.ts +7 -7
  770. package/src/internals/plugins/pty/types.ts +7 -7
  771. package/src/internals/plugins/subtask2/LICENSE +128 -128
  772. package/src/internals/plugins/subtask2/commands/index.ts +7 -7
  773. package/src/internals/plugins/subtask2/commands/loader.ts +39 -39
  774. package/src/internals/plugins/subtask2/commands/manifest.ts +64 -64
  775. package/src/internals/plugins/subtask2/commands/resolver.ts +28 -28
  776. package/src/internals/plugins/subtask2/core/plugin.ts +52 -52
  777. package/src/internals/plugins/subtask2/core/state.ts +764 -764
  778. package/src/internals/plugins/subtask2/features/auto.ts +57 -57
  779. package/src/internals/plugins/subtask2/features/index.ts +9 -9
  780. package/src/internals/plugins/subtask2/features/inline-subtasks.ts +205 -205
  781. package/src/internals/plugins/subtask2/features/parallel.ts +148 -148
  782. package/src/internals/plugins/subtask2/features/results.ts +48 -48
  783. package/src/internals/plugins/subtask2/features/returns.ts +273 -273
  784. package/src/internals/plugins/subtask2/features/turns.ts +190 -190
  785. package/src/internals/plugins/subtask2/hooks/command-hooks.ts +283 -283
  786. package/src/internals/plugins/subtask2/hooks/message-hooks.ts +603 -603
  787. package/src/internals/plugins/subtask2/hooks/session-idle-hook.ts +358 -358
  788. package/src/internals/plugins/subtask2/hooks/tool-hooks.ts +309 -309
  789. package/src/internals/plugins/subtask2/loop.ts +122 -122
  790. package/src/internals/plugins/subtask2/parsing/auto.ts +33 -33
  791. package/src/internals/plugins/subtask2/parsing/commands.ts +154 -154
  792. package/src/internals/plugins/subtask2/parsing/frontmatter.ts +20 -20
  793. package/src/internals/plugins/subtask2/parsing/index.ts +10 -10
  794. package/src/internals/plugins/subtask2/parsing/overrides.ts +68 -68
  795. package/src/internals/plugins/subtask2/parsing/parallel.ts +88 -88
  796. package/src/internals/plugins/subtask2/parsing/turns.ts +78 -78
  797. package/src/internals/plugins/subtask2/types.ts +41 -41
  798. package/src/internals/plugins/subtask2/utils/config.ts +100 -100
  799. package/src/internals/plugins/subtask2/utils/index.ts +7 -7
  800. package/src/internals/plugins/subtask2/utils/logger.ts +67 -67
  801. package/src/internals/plugins/subtask2/utils/prompts.ts +117 -117
  802. package/src/internals/plugins/websearch-cited/LICENSE +214 -214
  803. package/src/internals/plugins/websearch-cited/codex_prompt.txt +79 -79
  804. package/src/internals/plugins/websearch-cited/google.ts +749 -749
  805. package/src/internals/plugins/websearch-cited/index.ts +301 -301
  806. package/src/internals/plugins/websearch-cited/openai.ts +407 -407
  807. package/src/internals/plugins/websearch-cited/openrouter.ts +190 -190
  808. package/src/internals/plugins/websearch-cited/types.ts +7 -7
  809. package/src/lsp/index.ts +15 -15
  810. package/src/mcp/context7.ts +9 -9
  811. package/src/mcp/grep-app.ts +6 -6
  812. package/src/mcp/index.ts +54 -87
  813. package/src/mcp/omo-mcp-index.ts +30 -35
  814. package/src/mcp/registry.ts +132 -0
  815. package/src/mcp/types.ts +19 -9
  816. package/src/mcp/websearch.ts +44 -44
  817. package/src/permissions/index.ts +25 -25
  818. package/src/plugin/AGENTS.md +54 -54
  819. package/src/plugin/available-categories.ts +24 -24
  820. package/src/plugin/chat-headers.ts +141 -141
  821. package/src/plugin/chat-message.ts +309 -309
  822. package/src/plugin/chat-params.ts +182 -182
  823. package/src/plugin/command-execute-before.ts +80 -80
  824. package/src/plugin/event.ts +639 -639
  825. package/src/plugin/hooks/create-continuation-hooks.ts +128 -128
  826. package/src/plugin/hooks/create-core-hooks.ts +47 -47
  827. package/src/plugin/hooks/create-session-hooks.ts +286 -286
  828. package/src/plugin/hooks/create-skill-hooks.ts +50 -50
  829. package/src/plugin/hooks/create-tool-guard-hooks.ts +159 -159
  830. package/src/plugin/hooks/create-transform-hooks.ts +85 -85
  831. package/src/plugin/messages-transform.ts +28 -28
  832. package/src/plugin/normalize-tool-arg-schemas.ts +75 -75
  833. package/src/plugin/recent-synthetic-idles.ts +20 -20
  834. package/src/plugin/session-agent-resolver.ts +37 -37
  835. package/src/plugin/session-status-normalizer.ts +22 -22
  836. package/src/plugin/skill-context.ts +132 -132
  837. package/src/plugin/system-transform.ts +6 -6
  838. package/src/plugin/tool-execute-after.ts +178 -178
  839. package/src/plugin/tool-execute-before.ts +222 -222
  840. package/src/plugin/tool-registry.ts +282 -282
  841. package/src/plugin/types.ts +26 -26
  842. package/src/plugin/ultrawork-db-model-override.ts +142 -142
  843. package/src/plugin/ultrawork-model-override.ts +196 -196
  844. package/src/plugin/ultrawork-variant-availability.ts +51 -51
  845. package/src/plugin/unstable-agent-babysitter.ts +41 -41
  846. package/src/plugin-config.ts +314 -314
  847. package/src/plugin-dispose.ts +51 -51
  848. package/src/plugin-handlers/AGENTS.md +92 -92
  849. package/src/plugin-handlers/agent-config-handler.ts +502 -502
  850. package/src/plugin-handlers/agent-key-remapper.ts +39 -39
  851. package/src/plugin-handlers/agent-override-protection.ts +38 -38
  852. package/src/plugin-handlers/agent-priority-order.ts +63 -63
  853. package/src/plugin-handlers/category-config-resolver.ts +9 -9
  854. package/src/plugin-handlers/command-config-handler.ts +105 -105
  855. package/src/plugin-handlers/config-handler.ts +61 -61
  856. package/src/plugin-handlers/index.ts +10 -10
  857. package/src/plugin-handlers/mcp-config-handler.ts +205 -205
  858. package/src/plugin-handlers/plan-model-inheritance.ts +27 -27
  859. package/src/plugin-handlers/plugin-components-loader.ts +70 -70
  860. package/src/plugin-handlers/provider-config-handler.ts +73 -73
  861. package/src/plugin-handlers/strategist-agent-config-builder.ts +128 -128
  862. package/src/plugin-handlers/tool-config-handler.ts +193 -193
  863. package/src/plugin-interface.ts +83 -83
  864. package/src/plugin-state.ts +18 -18
  865. package/src/shared/AGENTS.md +54 -54
  866. package/src/shared/agent-display-names.ts +182 -182
  867. package/src/shared/agent-tool-restrictions.ts +80 -80
  868. package/src/shared/agent-variant.ts +101 -101
  869. package/src/shared/agents-config-dir.ts +23 -23
  870. package/src/shared/archive-entry-validator.ts +83 -83
  871. package/src/shared/background-output-consumption.ts +69 -69
  872. package/src/shared/binary-downloader.ts +127 -127
  873. package/src/shared/claude-config-dir.ts +16 -16
  874. package/src/shared/closure-protocol.ts +53 -53
  875. package/src/shared/command-executor/embedded-commands.ts +26 -26
  876. package/src/shared/command-executor/execute-command.ts +28 -28
  877. package/src/shared/command-executor/execute-hook-command.ts +129 -129
  878. package/src/shared/command-executor/home-directory.ts +5 -5
  879. package/src/shared/command-executor/resolve-commands-in-text.ts +49 -49
  880. package/src/shared/command-executor/shell-path.ts +27 -27
  881. package/src/shared/command-executor.ts +5 -5
  882. package/src/shared/compaction-agent-config-checkpoint.ts +42 -42
  883. package/src/shared/compaction-marker.ts +61 -61
  884. package/src/shared/config-errors.ts +18 -18
  885. package/src/shared/connected-providers-cache.ts +215 -215
  886. package/src/shared/contains-path.ts +50 -50
  887. package/src/shared/context-limit-resolver.ts +42 -42
  888. package/src/shared/data-path.ts +64 -64
  889. package/src/shared/deep-merge.ts +53 -53
  890. package/src/shared/disabled-tools.ts +19 -19
  891. package/src/shared/dynamic-truncator.ts +222 -222
  892. package/src/shared/external-plugin-detector.ts +139 -139
  893. package/src/shared/fallback-chain-from-models.ts +124 -124
  894. package/src/shared/fallback-model-availability.ts +102 -102
  895. package/src/shared/file-reference-resolver.ts +99 -99
  896. package/src/shared/file-utils.ts +34 -34
  897. package/src/shared/first-message-variant.ts +28 -28
  898. package/src/shared/frontmatter.ts +31 -31
  899. package/src/shared/git-worktree/collect-git-diff-stats.ts +56 -56
  900. package/src/shared/git-worktree/format-file-changes.ts +46 -46
  901. package/src/shared/git-worktree/index.ts +7 -7
  902. package/src/shared/git-worktree/parse-diff-numstat.ts +27 -27
  903. package/src/shared/git-worktree/parse-status-porcelain-line.ts +27 -27
  904. package/src/shared/git-worktree/parse-status-porcelain.ts +15 -15
  905. package/src/shared/git-worktree/types.ts +8 -8
  906. package/src/shared/hook-disabled.ts +22 -22
  907. package/src/shared/index.ts +80 -80
  908. package/src/shared/internal-initiator-marker.ts +18 -18
  909. package/src/shared/is-abort-error.ts +20 -20
  910. package/src/shared/json-file-cache-store.ts +98 -98
  911. package/src/shared/jsonc-parser.ts +98 -98
  912. package/src/shared/known-variants.ts +16 -16
  913. package/src/shared/legacy-plugin-warning.ts +68 -68
  914. package/src/shared/load-opencode-plugins.ts +60 -60
  915. package/src/shared/log-legacy-plugin-startup-warning.ts +46 -46
  916. package/src/shared/logger.ts +48 -48
  917. package/src/shared/merge-categories.ts +18 -18
  918. package/src/shared/migrate-legacy-config-file.ts +66 -66
  919. package/src/shared/migrate-legacy-plugin-entry.ts +75 -75
  920. package/src/shared/migration/agent-category.ts +60 -60
  921. package/src/shared/migration/agent-names.ts +100 -100
  922. package/src/shared/migration/config-migration.ts +210 -210
  923. package/src/shared/migration/hook-names.ts +40 -40
  924. package/src/shared/migration/migrations-sidecar.ts +92 -92
  925. package/src/shared/migration/model-versions.ts +50 -50
  926. package/src/shared/migration.ts +5 -5
  927. package/src/shared/model-availability.ts +294 -294
  928. package/src/shared/model-capabilities/bundled-snapshot.ts +15 -15
  929. package/src/shared/model-capabilities/get-model-capabilities.ts +140 -140
  930. package/src/shared/model-capabilities/index.ts +9 -9
  931. package/src/shared/model-capabilities/runtime-model-readers.ts +190 -190
  932. package/src/shared/model-capabilities/types.ts +80 -80
  933. package/src/shared/model-capabilities-cache.ts +213 -213
  934. package/src/shared/model-capability-aliases.ts +108 -108
  935. package/src/shared/model-capability-guardrails.ts +149 -149
  936. package/src/shared/model-capability-heuristics.ts +32 -32
  937. package/src/shared/model-error-classifier.ts +214 -214
  938. package/src/shared/model-format-normalizer.ts +20 -20
  939. package/src/shared/model-normalization.ts +8 -8
  940. package/src/shared/model-requirements.ts +26 -26
  941. package/src/shared/model-resolution-pipeline.ts +216 -216
  942. package/src/shared/model-resolution-types.ts +41 -41
  943. package/src/shared/model-resolver.ts +106 -106
  944. package/src/shared/model-sanitizer.ts +12 -12
  945. package/src/shared/model-settings-compatibility.ts +200 -200
  946. package/src/shared/model-suggestion-retry.ts +182 -182
  947. package/src/shared/normalize-sdk-response.ts +36 -36
  948. package/src/shared/opencode-command-dirs.ts +36 -36
  949. package/src/shared/opencode-config-dir-types.ts +15 -15
  950. package/src/shared/opencode-config-dir.ts +135 -135
  951. package/src/shared/opencode-http-api.ts +139 -139
  952. package/src/shared/opencode-message-dir.ts +29 -29
  953. package/src/shared/opencode-server-auth.ts +190 -190
  954. package/src/shared/opencode-storage-detection.ts +33 -33
  955. package/src/shared/opencode-storage-paths.ts +6 -6
  956. package/src/shared/opencode-version.ts +80 -80
  957. package/src/shared/parse-tools-config.ts +25 -25
  958. package/src/shared/pattern-matcher.ts +46 -46
  959. package/src/shared/permission-compat.ts +86 -86
  960. package/src/shared/plugin-command-discovery.ts +28 -28
  961. package/src/shared/plugin-entry-migrator.ts +21 -21
  962. package/src/shared/plugin-identity.ts +8 -8
  963. package/src/shared/port-utils.ts +48 -48
  964. package/src/shared/project-discovery-dirs.ts +101 -101
  965. package/src/shared/prompt-timeout-context.ts +49 -49
  966. package/src/shared/prompt-tools.ts +35 -35
  967. package/src/shared/provider-model-id-transform.ts +58 -58
  968. package/src/shared/question-denied-session-permission.ts +9 -9
  969. package/src/shared/record-type-guard.ts +3 -3
  970. package/src/shared/resolve-agent-definition-paths.ts +22 -22
  971. package/src/shared/retry-status-utils.ts +19 -19
  972. package/src/shared/runtime-plugin-config.ts +98 -98
  973. package/src/shared/safe-create-hook.ts +24 -24
  974. package/src/shared/session-category-registry.ts +27 -27
  975. package/src/shared/session-cursor.ts +108 -108
  976. package/src/shared/session-directory-resolver.ts +41 -41
  977. package/src/shared/session-injected-paths.ts +59 -59
  978. package/src/shared/session-model-state.ts +15 -15
  979. package/src/shared/session-prompt-params-helpers.ts +31 -31
  980. package/src/shared/session-prompt-params-state.ts +37 -37
  981. package/src/shared/session-tools-store.ts +18 -18
  982. package/src/shared/session-utils.ts +25 -25
  983. package/src/shared/shell-env.ts +175 -175
  984. package/src/shared/skill-path-resolver.ts +26 -26
  985. package/src/shared/snake-case.ts +44 -44
  986. package/src/shared/spawn-with-windows-hide.ts +84 -84
  987. package/src/shared/system-directive.ts +67 -67
  988. package/src/shared/task-system-enabled.ts +9 -9
  989. package/src/shared/tmux/constants.ts +12 -12
  990. package/src/shared/tmux/index.ts +3 -3
  991. package/src/shared/tmux/tmux-utils/environment.ts +13 -13
  992. package/src/shared/tmux/tmux-utils/layout.ts +96 -96
  993. package/src/shared/tmux/tmux-utils/pane-close.ts +48 -48
  994. package/src/shared/tmux/tmux-utils/pane-dimensions.ts +28 -28
  995. package/src/shared/tmux/tmux-utils/pane-replace.ts +73 -73
  996. package/src/shared/tmux/tmux-utils/pane-spawn.ts +94 -94
  997. package/src/shared/tmux/tmux-utils/server-health.ts +62 -62
  998. package/src/shared/tmux/tmux-utils/session-spawn.ts +145 -145
  999. package/src/shared/tmux/tmux-utils/window-spawn.ts +93 -93
  1000. package/src/shared/tmux/tmux-utils.ts +15 -15
  1001. package/src/shared/tmux/types.ts +4 -4
  1002. package/src/shared/tool-name.ts +27 -27
  1003. package/src/shared/truncate-description.ts +11 -11
  1004. package/src/shared/vision-capable-models-cache.ts +17 -17
  1005. package/src/shared/write-file-atomically.ts +31 -31
  1006. package/src/shared/zip-entry-listing/powershell-zip-entry-listing.ts +99 -99
  1007. package/src/shared/zip-entry-listing/python-zip-entry-listing.ts +55 -55
  1008. package/src/shared/zip-entry-listing/read-zip-symlink-target.ts +23 -23
  1009. package/src/shared/zip-entry-listing/tar-zip-entry-listing.ts +93 -93
  1010. package/src/shared/zip-entry-listing/zipinfo-zip-entry-listing.ts +72 -72
  1011. package/src/shared/zip-entry-listing.ts +13 -13
  1012. package/src/shared/zip-extractor.ts +118 -118
  1013. package/src/skills/index.ts +56 -56
  1014. package/src/testing/module-mock-lifecycle.ts +143 -143
  1015. package/src/tools/AGENTS.md +108 -108
  1016. package/src/tools/ast-grep/cli-binary-path-resolution.ts +60 -60
  1017. package/src/tools/ast-grep/cli.ts +177 -177
  1018. package/src/tools/ast-grep/constants.ts +5 -5
  1019. package/src/tools/ast-grep/downloader.ts +119 -119
  1020. package/src/tools/ast-grep/environment-check.ts +89 -89
  1021. package/src/tools/ast-grep/index.ts +5 -5
  1022. package/src/tools/ast-grep/language-support.ts +63 -63
  1023. package/src/tools/ast-grep/process-output-timeout.ts +28 -28
  1024. package/src/tools/ast-grep/result-formatter.ts +102 -102
  1025. package/src/tools/ast-grep/sg-cli-path.ts +102 -102
  1026. package/src/tools/ast-grep/sg-compact-json-output.ts +54 -54
  1027. package/src/tools/ast-grep/tools.ts +117 -117
  1028. package/src/tools/ast-grep/types.ts +61 -61
  1029. package/src/tools/background-task/AGENTS.md +53 -53
  1030. package/src/tools/background-task/clients.ts +32 -32
  1031. package/src/tools/background-task/constants.ts +9 -9
  1032. package/src/tools/background-task/create-background-cancel.ts +115 -115
  1033. package/src/tools/background-task/create-background-output.ts +159 -159
  1034. package/src/tools/background-task/create-background-task.ts +126 -126
  1035. package/src/tools/background-task/delay.ts +3 -3
  1036. package/src/tools/background-task/full-session-format.ts +148 -148
  1037. package/src/tools/background-task/index.ts +8 -8
  1038. package/src/tools/background-task/message-dir.ts +1 -1
  1039. package/src/tools/background-task/session-messages.ts +22 -22
  1040. package/src/tools/background-task/task-result-format.ts +113 -113
  1041. package/src/tools/background-task/task-status-format.ts +72 -72
  1042. package/src/tools/background-task/time-format.ts +30 -30
  1043. package/src/tools/background-task/tools.ts +11 -11
  1044. package/src/tools/background-task/truncate-text.ts +4 -4
  1045. package/src/tools/background-task/types.ts +72 -72
  1046. package/src/tools/call-omo-agent/AGENTS.md +51 -51
  1047. package/src/tools/call-omo-agent/agent-resolver.ts +64 -64
  1048. package/src/tools/call-omo-agent/background-agent-executor.ts +91 -91
  1049. package/src/tools/call-omo-agent/background-executor.ts +98 -98
  1050. package/src/tools/call-omo-agent/completion-poller.ts +65 -65
  1051. package/src/tools/call-omo-agent/constants.ts +23 -23
  1052. package/src/tools/call-omo-agent/index.ts +3 -3
  1053. package/src/tools/call-omo-agent/message-dir.ts +1 -1
  1054. package/src/tools/call-omo-agent/message-processor.ts +86 -86
  1055. package/src/tools/call-omo-agent/message-storage-directory.ts +1 -1
  1056. package/src/tools/call-omo-agent/session-creator.ts +70 -70
  1057. package/src/tools/call-omo-agent/subagent-session-creator.ts +74 -74
  1058. package/src/tools/call-omo-agent/sync-executor.ts +148 -148
  1059. package/src/tools/call-omo-agent/tool-context-with-metadata.ts +10 -10
  1060. package/src/tools/call-omo-agent/tools.ts +192 -192
  1061. package/src/tools/call-omo-agent/types.ts +34 -34
  1062. package/src/tools/delegate-task/AGENTS.md +58 -58
  1063. package/src/tools/delegate-task/anthropic-categories.ts +62 -62
  1064. package/src/tools/delegate-task/available-models.ts +64 -64
  1065. package/src/tools/delegate-task/background-continuation.ts +68 -68
  1066. package/src/tools/delegate-task/background-task.ts +165 -165
  1067. package/src/tools/delegate-task/builtin-categories.ts +33 -33
  1068. package/src/tools/delegate-task/builtin-category-definition.ts +8 -8
  1069. package/src/tools/delegate-task/cancel-unstable-agent-task.ts +19 -19
  1070. package/src/tools/delegate-task/categories.ts +77 -77
  1071. package/src/tools/delegate-task/category-resolver.ts +310 -310
  1072. package/src/tools/delegate-task/constants.ts +351 -351
  1073. package/src/tools/delegate-task/delegated-model-config.ts +20 -20
  1074. package/src/tools/delegate-task/error-formatting.ts +51 -51
  1075. package/src/tools/delegate-task/executor-types.ts +39 -39
  1076. package/src/tools/delegate-task/executor.ts +16 -16
  1077. package/src/tools/delegate-task/fallback-entry-resolution.ts +27 -27
  1078. package/src/tools/delegate-task/fallback-entry-settings.ts +20 -20
  1079. package/src/tools/delegate-task/google-categories.ts +130 -130
  1080. package/src/tools/delegate-task/index.ts +4 -4
  1081. package/src/tools/delegate-task/kimi-categories.ts +40 -40
  1082. package/src/tools/delegate-task/model-selection.ts +201 -201
  1083. package/src/tools/delegate-task/model-string-parser.ts +63 -63
  1084. package/src/tools/delegate-task/openai-categories.ts +128 -128
  1085. package/src/tools/delegate-task/parent-context-resolver.ts +47 -47
  1086. package/src/tools/delegate-task/prompt-builder.ts +107 -107
  1087. package/src/tools/delegate-task/resolve-call-id.ts +5 -5
  1088. package/src/tools/delegate-task/skill-resolver.ts +22 -22
  1089. package/src/tools/delegate-task/sub-agent.ts +70 -70
  1090. package/src/tools/delegate-task/subagent-discovery.ts +152 -152
  1091. package/src/tools/delegate-task/subagent-resolver.ts +225 -225
  1092. package/src/tools/delegate-task/sync-continuation-deps.ts +9 -9
  1093. package/src/tools/delegate-task/sync-continuation.ts +149 -149
  1094. package/src/tools/delegate-task/sync-prompt-sender.ts +137 -137
  1095. package/src/tools/delegate-task/sync-result-fetcher.ts +60 -60
  1096. package/src/tools/delegate-task/sync-session-creator.ts +29 -29
  1097. package/src/tools/delegate-task/sync-session-poller.ts +188 -188
  1098. package/src/tools/delegate-task/sync-task-deps.ts +13 -13
  1099. package/src/tools/delegate-task/sync-task-fallback.ts +68 -68
  1100. package/src/tools/delegate-task/sync-task.ts +243 -243
  1101. package/src/tools/delegate-task/time-formatter.ts +13 -13
  1102. package/src/tools/delegate-task/timing.ts +46 -46
  1103. package/src/tools/delegate-task/token-limiter.ts +123 -123
  1104. package/src/tools/delegate-task/tools.ts +259 -259
  1105. package/src/tools/delegate-task/types.ts +89 -89
  1106. package/src/tools/delegate-task/unstable-agent-task.ts +243 -243
  1107. package/src/tools/glob/cli.ts +206 -206
  1108. package/src/tools/glob/constants.ts +12 -12
  1109. package/src/tools/glob/index.ts +1 -1
  1110. package/src/tools/glob/result-formatter.ts +26 -26
  1111. package/src/tools/glob/tools.ts +49 -49
  1112. package/src/tools/glob/types.ts +23 -23
  1113. package/src/tools/grep/cli.ts +279 -279
  1114. package/src/tools/grep/constants.ts +141 -141
  1115. package/src/tools/grep/downloader.ts +128 -128
  1116. package/src/tools/grep/index.ts +1 -1
  1117. package/src/tools/grep/result-formatter.ts +60 -60
  1118. package/src/tools/grep/tools.ts +75 -75
  1119. package/src/tools/grep/types.ts +42 -42
  1120. package/src/tools/hashline-edit/AGENTS.md +92 -92
  1121. package/src/tools/hashline-edit/autocorrect-replacement-lines.ts +179 -179
  1122. package/src/tools/hashline-edit/constants.ts +10 -10
  1123. package/src/tools/hashline-edit/diff-utils.ts +53 -53
  1124. package/src/tools/hashline-edit/edit-deduplication.ts +43 -43
  1125. package/src/tools/hashline-edit/edit-operation-primitives.ts +126 -126
  1126. package/src/tools/hashline-edit/edit-operations.ts +103 -103
  1127. package/src/tools/hashline-edit/edit-ordering.ts +56 -56
  1128. package/src/tools/hashline-edit/edit-text-normalization.ts +111 -111
  1129. package/src/tools/hashline-edit/file-text-canonicalization.ts +44 -44
  1130. package/src/tools/hashline-edit/formatter-trigger.ts +132 -132
  1131. package/src/tools/hashline-edit/hash-computation.ts +154 -154
  1132. package/src/tools/hashline-edit/hashline-chunk-formatter.ts +52 -52
  1133. package/src/tools/hashline-edit/hashline-edit-diff.ts +31 -31
  1134. package/src/tools/hashline-edit/hashline-edit-executor.ts +197 -197
  1135. package/src/tools/hashline-edit/index.ts +20 -20
  1136. package/src/tools/hashline-edit/normalize-edits.ts +95 -95
  1137. package/src/tools/hashline-edit/tool-description.ts +95 -95
  1138. package/src/tools/hashline-edit/tools.ts +42 -42
  1139. package/src/tools/hashline-edit/types.ts +20 -20
  1140. package/src/tools/hashline-edit/validation.ts +181 -181
  1141. package/src/tools/index.ts +64 -64
  1142. package/src/tools/interactive-bash/constants.ts +18 -18
  1143. package/src/tools/interactive-bash/index.ts +4 -4
  1144. package/src/tools/interactive-bash/tmux-path-resolver.ts +71 -71
  1145. package/src/tools/interactive-bash/tools.ts +136 -136
  1146. package/src/tools/look-at/assistant-message-extractor.ts +67 -67
  1147. package/src/tools/look-at/constants.ts +3 -3
  1148. package/src/tools/look-at/image-converter.ts +164 -164
  1149. package/src/tools/look-at/index.ts +3 -3
  1150. package/src/tools/look-at/look-at-arguments.ts +34 -34
  1151. package/src/tools/look-at/mime-type-inference.ts +94 -94
  1152. package/src/tools/look-at/multimodal-agent-metadata.ts +166 -166
  1153. package/src/tools/look-at/multimodal-fallback-chain.ts +66 -66
  1154. package/src/tools/look-at/session-poller.ts +42 -42
  1155. package/src/tools/look-at/tools.ts +245 -245
  1156. package/src/tools/look-at/types.ts +5 -5
  1157. package/src/tools/lsp/AGENTS.md +70 -70
  1158. package/src/tools/lsp/client.ts +3 -3
  1159. package/src/tools/lsp/config.ts +3 -3
  1160. package/src/tools/lsp/constants.ts +7 -7
  1161. package/src/tools/lsp/diagnostics-tool.ts +75 -75
  1162. package/src/tools/lsp/directory-diagnostics.ts +163 -163
  1163. package/src/tools/lsp/find-references-tool.ts +43 -43
  1164. package/src/tools/lsp/goto-definition-tool.ts +42 -42
  1165. package/src/tools/lsp/index.ts +9 -9
  1166. package/src/tools/lsp/infer-extension.ts +65 -65
  1167. package/src/tools/lsp/language-config.ts +5 -5
  1168. package/src/tools/lsp/language-mappings.ts +171 -171
  1169. package/src/tools/lsp/lsp-client-connection.ts +66 -66
  1170. package/src/tools/lsp/lsp-client-transport.ts +210 -210
  1171. package/src/tools/lsp/lsp-client-wrapper.ts +116 -116
  1172. package/src/tools/lsp/lsp-client.ts +129 -129
  1173. package/src/tools/lsp/lsp-formatters.ts +193 -193
  1174. package/src/tools/lsp/lsp-manager-process-cleanup.ts +83 -83
  1175. package/src/tools/lsp/lsp-manager-temp-directory-cleanup.ts +29 -29
  1176. package/src/tools/lsp/lsp-process.ts +158 -158
  1177. package/src/tools/lsp/lsp-server.ts +217 -217
  1178. package/src/tools/lsp/rename-tools.ts +53 -53
  1179. package/src/tools/lsp/server-config-loader.ts +116 -116
  1180. package/src/tools/lsp/server-definitions.ts +91 -91
  1181. package/src/tools/lsp/server-installation.ts +58 -58
  1182. package/src/tools/lsp/server-path-bases.ts +16 -16
  1183. package/src/tools/lsp/server-resolution.ts +109 -109
  1184. package/src/tools/lsp/symbols-tool.ts +76 -76
  1185. package/src/tools/lsp/tools.ts +5 -5
  1186. package/src/tools/lsp/types.ts +124 -124
  1187. package/src/tools/lsp/workspace-edit.ts +121 -121
  1188. package/src/tools/session-manager/constants.ts +93 -93
  1189. package/src/tools/session-manager/file-storage.ts +203 -203
  1190. package/src/tools/session-manager/index.ts +3 -3
  1191. package/src/tools/session-manager/sdk-storage.ts +135 -135
  1192. package/src/tools/session-manager/sdk-unavailable.ts +43 -43
  1193. package/src/tools/session-manager/session-formatter.ts +199 -199
  1194. package/src/tools/session-manager/storage.ts +161 -161
  1195. package/src/tools/session-manager/tools.ts +197 -197
  1196. package/src/tools/session-manager/types.ts +99 -99
  1197. package/src/tools/shared/semaphore.ts +32 -32
  1198. package/src/tools/skill/constants.ts +14 -14
  1199. package/src/tools/skill/description-formatter.ts +61 -61
  1200. package/src/tools/skill/index.ts +3 -3
  1201. package/src/tools/skill/mcp-capability-formatter.ts +97 -97
  1202. package/src/tools/skill/native-skills.ts +62 -62
  1203. package/src/tools/skill/scope-priority.ts +17 -17
  1204. package/src/tools/skill/skill-body.ts +26 -26
  1205. package/src/tools/skill/skill-matcher.ts +40 -40
  1206. package/src/tools/skill/tools.ts +196 -196
  1207. package/src/tools/skill/types.ts +48 -48
  1208. package/src/tools/skill-mcp/constants.ts +9 -9
  1209. package/src/tools/skill-mcp/index.ts +3 -3
  1210. package/src/tools/skill-mcp/tools.ts +204 -204
  1211. package/src/tools/skill-mcp/types.ts +8 -8
  1212. package/src/tools/slashcommand/command-discovery.ts +161 -161
  1213. package/src/tools/slashcommand/command-output-formatter.ts +75 -75
  1214. package/src/tools/slashcommand/index.ts +2 -2
  1215. package/src/tools/slashcommand/types.ts +21 -21
  1216. package/src/tools/task/index.ts +7 -7
  1217. package/src/tools/task/task-create.ts +113 -113
  1218. package/src/tools/task/task-get.ts +47 -47
  1219. package/src/tools/task/task-list.ts +79 -79
  1220. package/src/tools/task/task-update.ts +152 -152
  1221. package/src/tools/task/todo-sync.ts +205 -205
  1222. package/src/tools/task/types.ts +77 -77
  1223. package/scripts/check_docs.ts +0 -129
  1224. package/scripts/doctor.ts +0 -522
  1225. package/scripts/measure_prompts.ts +0 -193
  1226. package/scripts/test_routing.ts +0 -294
@@ -1,2220 +1,2220 @@
1
-
2
- import type { PluginInput } from "@opencode-ai/plugin"
3
- import { isAgentNotFoundError, FALLBACK_AGENT, buildFallbackBody } from "./spawner"
4
- import type {
5
- BackgroundTask,
6
- LaunchInput,
7
- ResumeInput,
8
- } from "./types"
9
- import { TaskHistory } from "./task-history"
10
- import {
11
- log,
12
- getAgentToolRestrictions,
13
- normalizePromptTools,
14
- normalizeSDKResponse,
15
- promptWithModelSuggestionRetry,
16
- resolveInheritedPromptTools,
17
- createInternalAgentTextPart,
18
- } from "../../shared"
19
- import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
20
- import { setSessionTools } from "../../shared/session-tools-store"
21
- import { SessionCategoryRegistry } from "../../shared/session-category-registry"
22
- import { ConcurrencyManager } from "./concurrency"
23
- import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
24
- import { isInsideTmux } from "../../shared/tmux"
25
- import {
26
- shouldRetryError,
27
- hasMoreFallbacks,
28
- } from "../../shared/model-error-classifier"
29
- import {
30
- POLLING_INTERVAL_MS,
31
- TASK_CLEANUP_DELAY_MS,
32
- TASK_TTL_MS,
33
- } from "./constants"
34
-
35
- import { subagentSessions } from "../claude-code-session-state"
36
- import { getTaskToastManager } from "../task-toast-manager"
37
- import { formatDuration } from "./duration-formatter"
38
- import {
39
- buildBackgroundTaskNotificationText,
40
- type BackgroundTaskNotificationTask,
41
- } from "./background-task-notification-template"
42
- import {
43
- isAbortedSessionError,
44
- extractErrorName,
45
- extractErrorMessage,
46
- getSessionErrorMessage,
47
- isRecord,
48
- } from "./error-classifier"
49
- import { tryFallbackRetry } from "./fallback-retry-handler"
50
- import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
51
- import {
52
- findNearestMessageExcludingCompaction,
53
- resolvePromptContextFromSessionMessages,
54
- } from "./compaction-aware-message-resolver"
55
- import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
56
- import { MESSAGE_STORAGE } from "../hook-message-injector"
57
- import { join } from "node:path"
58
- import { pruneStaleTasksAndNotifications } from "./task-poller"
59
- import { checkAndInterruptStaleTasks } from "./task-poller"
60
- import { removeTaskToastTracking } from "./remove-task-toast-tracking"
61
- import { abortWithTimeout } from "./abort-with-timeout"
62
- import {
63
- MIN_SESSION_GONE_POLLS,
64
- verifySessionExists as verifySessionStillExists,
65
- } from "./session-existence"
66
- import { isActiveSessionStatus, isTerminalSessionStatus } from "./session-status-classifier"
67
- import {
68
- detectRepetitiveToolUse,
69
- recordToolCall,
70
- resolveCircuitBreakerSettings,
71
- type CircuitBreakerSettings,
72
- } from "./loop-detector"
73
- import {
74
- createSubagentDepthLimitError,
75
- createSubagentDescendantLimitError,
76
- getMaxRootSessionSpawnBudget,
77
- getMaxSubagentDepth,
78
- resolveSubagentSpawnContext,
79
- type SubagentSpawnContext,
80
- } from "./subagent-spawn-limits"
81
-
82
- type OpencodeClient = PluginInput["client"]
83
-
84
-
85
- interface MessagePartInfo {
86
- id?: string
87
- sessionID?: string
88
- type?: string
89
- tool?: string
90
- state?: { status?: string; input?: Record<string, unknown> }
91
- }
92
-
93
- interface EventProperties {
94
- sessionID?: string
95
- info?: { id?: string }
96
- [key: string]: unknown
97
- }
98
-
99
- interface Event {
100
- type: string
101
- properties?: EventProperties
102
- }
103
-
104
- function resolveMessagePartInfo(properties: EventProperties | undefined): MessagePartInfo | undefined {
105
- if (!properties || typeof properties !== "object") {
106
- return undefined
107
- }
108
-
109
- const nestedPart = properties.part
110
- if (nestedPart && typeof nestedPart === "object") {
111
- return nestedPart as MessagePartInfo
112
- }
113
-
114
- return properties as MessagePartInfo
115
- }
116
-
117
- interface Todo {
118
- content: string
119
- status: string
120
- priority: string
121
- id: string
122
- }
123
-
124
- interface QueueItem {
125
- task: BackgroundTask
126
- input: LaunchInput
127
- }
128
-
129
- export interface SubagentSessionCreatedEvent {
130
- sessionID: string
131
- parentID: string
132
- title: string
133
- }
134
-
135
- export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise<void>
136
-
137
- const MAX_TASK_REMOVAL_RESCHEDULES = 6
138
-
139
- export class BackgroundManager {
140
-
141
-
142
- private tasks: Map<string, BackgroundTask>
143
- private notifications: Map<string, BackgroundTask[]>
144
- private pendingNotifications: Map<string, string[]>
145
- private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching
146
- private client: OpencodeClient
147
- private directory: string
148
- private pollingInterval?: ReturnType<typeof setInterval>
149
- private pollingInFlight = false
150
- private concurrencyManager: ConcurrencyManager
151
- private shutdownTriggered = false
152
- private config?: BackgroundTaskConfig
153
- private tmuxEnabled: boolean
154
- private onSubagentSessionCreated?: OnSubagentSessionCreated
155
- private onShutdown?: () => void | Promise<void>
156
-
157
- private queuesByKey: Map<string, QueueItem[]> = new Map()
158
- private processingKeys: Set<string> = new Set()
159
- private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
160
- private completedTaskSummaries: Map<string, BackgroundTaskNotificationTask[]> = new Map()
161
- private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
162
- private notificationQueueByParent: Map<string, Promise<void>> = new Map()
163
- private observedOutputSessions: Set<string> = new Set()
164
- private observedIncompleteTodosBySession: Map<string, boolean> = new Map()
165
- private rootDescendantCounts: Map<string, number>
166
- private preStartDescendantReservations: Set<string>
167
- private enableParentSessionNotifications: boolean
168
- readonly taskHistory = new TaskHistory()
169
- private cachedCircuitBreakerSettings?: CircuitBreakerSettings
170
-
171
- constructor(
172
- ctx: PluginInput,
173
- config?: BackgroundTaskConfig,
174
- options?: {
175
- tmuxConfig?: TmuxConfig
176
- onSubagentSessionCreated?: OnSubagentSessionCreated
177
- onShutdown?: () => void | Promise<void>
178
- enableParentSessionNotifications?: boolean
179
- }
180
- ) {
181
- this.tasks = new Map()
182
- this.notifications = new Map()
183
- this.pendingNotifications = new Map()
184
- this.pendingByParent = new Map()
185
- this.client = ctx.client
186
- this.directory = ctx.directory
187
- this.concurrencyManager = new ConcurrencyManager(config)
188
- this.config = config
189
- this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false
190
- this.onSubagentSessionCreated = options?.onSubagentSessionCreated
191
- this.onShutdown = options?.onShutdown
192
- this.rootDescendantCounts = new Map()
193
- this.preStartDescendantReservations = new Set()
194
- this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true
195
- this.registerProcessCleanup()
196
- }
197
-
198
- private async abortSessionWithLogging(sessionID: string, reason: string): Promise<void> {
199
- try {
200
- await abortWithTimeout(this.client, sessionID)
201
- } catch (error) {
202
- log(`[background-agent] Failed to abort session during ${reason}:`, {
203
- sessionID,
204
- error,
205
- })
206
- }
207
- }
208
-
209
- async assertCanSpawn(parentSessionID: string): Promise<SubagentSpawnContext> {
210
- const spawnContext = await resolveSubagentSpawnContext(this.client, parentSessionID, this.directory)
211
- const maxDepth = getMaxSubagentDepth(this.config)
212
- if (spawnContext.childDepth > maxDepth) {
213
- throw createSubagentDepthLimitError({
214
- childDepth: spawnContext.childDepth,
215
- maxDepth,
216
- parentSessionID,
217
- rootSessionID: spawnContext.rootSessionID,
218
- })
219
- }
220
-
221
- const maxRootSessionSpawnBudget = getMaxRootSessionSpawnBudget(this.config)
222
- const descendantCount = this.rootDescendantCounts.get(spawnContext.rootSessionID) ?? 0
223
- if (descendantCount >= maxRootSessionSpawnBudget) {
224
- throw createSubagentDescendantLimitError({
225
- rootSessionID: spawnContext.rootSessionID,
226
- descendantCount,
227
- maxDescendants: maxRootSessionSpawnBudget,
228
- })
229
- }
230
-
231
- return spawnContext
232
- }
233
-
234
- async reserveSubagentSpawn(parentSessionID: string): Promise<{
235
- spawnContext: SubagentSpawnContext
236
- descendantCount: number
237
- commit: () => number
238
- rollback: () => void
239
- }> {
240
- const spawnContext = await this.assertCanSpawn(parentSessionID)
241
- const descendantCount = this.registerRootDescendant(spawnContext.rootSessionID)
242
- let settled = false
243
-
244
- return {
245
- spawnContext,
246
- descendantCount,
247
- commit: () => {
248
- settled = true
249
- return descendantCount
250
- },
251
- rollback: () => {
252
- if (settled) return
253
- settled = true
254
- this.unregisterRootDescendant(spawnContext.rootSessionID)
255
- },
256
- }
257
- }
258
-
259
- private registerRootDescendant(rootSessionID: string): number {
260
- const nextCount = (this.rootDescendantCounts.get(rootSessionID) ?? 0) + 1
261
- this.rootDescendantCounts.set(rootSessionID, nextCount)
262
- return nextCount
263
- }
264
-
265
- private unregisterRootDescendant(rootSessionID: string): void {
266
- const currentCount = this.rootDescendantCounts.get(rootSessionID) ?? 0
267
- if (currentCount <= 1) {
268
- this.rootDescendantCounts.delete(rootSessionID)
269
- return
270
- }
271
-
272
- this.rootDescendantCounts.set(rootSessionID, currentCount - 1)
273
- }
274
-
275
- private markPreStartDescendantReservation(task: BackgroundTask): void {
276
- this.preStartDescendantReservations.add(task.id)
277
- }
278
-
279
- private settlePreStartDescendantReservation(task: BackgroundTask): void {
280
- this.preStartDescendantReservations.delete(task.id)
281
- }
282
-
283
- private rollbackPreStartDescendantReservation(task: BackgroundTask): void {
284
- if (!this.preStartDescendantReservations.delete(task.id)) {
285
- return
286
- }
287
-
288
- if (!task.rootSessionID) {
289
- return
290
- }
291
-
292
- this.unregisterRootDescendant(task.rootSessionID)
293
- }
294
-
295
- async launch(input: LaunchInput): Promise<BackgroundTask> {
296
- log("[background-agent] launch() called with:", {
297
- agent: input.agent,
298
- model: input.model,
299
- description: input.description,
300
- parentSessionID: input.parentSessionID,
301
- })
302
-
303
- if (!input.agent || input.agent.trim() === "") {
304
- throw new Error("Agent parameter is required")
305
- }
306
-
307
- const spawnReservation = await this.reserveSubagentSpawn(input.parentSessionID)
308
-
309
- try {
310
- log("[background-agent] spawn guard passed", {
311
- parentSessionID: input.parentSessionID,
312
- rootSessionID: spawnReservation.spawnContext.rootSessionID,
313
- childDepth: spawnReservation.spawnContext.childDepth,
314
- descendantCount: spawnReservation.descendantCount,
315
- })
316
-
317
- // Create task immediately with status="pending"
318
- const task: BackgroundTask = {
319
- id: `bg_${crypto.randomUUID().slice(0, 8)}`,
320
- status: "pending",
321
- queuedAt: new Date(),
322
- rootSessionID: spawnReservation.spawnContext.rootSessionID,
323
- // Do NOT set startedAt - will be set when running
324
- // Do NOT set sessionID - will be set when running
325
- description: input.description,
326
- prompt: input.prompt,
327
- agent: input.agent,
328
- spawnDepth: spawnReservation.spawnContext.childDepth,
329
- parentSessionID: input.parentSessionID,
330
- parentMessageID: input.parentMessageID,
331
- parentModel: input.parentModel,
332
- parentAgent: input.parentAgent,
333
- parentTools: input.parentTools,
334
- model: input.model,
335
- fallbackChain: input.fallbackChain,
336
- attemptCount: 0,
337
- category: input.category,
338
- }
339
-
340
- this.tasks.set(task.id, task)
341
- this.taskHistory.record(input.parentSessionID, { id: task.id, agent: input.agent, description: input.description, status: "pending", category: input.category })
342
-
343
- // Track for batched notifications immediately (pending state)
344
- if (input.parentSessionID) {
345
- const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
346
- pending.add(task.id)
347
- this.pendingByParent.set(input.parentSessionID, pending)
348
- }
349
-
350
- // Add to queue
351
- const key = this.getConcurrencyKeyFromInput(input)
352
- const queue = this.queuesByKey.get(key) ?? []
353
- queue.push({ task, input })
354
- this.queuesByKey.set(key, queue)
355
-
356
- log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: queue.length })
357
-
358
- const toastManager = getTaskToastManager()
359
- if (toastManager) {
360
- toastManager.addTask({
361
- id: task.id,
362
- description: input.description,
363
- agent: input.agent,
364
- isBackground: true,
365
- status: "queued",
366
- skills: input.skills,
367
- })
368
- }
369
-
370
- spawnReservation.commit()
371
- this.markPreStartDescendantReservation(task)
372
-
373
- // Trigger processing (fire-and-forget)
374
- void this.processKey(key)
375
-
376
- return { ...task }
377
- } catch (error) {
378
- spawnReservation.rollback()
379
- throw error
380
- }
381
- }
382
-
383
- private async processKey(key: string): Promise<void> {
384
- if (this.processingKeys.has(key)) {
385
- return
386
- }
387
-
388
- this.processingKeys.add(key)
389
-
390
- try {
391
- const queue = this.queuesByKey.get(key)
392
- while (queue && queue.length > 0) {
393
- const item = queue.shift()
394
- if (!item) {
395
- continue
396
- }
397
-
398
- await this.concurrencyManager.acquire(key)
399
-
400
- if (item.task.status === "cancelled" || item.task.status === "error" || item.task.status === "interrupt") {
401
- this.rollbackPreStartDescendantReservation(item.task)
402
- this.concurrencyManager.release(key)
403
- continue
404
- }
405
-
406
- try {
407
- await this.startTask(item)
408
- } catch (error) {
409
- log("[background-agent] Error starting task:", error)
410
- this.rollbackPreStartDescendantReservation(item.task)
411
-
412
- // Mark task as error so the parent polling loop detects the failure
413
- // instead of leaving it in a zombie "running" state with no prompt sent
414
- item.task.status = "error"
415
- item.task.error = error instanceof Error ? error.message : String(error)
416
- item.task.completedAt = new Date()
417
-
418
- if (item.task.concurrencyKey) {
419
- this.concurrencyManager.release(item.task.concurrencyKey)
420
- item.task.concurrencyKey = undefined
421
- } else {
422
- this.concurrencyManager.release(key)
423
- }
424
-
425
- removeTaskToastTracking(item.task.id)
426
-
427
- // Abort the orphaned session if one was created before the error
428
- if (item.task.sessionID) {
429
- await this.abortSessionWithLogging(item.task.sessionID, "startTask error cleanup")
430
- }
431
-
432
- this.markForNotification(item.task)
433
- this.enqueueNotificationForParent(item.task.parentSessionID, () => this.notifyParentSession(item.task)).catch(err => {
434
- log("[background-agent] Failed to notify on startTask error:", err)
435
- })
436
- }
437
- }
438
- } finally {
439
- this.processingKeys.delete(key)
440
- }
441
- }
442
-
443
- private async startTask(item: QueueItem): Promise<void> {
444
- const { task, input } = item
445
-
446
- log("[background-agent] Starting task:", {
447
- taskId: task.id,
448
- agent: input.agent,
449
- model: input.model,
450
- })
451
-
452
- const concurrencyKey = this.getConcurrencyKeyFromInput(input)
453
-
454
- const parentSession = await this.client.session.get({
455
- path: { id: input.parentSessionID },
456
- query: { directory: this.directory },
457
- }).catch((err) => {
458
- log(`[background-agent] Failed to get parent session: ${err}`)
459
- return null
460
- })
461
- const parentDirectory = parentSession?.data?.directory ?? this.directory
462
- log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`)
463
-
464
- const createResult = await this.client.session.create({
465
- body: {
466
- parentID: input.parentSessionID,
467
- title: `${input.description} (@${input.agent} subagent)`,
468
- ...(input.sessionPermission ? { permission: input.sessionPermission } : {}),
469
- } as Record<string, unknown>,
470
- query: {
471
- directory: parentDirectory,
472
- },
473
- })
474
-
475
- if (createResult.error) {
476
- throw new Error(`Failed to create background session: ${createResult.error}`)
477
- }
478
-
479
- if (!createResult.data?.id) {
480
- throw new Error("Failed to create background session: API returned no session ID")
481
- }
482
-
483
- const sessionID = createResult.data.id
484
-
485
- if (task.status === "cancelled") {
486
- await this.abortSessionWithLogging(sessionID, "cancelled pre-start cleanup")
487
- this.concurrencyManager.release(concurrencyKey)
488
- return
489
- }
490
-
491
- this.settlePreStartDescendantReservation(task)
492
- subagentSessions.add(sessionID)
493
-
494
- log("[background-agent] tmux callback check", {
495
- hasCallback: !!this.onSubagentSessionCreated,
496
- tmuxEnabled: this.tmuxEnabled,
497
- isInsideTmux: isInsideTmux(),
498
- sessionID,
499
- parentID: input.parentSessionID,
500
- })
501
-
502
- if (this.onSubagentSessionCreated && this.tmuxEnabled && isInsideTmux()) {
503
- log("[background-agent] Invoking tmux callback NOW", { sessionID })
504
- await this.onSubagentSessionCreated({
505
- sessionID,
506
- parentID: input.parentSessionID,
507
- title: input.description,
508
- }).catch((err) => {
509
- log("[background-agent] Failed to spawn tmux pane:", err)
510
- })
511
- log("[background-agent] tmux callback completed, waiting 200ms")
512
- await new Promise(r => setTimeout(r, 200))
513
- } else {
514
- log("[background-agent] SKIP tmux callback - conditions not met")
515
- }
516
-
517
- if (this.tasks.get(task.id)?.status === "cancelled") {
518
- await this.abortSessionWithLogging(sessionID, "cancelled during tmux setup")
519
- subagentSessions.delete(sessionID)
520
- if (task.rootSessionID) {
521
- this.unregisterRootDescendant(task.rootSessionID)
522
- }
523
- this.concurrencyManager.release(concurrencyKey)
524
- return
525
- }
526
-
527
- task.status = "running"
528
- task.startedAt = new Date()
529
- task.sessionID = sessionID
530
- task.progress = {
531
- toolCalls: 0,
532
- lastUpdate: new Date(),
533
- }
534
- task.concurrencyKey = concurrencyKey
535
- task.concurrencyGroup = concurrencyKey
536
-
537
- this.taskHistory.record(input.parentSessionID, { id: task.id, sessionID, agent: input.agent, description: input.description, status: "running", category: input.category, startedAt: task.startedAt })
538
- this.startPolling()
539
-
540
- log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
541
-
542
- const toastManager = getTaskToastManager()
543
- if (toastManager) {
544
- toastManager.updateTask(task.id, "running")
545
- }
546
-
547
- log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
548
- sessionID,
549
- agent: input.agent,
550
- model: input.model,
551
- hasSkillContent: !!input.skillContent,
552
- promptLength: input.prompt.length,
553
- })
554
-
555
- // Fire-and-forget prompt via promptAsync (no response body needed)
556
- // OpenCode prompt payload accepts model provider/model IDs and top-level variant only.
557
- // Temperature/topP and provider-specific options are applied through chat.params.
558
- const launchModel = input.model
559
- ? {
560
- providerID: input.model.providerID,
561
- modelID: input.model.modelID,
562
- }
563
- : undefined
564
- const launchVariant = input.model?.variant
565
-
566
- if (input.model) {
567
- applySessionPromptParams(sessionID, input.model)
568
- }
569
-
570
- const promptBody = {
571
- agent: input.agent,
572
- ...(launchModel ? { model: launchModel } : {}),
573
- ...(launchVariant ? { variant: launchVariant } : {}),
574
- system: input.skillContent,
575
- tools: (() => {
576
- const tools = {
577
- task: false,
578
- call_omo_agent: true,
579
- question: false,
580
- ...getAgentToolRestrictions(input.agent),
581
- }
582
- setSessionTools(sessionID, tools)
583
- return tools
584
- })(),
585
- parts: [createInternalAgentTextPart(input.prompt)],
586
- }
587
-
588
- promptWithModelSuggestionRetry(this.client, {
589
- path: { id: sessionID },
590
- body: promptBody,
591
- }).catch(async (error) => {
592
- // Retry with fallback agent if the original agent was unregistered (e.g., after a model switch)
593
- if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) {
594
- log("[background-agent] Agent not found, retrying with fallback agent", {
595
- original: input.agent,
596
- fallback: FALLBACK_AGENT,
597
- taskId: task.id,
598
- })
599
- try {
600
- const fallbackBody = buildFallbackBody(promptBody, FALLBACK_AGENT)
601
- setSessionTools(sessionID, fallbackBody.tools as Record<string, boolean>)
602
- await promptWithModelSuggestionRetry(this.client, {
603
- path: { id: sessionID },
604
- body: fallbackBody,
605
- })
606
- task.agent = FALLBACK_AGENT
607
- return
608
- } catch (retryError) {
609
- log("[background-agent] Fallback agent also failed:", retryError)
610
- }
611
- }
612
-
613
- log("[background-agent] promptAsync error:", error)
614
- const existingTask = this.findBySession(sessionID)
615
- if (existingTask) {
616
- existingTask.status = "interrupt"
617
- const errorMessage = error instanceof Error ? error.message : String(error)
618
- if (errorMessage.includes("agent.name") || errorMessage.includes("undefined") || isAgentNotFoundError(error)) {
619
- existingTask.error = `Agent "${input.agent}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`
620
- } else {
621
- existingTask.error = errorMessage
622
- }
623
- existingTask.completedAt = new Date()
624
- if (existingTask.rootSessionID) {
625
- this.unregisterRootDescendant(existingTask.rootSessionID)
626
- }
627
- if (existingTask.concurrencyKey) {
628
- this.concurrencyManager.release(existingTask.concurrencyKey)
629
- existingTask.concurrencyKey = undefined
630
- }
631
-
632
- removeTaskToastTracking(existingTask.id)
633
-
634
- // Abort the session to prevent infinite polling hang
635
- // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
636
- await this.abortSessionWithLogging(sessionID, "launch error cleanup")
637
-
638
- this.markForNotification(existingTask)
639
- this.enqueueNotificationForParent(existingTask.parentSessionID, () => this.notifyParentSession(existingTask)).catch(err => {
640
- log("[background-agent] Failed to notify on error:", err)
641
- })
642
- }
643
- })
644
- }
645
-
646
- getTask(id: string): BackgroundTask | undefined {
647
- return this.tasks.get(id)
648
- }
649
-
650
- getTasksByParentSession(sessionID: string): BackgroundTask[] {
651
- const result: BackgroundTask[] = []
652
- for (const task of this.tasks.values()) {
653
- if (task.parentSessionID === sessionID) {
654
- result.push(task)
655
- }
656
- }
657
- return result
658
- }
659
-
660
- getAllDescendantTasks(sessionID: string): BackgroundTask[] {
661
- const result: BackgroundTask[] = []
662
- const directChildren = this.getTasksByParentSession(sessionID)
663
-
664
- for (const child of directChildren) {
665
- result.push(child)
666
- if (child.sessionID) {
667
- const descendants = this.getAllDescendantTasks(child.sessionID)
668
- result.push(...descendants)
669
- }
670
- }
671
-
672
- return result
673
- }
674
-
675
- findBySession(sessionID: string): BackgroundTask | undefined {
676
- for (const task of this.tasks.values()) {
677
- if (task.sessionID === sessionID) {
678
- return task
679
- }
680
- }
681
- return undefined
682
- }
683
-
684
- private getConcurrencyKeyFromInput(input: LaunchInput): string {
685
- if (input.model) {
686
- return `${input.model.providerID}/${input.model.modelID}`
687
- }
688
- return input.agent
689
- }
690
-
691
- /**
692
- * Track a task created elsewhere (e.g., from task) for notification tracking.
693
- * This allows tasks created by other tools to receive the same toast/prompt notifications.
694
- */
695
- async trackTask(input: {
696
- taskId: string
697
- sessionID: string
698
- parentSessionID: string
699
- description: string
700
- agent?: string
701
- parentAgent?: string
702
- concurrencyKey?: string
703
- }): Promise<BackgroundTask> {
704
- const existingTask = this.tasks.get(input.taskId)
705
- if (existingTask) {
706
- // P2 fix: Clean up old parent's pending set BEFORE changing parent
707
- // Otherwise cleanupPendingByParent would use the new parent ID
708
- const parentChanged = input.parentSessionID !== existingTask.parentSessionID
709
- if (parentChanged) {
710
- this.cleanupPendingByParent(existingTask) // Clean from OLD parent
711
- existingTask.parentSessionID = input.parentSessionID
712
- }
713
- if (input.parentAgent !== undefined) {
714
- existingTask.parentAgent = input.parentAgent
715
- }
716
- if (!existingTask.concurrencyGroup) {
717
- existingTask.concurrencyGroup = input.concurrencyKey ?? existingTask.agent
718
- }
719
-
720
- if (existingTask.sessionID) {
721
- subagentSessions.add(existingTask.sessionID)
722
- }
723
- this.startPolling()
724
-
725
- // Track for batched notifications if task is pending or running
726
- if (existingTask.status === "pending" || existingTask.status === "running") {
727
- const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
728
- pending.add(existingTask.id)
729
- this.pendingByParent.set(input.parentSessionID, pending)
730
- } else if (!parentChanged) {
731
- // Only clean up if parent didn't change (already cleaned above if it did)
732
- this.cleanupPendingByParent(existingTask)
733
- }
734
-
735
- log("[background-agent] External task already registered:", { taskId: existingTask.id, sessionID: existingTask.sessionID, status: existingTask.status })
736
-
737
- return existingTask
738
- }
739
-
740
- const concurrencyGroup = input.concurrencyKey ?? input.agent ?? "task"
741
-
742
- // Acquire concurrency slot if a key is provided
743
- if (input.concurrencyKey) {
744
- await this.concurrencyManager.acquire(input.concurrencyKey)
745
- }
746
-
747
- const task: BackgroundTask = {
748
- id: input.taskId,
749
- sessionID: input.sessionID,
750
- parentSessionID: input.parentSessionID,
751
- parentMessageID: "",
752
- description: input.description,
753
- prompt: "",
754
- agent: input.agent || "task",
755
- status: "running",
756
- startedAt: new Date(),
757
- progress: {
758
- toolCalls: 0,
759
- lastUpdate: new Date(),
760
- },
761
- parentAgent: input.parentAgent,
762
- concurrencyKey: input.concurrencyKey,
763
- concurrencyGroup,
764
- }
765
-
766
- this.tasks.set(task.id, task)
767
- subagentSessions.add(input.sessionID)
768
- this.startPolling()
769
- this.taskHistory.record(input.parentSessionID, { id: task.id, sessionID: input.sessionID, agent: input.agent || "task", description: input.description, status: "running", startedAt: task.startedAt })
770
-
771
- if (input.parentSessionID) {
772
- const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
773
- pending.add(task.id)
774
- this.pendingByParent.set(input.parentSessionID, pending)
775
- }
776
-
777
- log("[background-agent] Registered external task:", { taskId: task.id, sessionID: input.sessionID })
778
-
779
- return task
780
- }
781
-
782
- async resume(input: ResumeInput): Promise<BackgroundTask> {
783
- const existingTask = this.findBySession(input.sessionId)
784
- if (!existingTask) {
785
- throw new Error(`Task not found for session: ${input.sessionId}`)
786
- }
787
-
788
- if (!existingTask.sessionID) {
789
- throw new Error(`Task has no sessionID: ${existingTask.id}`)
790
- }
791
-
792
- if (existingTask.status === "running") {
793
- log("[background-agent] Resume skipped - task already running:", {
794
- taskId: existingTask.id,
795
- sessionID: existingTask.sessionID,
796
- })
797
- return existingTask
798
- }
799
-
800
- const completionTimer = this.completionTimers.get(existingTask.id)
801
- if (completionTimer) {
802
- clearTimeout(completionTimer)
803
- this.completionTimers.delete(existingTask.id)
804
- }
805
-
806
- // Re-acquire concurrency using the persisted concurrency group
807
- const concurrencyKey = existingTask.concurrencyGroup ?? existingTask.agent
808
- await this.concurrencyManager.acquire(concurrencyKey)
809
- existingTask.concurrencyKey = concurrencyKey
810
- existingTask.concurrencyGroup = concurrencyKey
811
-
812
-
813
- existingTask.status = "running"
814
- existingTask.completedAt = undefined
815
- existingTask.error = undefined
816
- existingTask.parentSessionID = input.parentSessionID
817
- existingTask.parentMessageID = input.parentMessageID
818
- existingTask.parentModel = input.parentModel
819
- existingTask.parentAgent = input.parentAgent
820
- if (input.parentTools) {
821
- existingTask.parentTools = input.parentTools
822
- }
823
- // Reset startedAt on resume to prevent immediate completion
824
- // The MIN_IDLE_TIME_MS check uses startedAt, so resumed tasks need fresh timing
825
- existingTask.startedAt = new Date()
826
-
827
- existingTask.progress = {
828
- toolCalls: existingTask.progress?.toolCalls ?? 0,
829
- toolCallWindow: existingTask.progress?.toolCallWindow,
830
- countedToolPartIDs: existingTask.progress?.countedToolPartIDs,
831
- lastUpdate: new Date(),
832
- }
833
-
834
- this.startPolling()
835
- if (existingTask.sessionID) {
836
- subagentSessions.add(existingTask.sessionID)
837
- }
838
-
839
- if (input.parentSessionID) {
840
- const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
841
- pending.add(existingTask.id)
842
- this.pendingByParent.set(input.parentSessionID, pending)
843
- }
844
-
845
- const toastManager = getTaskToastManager()
846
- if (toastManager) {
847
- toastManager.addTask({
848
- id: existingTask.id,
849
- description: existingTask.description,
850
- agent: existingTask.agent,
851
- isBackground: true,
852
- })
853
- }
854
-
855
- log("[background-agent] Resuming task:", { taskId: existingTask.id, sessionID: existingTask.sessionID })
856
-
857
- log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", {
858
- sessionID: existingTask.sessionID,
859
- agent: existingTask.agent,
860
- model: existingTask.model,
861
- promptLength: input.prompt.length,
862
- })
863
-
864
- // Fire-and-forget prompt via promptAsync (no response body needed)
865
- // Resume uses the same PromptInput contract as launch: model IDs plus top-level variant.
866
- const resumeModel = existingTask.model
867
- ? {
868
- providerID: existingTask.model.providerID,
869
- modelID: existingTask.model.modelID,
870
- }
871
- : undefined
872
- const resumeVariant = existingTask.model?.variant
873
-
874
- if (existingTask.model) {
875
- applySessionPromptParams(existingTask.sessionID!, existingTask.model)
876
- }
877
-
878
- this.client.session.promptAsync({
879
- path: { id: existingTask.sessionID },
880
- body: {
881
- agent: existingTask.agent,
882
- ...(resumeModel ? { model: resumeModel } : {}),
883
- ...(resumeVariant ? { variant: resumeVariant } : {}),
884
- tools: (() => {
885
- const tools = {
886
- task: false,
887
- call_omo_agent: true,
888
- question: false,
889
- ...getAgentToolRestrictions(existingTask.agent),
890
- }
891
- setSessionTools(existingTask.sessionID!, tools)
892
- return tools
893
- })(),
894
- parts: [createInternalAgentTextPart(input.prompt)],
895
- },
896
- }).catch(async (error) => {
897
- log("[background-agent] resume prompt error:", error)
898
- existingTask.status = "interrupt"
899
- const errorMessage = error instanceof Error ? error.message : String(error)
900
- existingTask.error = errorMessage
901
- existingTask.completedAt = new Date()
902
- if (existingTask.rootSessionID) {
903
- this.unregisterRootDescendant(existingTask.rootSessionID)
904
- }
905
-
906
- // Release concurrency on error to prevent slot leaks
907
- if (existingTask.concurrencyKey) {
908
- this.concurrencyManager.release(existingTask.concurrencyKey)
909
- existingTask.concurrencyKey = undefined
910
- }
911
-
912
- removeTaskToastTracking(existingTask.id)
913
-
914
- // Abort the session to prevent infinite polling hang
915
- // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
916
- if (existingTask.sessionID) {
917
- await this.abortSessionWithLogging(existingTask.sessionID, "resume error cleanup")
918
- }
919
-
920
- this.markForNotification(existingTask)
921
- this.enqueueNotificationForParent(existingTask.parentSessionID, () => this.notifyParentSession(existingTask)).catch(err => {
922
- log("[background-agent] Failed to notify on resume error:", err)
923
- })
924
- })
925
-
926
- return existingTask
927
- }
928
-
929
- private async checkSessionTodos(sessionID: string): Promise<boolean> {
930
- const observedIncompleteTodos = this.observedIncompleteTodosBySession.get(sessionID)
931
- if (observedIncompleteTodos !== undefined) {
932
- return observedIncompleteTodos
933
- }
934
-
935
- try {
936
- const response = await this.client.session.todo({
937
- path: { id: sessionID },
938
- })
939
- const todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true })
940
- if (!todos || todos.length === 0) {
941
- this.observedIncompleteTodosBySession.set(sessionID, false)
942
- return false
943
- }
944
-
945
- const incomplete = todos.filter(
946
- (t) => t.status !== "completed" && t.status !== "cancelled"
947
- )
948
- const hasIncompleteTodos = incomplete.length > 0
949
- this.observedIncompleteTodosBySession.set(sessionID, hasIncompleteTodos)
950
- return hasIncompleteTodos
951
- } catch (error) {
952
- log("[background-agent] Failed to check session todos:", {
953
- sessionID,
954
- error,
955
- })
956
- return false
957
- }
958
- }
959
-
960
- private markSessionOutputObserved(sessionID: string): void {
961
- this.observedOutputSessions.add(sessionID)
962
- }
963
-
964
- private clearSessionOutputObserved(sessionID: string): void {
965
- this.observedOutputSessions.delete(sessionID)
966
- }
967
-
968
- private clearSessionTodoObservation(sessionID: string): void {
969
- this.observedIncompleteTodosBySession.delete(sessionID)
970
- }
971
-
972
- private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined): boolean {
973
- if (!partInfo?.sessionID) return false
974
- if (partInfo.tool) return true
975
- if (partInfo.type === "tool" || partInfo.type === "tool_result") return true
976
- if (partInfo.type === "text" || partInfo.type === "reasoning") return true
977
-
978
- const field = typeof (partInfo as { field?: unknown }).field === "string"
979
- ? (partInfo as { field?: string }).field
980
- : undefined
981
- return field === "text" || field === "reasoning"
982
- }
983
-
984
- handleEvent(event: Event): void {
985
- const props = event.properties
986
-
987
- if (event.type === "message.updated") {
988
- const info = props?.info
989
- if (!info || typeof info !== "object") return
990
-
991
- const sessionID = (info as Record<string, unknown>)["sessionID"]
992
- const role = (info as Record<string, unknown>)["role"]
993
- if (typeof sessionID !== "string") return
994
-
995
- if (role === "tool") {
996
- this.markSessionOutputObserved(sessionID)
997
- }
998
-
999
- if (role !== "assistant") return
1000
-
1001
- const task = this.findBySession(sessionID)
1002
- if (!task || task.status !== "running") return
1003
-
1004
- const assistantError = (info as Record<string, unknown>)["error"]
1005
- if (!assistantError) return
1006
-
1007
- const errorInfo = {
1008
- name: extractErrorName(assistantError),
1009
- message: extractErrorMessage(assistantError),
1010
- }
1011
- void this.tryFallbackRetry(task, errorInfo, "message.updated").catch((error) => {
1012
- log("[background-agent] Error handling message.updated fallback retry:", {
1013
- error,
1014
- taskId: task.id,
1015
- })
1016
- })
1017
- }
1018
-
1019
- if (event.type === "message.part.updated" || event.type === "message.part.delta") {
1020
- const partInfo = resolveMessagePartInfo(props)
1021
- const sessionID = partInfo?.sessionID
1022
- if (!sessionID) return
1023
-
1024
- const task = this.findBySession(sessionID)
1025
- if (!task) return
1026
-
1027
- if (this.hasOutputSignalFromPart(partInfo)) {
1028
- this.markSessionOutputObserved(sessionID)
1029
- }
1030
-
1031
- // Clear any pending idle deferral timer since the task is still active
1032
- const existingTimer = this.idleDeferralTimers.get(task.id)
1033
- if (existingTimer) {
1034
- clearTimeout(existingTimer)
1035
- this.idleDeferralTimers.delete(task.id)
1036
- }
1037
-
1038
- if (!task.progress) {
1039
- task.progress = {
1040
- toolCalls: 0,
1041
- lastUpdate: new Date(),
1042
- }
1043
- }
1044
- task.progress.lastUpdate = new Date()
1045
-
1046
- if (partInfo?.type === "tool" || partInfo?.tool) {
1047
- const countedToolPartIDs = task.progress.countedToolPartIDs ?? new Set<string>()
1048
- const shouldCountToolCall =
1049
- !partInfo.id ||
1050
- partInfo.state?.status !== "running" ||
1051
- !countedToolPartIDs.has(partInfo.id)
1052
-
1053
- if (!shouldCountToolCall) {
1054
- return
1055
- }
1056
-
1057
- if (partInfo.id && partInfo.state?.status === "running") {
1058
- countedToolPartIDs.add(partInfo.id)
1059
- task.progress.countedToolPartIDs = countedToolPartIDs
1060
- }
1061
-
1062
- task.progress.toolCalls += 1
1063
- task.progress.lastTool = partInfo.tool
1064
- const circuitBreaker = this.cachedCircuitBreakerSettings ?? resolveCircuitBreakerSettings(this.config)
1065
- this.cachedCircuitBreakerSettings = circuitBreaker
1066
- if (partInfo.tool) {
1067
- task.progress.toolCallWindow = recordToolCall(
1068
- task.progress.toolCallWindow,
1069
- partInfo.tool,
1070
- circuitBreaker,
1071
- partInfo.state?.input
1072
- )
1073
-
1074
- if (circuitBreaker.enabled) {
1075
- const loopDetection = detectRepetitiveToolUse(task.progress.toolCallWindow)
1076
- if (loopDetection.triggered) {
1077
- log("[background-agent] Circuit breaker: consecutive tool usage detected", {
1078
- taskId: task.id,
1079
- agent: task.agent,
1080
- sessionID,
1081
- toolName: loopDetection.toolName,
1082
- repeatedCount: loopDetection.repeatedCount,
1083
- })
1084
- void this.cancelTask(task.id, {
1085
- source: "circuit-breaker",
1086
- reason: `Subagent called ${loopDetection.toolName} ${loopDetection.repeatedCount} consecutive times (threshold: ${circuitBreaker.consecutiveThreshold}). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage.`,
1087
- })
1088
- return
1089
- }
1090
- }
1091
- }
1092
-
1093
- const maxToolCalls = circuitBreaker.maxToolCalls
1094
- if (task.progress.toolCalls >= maxToolCalls) {
1095
- log("[background-agent] Circuit breaker: tool call limit reached", {
1096
- taskId: task.id,
1097
- toolCalls: task.progress.toolCalls,
1098
- maxToolCalls,
1099
- agent: task.agent,
1100
- sessionID,
1101
- })
1102
- void this.cancelTask(task.id, {
1103
- source: "circuit-breaker",
1104
- reason: `Subagent exceeded maximum tool call limit (${maxToolCalls}). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage.`,
1105
- })
1106
- }
1107
- }
1108
- }
1109
-
1110
- if (event.type === "todo.updated") {
1111
- const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined
1112
- const todos = Array.isArray(props?.todos) ? props.todos : undefined
1113
- if (!sessionID || !todos) return
1114
-
1115
- const hasIncompleteTodos = todos.some((todo) => {
1116
- if (!todo || typeof todo !== "object") return false
1117
- const status = (todo as { status?: unknown }).status
1118
- return status !== "completed" && status !== "cancelled"
1119
- })
1120
- this.observedIncompleteTodosBySession.set(sessionID, hasIncompleteTodos)
1121
- return
1122
- }
1123
-
1124
- if (event.type === "session.idle") {
1125
- if (!props || typeof props !== "object") return
1126
- handleSessionIdleBackgroundEvent({
1127
- properties: props as Record<string, unknown>,
1128
- findBySession: (id) => this.findBySession(id),
1129
- idleDeferralTimers: this.idleDeferralTimers,
1130
- validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
1131
- checkSessionTodos: (id) => this.checkSessionTodos(id),
1132
- tryCompleteTask: (task, source) => this.tryCompleteTask(task, source),
1133
- emitIdleEvent: (sessionID) => this.handleEvent({ type: "session.idle", properties: { sessionID } }),
1134
- })
1135
- }
1136
-
1137
- if (event.type === "session.error") {
1138
- const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined
1139
- if (!sessionID) return
1140
-
1141
- const task = this.findBySession(sessionID)
1142
- if (!task || task.status !== "running") return
1143
-
1144
- const errorObj = props?.error as { name?: string; message?: string } | undefined
1145
- const errorName = errorObj?.name
1146
- const errorMessage = props ? getSessionErrorMessage(props) : undefined
1147
-
1148
- const errorInfo = { name: errorName, message: errorMessage }
1149
- void this.handleSessionErrorEvent({
1150
- errorInfo,
1151
- errorMessage,
1152
- errorName,
1153
- task,
1154
- }).catch((error) => {
1155
- log("[background-agent] Error handling session.error event:", {
1156
- error,
1157
- taskId: task.id,
1158
- })
1159
- })
1160
- return
1161
- }
1162
-
1163
- if (event.type === "session.deleted") {
1164
- const info = props?.info
1165
- if (!info || typeof info.id !== "string") return
1166
- const sessionID = info.id
1167
- this.clearSessionOutputObserved(sessionID)
1168
- this.clearSessionTodoObservation(sessionID)
1169
-
1170
- const tasksToCancel = new Map<string, BackgroundTask>()
1171
- const directTask = this.findBySession(sessionID)
1172
- if (directTask) {
1173
- tasksToCancel.set(directTask.id, directTask)
1174
- }
1175
- for (const descendant of this.getAllDescendantTasks(sessionID)) {
1176
- tasksToCancel.set(descendant.id, descendant)
1177
- }
1178
-
1179
- this.pendingNotifications.delete(sessionID)
1180
-
1181
- if (tasksToCancel.size === 0) {
1182
- this.clearTaskHistoryWhenParentTasksGone(sessionID)
1183
- return
1184
- }
1185
-
1186
- const parentSessionsToClear = new Set<string>()
1187
-
1188
- const deletedSessionIDs = new Set<string>([sessionID])
1189
- for (const task of tasksToCancel.values()) {
1190
- if (task.sessionID) {
1191
- deletedSessionIDs.add(task.sessionID)
1192
- }
1193
- }
1194
-
1195
- for (const task of tasksToCancel.values()) {
1196
- parentSessionsToClear.add(task.parentSessionID)
1197
-
1198
- if (task.status === "running" || task.status === "pending") {
1199
- void this.cancelTask(task.id, {
1200
- source: "session.deleted",
1201
- reason: "Session deleted",
1202
- }).then(() => {
1203
- if (deletedSessionIDs.has(task.parentSessionID)) {
1204
- this.pendingNotifications.delete(task.parentSessionID)
1205
- }
1206
- }).catch(err => {
1207
- if (deletedSessionIDs.has(task.parentSessionID)) {
1208
- this.pendingNotifications.delete(task.parentSessionID)
1209
- }
1210
- log("[background-agent] Failed to cancel task on session.deleted:", { taskId: task.id, error: err })
1211
- })
1212
- }
1213
- }
1214
-
1215
- for (const parentSessionID of parentSessionsToClear) {
1216
- this.clearTaskHistoryWhenParentTasksGone(parentSessionID)
1217
- }
1218
-
1219
- this.rootDescendantCounts.delete(sessionID)
1220
- SessionCategoryRegistry.remove(sessionID)
1221
- }
1222
-
1223
- if (event.type === "session.status") {
1224
- const sessionID = props?.sessionID as string | undefined
1225
- const status = props?.status as { type?: string; message?: string } | undefined
1226
- if (!sessionID || status?.type !== "retry") return
1227
-
1228
- const task = this.findBySession(sessionID)
1229
- if (!task || task.status !== "running") return
1230
-
1231
- const errorMessage = typeof status.message === "string" ? status.message : undefined
1232
- const errorInfo = { name: "SessionRetry", message: errorMessage }
1233
- void this.tryFallbackRetry(task, errorInfo, "session.status").catch((error) => {
1234
- log("[background-agent] Error handling session.status fallback retry:", {
1235
- error,
1236
- taskId: task.id,
1237
- })
1238
- })
1239
- }
1240
- }
1241
-
1242
- private async handleSessionErrorEvent(args: {
1243
- task: BackgroundTask
1244
- errorInfo: { name?: string; message?: string }
1245
- errorName: string | undefined
1246
- errorMessage: string | undefined
1247
- }): Promise<void> {
1248
- const { task, errorInfo, errorMessage, errorName } = args
1249
-
1250
- // Agent-not-found errors are handled by the prompt catch block with agent fallback.
1251
- // Do not also trigger model fallback retry — that would race with the agent retry.
1252
- if (isAgentNotFoundError({ message: errorInfo.message } as Error)) {
1253
- log("[background-agent] Skipping session.error fallback for agent-not-found (handled by prompt catch)", {
1254
- taskId: task.id,
1255
- errorMessage: errorInfo.message?.slice(0, 100),
1256
- })
1257
- return
1258
- }
1259
-
1260
- if (await this.tryFallbackRetry(task, errorInfo, "session.error")) {
1261
- return
1262
- }
1263
-
1264
- const errorMsg = errorMessage ?? "Session error"
1265
- const canRetry =
1266
- shouldRetryError(errorInfo) &&
1267
- !!task.fallbackChain &&
1268
- hasMoreFallbacks(task.fallbackChain, task.attemptCount ?? 0)
1269
- log("[background-agent] Session error - no retry:", {
1270
- taskId: task.id,
1271
- errorName,
1272
- errorMessage: errorMsg?.slice(0, 100),
1273
- hasFallbackChain: !!task.fallbackChain,
1274
- canRetry,
1275
- })
1276
-
1277
- task.status = "error"
1278
- task.error = errorMsg
1279
- task.completedAt = new Date()
1280
- if (task.rootSessionID) {
1281
- this.unregisterRootDescendant(task.rootSessionID)
1282
- }
1283
- this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
1284
-
1285
- if (task.concurrencyKey) {
1286
- this.concurrencyManager.release(task.concurrencyKey)
1287
- task.concurrencyKey = undefined
1288
- }
1289
-
1290
- const completionTimer = this.completionTimers.get(task.id)
1291
- if (completionTimer) {
1292
- clearTimeout(completionTimer)
1293
- this.completionTimers.delete(task.id)
1294
- }
1295
-
1296
- const idleTimer = this.idleDeferralTimers.get(task.id)
1297
- if (idleTimer) {
1298
- clearTimeout(idleTimer)
1299
- this.idleDeferralTimers.delete(task.id)
1300
- }
1301
-
1302
- this.cleanupPendingByParent(task)
1303
- this.clearNotificationsForTask(task.id)
1304
- const toastManager = getTaskToastManager()
1305
- if (toastManager) {
1306
- toastManager.removeTask(task.id)
1307
- }
1308
- this.scheduleTaskRemoval(task.id)
1309
- if (task.sessionID) {
1310
- SessionCategoryRegistry.remove(task.sessionID)
1311
- }
1312
-
1313
- this.markForNotification(task)
1314
- this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => {
1315
- log("[background-agent] Error in notifyParentSession for errored task:", { taskId: task.id, error: err })
1316
- })
1317
- }
1318
-
1319
- private tryFallbackRetry(
1320
- task: BackgroundTask,
1321
- errorInfo: { name?: string; message?: string },
1322
- source: string,
1323
- ): Promise<boolean> {
1324
- const previousSessionID = task.sessionID
1325
- const result = tryFallbackRetry({
1326
- task,
1327
- errorInfo,
1328
- source,
1329
- concurrencyManager: this.concurrencyManager,
1330
- client: this.client,
1331
- idleDeferralTimers: this.idleDeferralTimers,
1332
- queuesByKey: this.queuesByKey,
1333
- processKey: (key: string) => this.processKey(key),
1334
- })
1335
- return result.then((retried) => {
1336
- if (retried && previousSessionID) {
1337
- this.clearSessionOutputObserved(previousSessionID)
1338
- this.clearSessionTodoObservation(previousSessionID)
1339
- subagentSessions.delete(previousSessionID)
1340
- }
1341
- return retried
1342
- })
1343
- }
1344
-
1345
- markForNotification(task: BackgroundTask): void {
1346
- const queue = this.notifications.get(task.parentSessionID) ?? []
1347
- queue.push(task)
1348
- this.notifications.set(task.parentSessionID, queue)
1349
- }
1350
-
1351
- getPendingNotifications(sessionID: string): BackgroundTask[] {
1352
- return this.notifications.get(sessionID) ?? []
1353
- }
1354
-
1355
- clearNotifications(sessionID: string): void {
1356
- this.notifications.delete(sessionID)
1357
- }
1358
-
1359
- queuePendingNotification(sessionID: string | undefined, notification: string): void {
1360
- if (!sessionID) return
1361
- const existingNotifications = this.pendingNotifications.get(sessionID) ?? []
1362
- existingNotifications.push(notification)
1363
- this.pendingNotifications.set(sessionID, existingNotifications)
1364
- }
1365
-
1366
- injectPendingNotificationsIntoChatMessage(output: { parts: Array<{ type: string; text?: string; [key: string]: unknown }> }, sessionID: string): void {
1367
- const pendingNotifications = this.pendingNotifications.get(sessionID)
1368
- if (!pendingNotifications || pendingNotifications.length === 0) {
1369
- return
1370
- }
1371
-
1372
- this.pendingNotifications.delete(sessionID)
1373
- const notificationContent = pendingNotifications.join("\n\n")
1374
- const firstTextPartIndex = output.parts.findIndex((part) => part.type === "text")
1375
-
1376
- if (firstTextPartIndex === -1) {
1377
- output.parts.unshift(createInternalAgentTextPart(notificationContent))
1378
- return
1379
- }
1380
-
1381
- const originalText = output.parts[firstTextPartIndex].text ?? ""
1382
- output.parts[firstTextPartIndex].text = `${notificationContent}\n\n---\n\n${originalText}`
1383
- }
1384
-
1385
- /**
1386
- * Validates that a session has actual assistant/tool output before marking complete.
1387
- * Prevents premature completion when session.idle fires before agent responds.
1388
- */
1389
- private async validateSessionHasOutput(sessionID: string): Promise<boolean> {
1390
- if (this.observedOutputSessions.has(sessionID)) {
1391
- return true
1392
- }
1393
-
1394
- try {
1395
- const response = await this.client.session.messages({
1396
- path: { id: sessionID },
1397
- })
1398
-
1399
- const messages = normalizeSDKResponse(response, [] as Array<{ info?: { role?: string } }>, { preferResponseOnMissingData: true })
1400
-
1401
- // Check for at least one assistant or tool message
1402
- const hasAssistantOrToolMessage = messages.some(
1403
- (m: { info?: { role?: string } }) =>
1404
- m.info?.role === "assistant" || m.info?.role === "tool"
1405
- )
1406
-
1407
- if (!hasAssistantOrToolMessage) {
1408
- log("[background-agent] No assistant/tool messages found in session:", sessionID)
1409
- return false
1410
- }
1411
-
1412
- // OpenCode API uses different part types than Anthropic's API:
1413
- // - "reasoning" with .text property (thinking/reasoning content)
1414
- // - "tool" with .state.output property (tool call results)
1415
- // - "text" with .text property (final text output)
1416
- // - "step-start"/"step-finish" (metadata, no content)
1417
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1418
- const hasContent = messages.some((m: any) => {
1419
- if (m.info?.role !== "assistant" && m.info?.role !== "tool") return false
1420
- const parts = m.parts ?? []
1421
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1422
- return parts.some((p: any) =>
1423
- // Text content (final output)
1424
- (p.type === "text" && p.text && p.text.trim().length > 0) ||
1425
- // Reasoning content (thinking blocks)
1426
- (p.type === "reasoning" && p.text && p.text.trim().length > 0) ||
1427
- // Tool calls (indicates work was done)
1428
- p.type === "tool" ||
1429
- // Tool results (output from executed tools) - important for tool-only tasks
1430
- (p.type === "tool_result" && p.content &&
1431
- (typeof p.content === "string" ? p.content.trim().length > 0 : p.content.length > 0))
1432
- )
1433
- })
1434
-
1435
- if (!hasContent) {
1436
- log("[background-agent] Messages exist but no content found in session:", sessionID)
1437
- return false
1438
- }
1439
-
1440
- this.markSessionOutputObserved(sessionID)
1441
- return true
1442
- } catch (error) {
1443
- log("[background-agent] Error validating session output:", error)
1444
- // On error, allow completion to proceed (don't block indefinitely)
1445
- return true
1446
- }
1447
- }
1448
-
1449
- private clearNotificationsForTask(taskId: string): void {
1450
- for (const [sessionID, tasks] of this.notifications.entries()) {
1451
- const filtered = tasks.filter((t) => t.id !== taskId)
1452
- if (filtered.length === 0) {
1453
- this.notifications.delete(sessionID)
1454
- } else {
1455
- this.notifications.set(sessionID, filtered)
1456
- }
1457
- }
1458
- }
1459
-
1460
- /**
1461
- * Remove task from pending tracking for its parent session.
1462
- * Cleans up the parent entry if no pending tasks remain.
1463
- */
1464
- private cleanupPendingByParent(task: BackgroundTask): void {
1465
- if (!task.parentSessionID) return
1466
- const pending = this.pendingByParent.get(task.parentSessionID)
1467
- if (pending) {
1468
- pending.delete(task.id)
1469
- if (pending.size === 0) {
1470
- this.pendingByParent.delete(task.parentSessionID)
1471
- }
1472
- }
1473
- }
1474
-
1475
- private clearTaskHistoryWhenParentTasksGone(parentSessionID: string | undefined): void {
1476
- if (!parentSessionID) return
1477
- if (this.getTasksByParentSession(parentSessionID).length > 0) return
1478
- this.taskHistory.clearSession(parentSessionID)
1479
- this.completedTaskSummaries.delete(parentSessionID)
1480
- }
1481
-
1482
- private scheduleTaskRemoval(taskId: string, rescheduleCount = 0): void {
1483
- const existingTimer = this.completionTimers.get(taskId)
1484
- if (existingTimer) {
1485
- clearTimeout(existingTimer)
1486
- this.completionTimers.delete(taskId)
1487
- }
1488
-
1489
- const timer = setTimeout(() => {
1490
- this.completionTimers.delete(taskId)
1491
- const task = this.tasks.get(taskId)
1492
- if (!task) return
1493
-
1494
- if (task.parentSessionID) {
1495
- const siblings = this.getTasksByParentSession(task.parentSessionID)
1496
- const runningOrPendingSiblings = siblings.filter(
1497
- sibling => sibling.id !== taskId && (sibling.status === "running" || sibling.status === "pending"),
1498
- )
1499
- const completedAtTimestamp = task.completedAt?.getTime()
1500
- const reachedTaskTtl = completedAtTimestamp !== undefined && (Date.now() - completedAtTimestamp) >= TASK_TTL_MS
1501
- if (runningOrPendingSiblings.length > 0 && rescheduleCount < MAX_TASK_REMOVAL_RESCHEDULES && !reachedTaskTtl) {
1502
- this.scheduleTaskRemoval(taskId, rescheduleCount + 1)
1503
- return
1504
- }
1505
- }
1506
-
1507
- this.clearNotificationsForTask(taskId)
1508
- this.tasks.delete(taskId)
1509
- this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
1510
- if (task.sessionID) {
1511
- subagentSessions.delete(task.sessionID)
1512
- SessionCategoryRegistry.remove(task.sessionID)
1513
- }
1514
- log("[background-agent] Removed completed task from memory:", taskId)
1515
- }, TASK_CLEANUP_DELAY_MS)
1516
-
1517
- this.completionTimers.set(taskId, timer)
1518
- }
1519
-
1520
- async cancelTask(
1521
- taskId: string,
1522
- options?: { source?: string; reason?: string; abortSession?: boolean; skipNotification?: boolean }
1523
- ): Promise<boolean> {
1524
- const task = this.tasks.get(taskId)
1525
- if (!task || (task.status !== "running" && task.status !== "pending")) {
1526
- return false
1527
- }
1528
-
1529
- const source = options?.source ?? "cancel"
1530
- const abortSession = options?.abortSession !== false
1531
- const reason = options?.reason
1532
-
1533
- if (task.status === "pending") {
1534
- const key = task.model
1535
- ? `${task.model.providerID}/${task.model.modelID}`
1536
- : task.agent
1537
- const queue = this.queuesByKey.get(key)
1538
- if (queue) {
1539
- const index = queue.findIndex(item => item.task.id === taskId)
1540
- if (index !== -1) {
1541
- queue.splice(index, 1)
1542
- if (queue.length === 0) {
1543
- this.queuesByKey.delete(key)
1544
- }
1545
- }
1546
- }
1547
- this.rollbackPreStartDescendantReservation(task)
1548
- log("[background-agent] Cancelled pending task:", { taskId, key })
1549
- }
1550
-
1551
- const wasRunning = task.status === "running"
1552
- task.status = "cancelled"
1553
- task.completedAt = new Date()
1554
- if (wasRunning && task.rootSessionID) {
1555
- this.unregisterRootDescendant(task.rootSessionID)
1556
- }
1557
- if (reason) {
1558
- task.error = reason
1559
- }
1560
- this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "cancelled", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
1561
-
1562
- if (task.concurrencyKey) {
1563
- this.concurrencyManager.release(task.concurrencyKey)
1564
- task.concurrencyKey = undefined
1565
- }
1566
-
1567
- const existingTimer = this.completionTimers.get(task.id)
1568
- if (existingTimer) {
1569
- clearTimeout(existingTimer)
1570
- this.completionTimers.delete(task.id)
1571
- }
1572
-
1573
- const idleTimer = this.idleDeferralTimers.get(task.id)
1574
- if (idleTimer) {
1575
- clearTimeout(idleTimer)
1576
- this.idleDeferralTimers.delete(task.id)
1577
- }
1578
-
1579
- if (abortSession && task.sessionID) {
1580
- // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
1581
- await this.abortSessionWithLogging(task.sessionID, `task cancellation (${source})`)
1582
-
1583
- SessionCategoryRegistry.remove(task.sessionID)
1584
- }
1585
-
1586
- removeTaskToastTracking(task.id)
1587
-
1588
- if (options?.skipNotification) {
1589
- this.cleanupPendingByParent(task)
1590
- this.scheduleTaskRemoval(task.id)
1591
- log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id)
1592
- return true
1593
- }
1594
-
1595
- this.markForNotification(task)
1596
-
1597
- try {
1598
- await this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task))
1599
- log(`[background-agent] Task cancelled via ${source}:`, task.id)
1600
- } catch (err) {
1601
- log("[background-agent] Error in notifyParentSession for cancelled task:", { taskId: task.id, error: err })
1602
- }
1603
-
1604
- return true
1605
- }
1606
-
1607
- /**
1608
- * Cancels a pending task by removing it from queue and marking as cancelled.
1609
- * Does NOT abort session (no session exists yet) or release concurrency slot (wasn't acquired).
1610
- */
1611
- cancelPendingTask(taskId: string): boolean {
1612
- const task = this.tasks.get(taskId)
1613
- if (!task || task.status !== "pending") {
1614
- return false
1615
- }
1616
-
1617
- void this.cancelTask(taskId, { source: "cancelPendingTask", abortSession: false })
1618
- return true
1619
- }
1620
-
1621
- private startPolling(): void {
1622
- if (this.pollingInterval) return
1623
-
1624
- this.pollingInterval = setInterval(() => {
1625
- this.pollRunningTasks()
1626
- }, POLLING_INTERVAL_MS)
1627
- this.pollingInterval.unref()
1628
- }
1629
-
1630
- private stopPolling(): void {
1631
- if (this.pollingInterval) {
1632
- clearInterval(this.pollingInterval)
1633
- this.pollingInterval = undefined
1634
- }
1635
- }
1636
-
1637
- private registerProcessCleanup(): void {
1638
- registerManagerForCleanup(this)
1639
- }
1640
-
1641
- private unregisterProcessCleanup(): void {
1642
- unregisterManagerForCleanup(this)
1643
- }
1644
-
1645
-
1646
- /**
1647
- * Get all running tasks (for compaction hook)
1648
- */
1649
- getRunningTasks(): BackgroundTask[] {
1650
- return Array.from(this.tasks.values()).filter(t => t.status === "running")
1651
- }
1652
-
1653
- /**
1654
- * Get all non-running tasks still in memory (for compaction hook)
1655
- */
1656
- getNonRunningTasks(): BackgroundTask[] {
1657
- return Array.from(this.tasks.values()).filter(t => t.status !== "running")
1658
- }
1659
-
1660
- /**
1661
- * Safely complete a task with race condition protection.
1662
- * Returns true if task was successfully completed, false if already completed by another path.
1663
- */
1664
- private async tryCompleteTask(task: BackgroundTask, source: string): Promise<boolean> {
1665
- // Guard: Check if task is still running (could have been completed by another path)
1666
- if (task.status !== "running") {
1667
- log("[background-agent] Task already completed, skipping:", { taskId: task.id, status: task.status, source })
1668
- return false
1669
- }
1670
-
1671
- // Atomically mark as completed to prevent race conditions
1672
- task.status = "completed"
1673
- task.completedAt = new Date()
1674
- this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "completed", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
1675
-
1676
- if (task.rootSessionID) {
1677
- this.unregisterRootDescendant(task.rootSessionID)
1678
- }
1679
-
1680
- removeTaskToastTracking(task.id)
1681
-
1682
- // Release concurrency BEFORE any async operations to prevent slot leaks
1683
- if (task.concurrencyKey) {
1684
- this.concurrencyManager.release(task.concurrencyKey)
1685
- task.concurrencyKey = undefined
1686
- }
1687
-
1688
- this.markForNotification(task)
1689
-
1690
- const idleTimer = this.idleDeferralTimers.get(task.id)
1691
- if (idleTimer) {
1692
- clearTimeout(idleTimer)
1693
- this.idleDeferralTimers.delete(task.id)
1694
- }
1695
-
1696
- if (task.sessionID) {
1697
- // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
1698
- await this.abortSessionWithLogging(task.sessionID, `task completion (${source})`)
1699
-
1700
- SessionCategoryRegistry.remove(task.sessionID)
1701
- }
1702
-
1703
- try {
1704
- await this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task))
1705
- log(`[background-agent] Task completed via ${source}:`, task.id)
1706
- } catch (err) {
1707
- log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error: err })
1708
- // Concurrency already released, notification failed but task is complete
1709
- }
1710
-
1711
- return true
1712
- }
1713
-
1714
- private async notifyParentSession(task: BackgroundTask): Promise<void> {
1715
- const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt)
1716
-
1717
- log("[background-agent] notifyParentSession called for task:", task.id)
1718
-
1719
- // Show toast notification
1720
- const toastManager = getTaskToastManager()
1721
- if (toastManager) {
1722
- toastManager.showCompletionToast({
1723
- id: task.id,
1724
- description: task.description,
1725
- duration,
1726
- })
1727
- }
1728
-
1729
- if (!this.completedTaskSummaries.has(task.parentSessionID)) {
1730
- this.completedTaskSummaries.set(task.parentSessionID, [])
1731
- }
1732
- this.completedTaskSummaries.get(task.parentSessionID)!.push({
1733
- id: task.id,
1734
- description: task.description,
1735
- status: task.status,
1736
- error: task.error,
1737
- })
1738
-
1739
- // Update pending tracking and check if all tasks complete
1740
- const pendingSet = this.pendingByParent.get(task.parentSessionID)
1741
- let allComplete = false
1742
- let remainingCount = 0
1743
- if (pendingSet) {
1744
- pendingSet.delete(task.id)
1745
- remainingCount = pendingSet.size
1746
- allComplete = remainingCount === 0
1747
- if (allComplete) {
1748
- this.pendingByParent.delete(task.parentSessionID)
1749
- }
1750
- } else {
1751
- remainingCount = Array.from(this.tasks.values())
1752
- .filter(t => t.parentSessionID === task.parentSessionID && t.id !== task.id && (t.status === "running" || t.status === "pending"))
1753
- .length
1754
- allComplete = remainingCount === 0
1755
- }
1756
-
1757
- const completedTasks = allComplete
1758
- ? (this.completedTaskSummaries.get(task.parentSessionID) ?? [{ id: task.id, description: task.description, status: task.status, error: task.error }])
1759
- : []
1760
-
1761
- if (allComplete) {
1762
- this.completedTaskSummaries.delete(task.parentSessionID)
1763
- }
1764
-
1765
- const statusText = task.status === "completed"
1766
- ? "COMPLETED"
1767
- : task.status === "interrupt"
1768
- ? "INTERRUPTED"
1769
- : task.status === "error"
1770
- ? "ERROR"
1771
- : "CANCELLED"
1772
- const notification = buildBackgroundTaskNotificationText({
1773
- task,
1774
- duration,
1775
- statusText,
1776
- allComplete,
1777
- remainingCount,
1778
- completedTasks,
1779
- })
1780
-
1781
- let agent: string | undefined = task.parentAgent
1782
- let model: { providerID: string; modelID: string } | undefined
1783
- let tools: Record<string, boolean> | undefined = task.parentTools
1784
- let promptContext: ReturnType<typeof resolvePromptContextFromSessionMessages> = null
1785
-
1786
- if (this.enableParentSessionNotifications) {
1787
- try {
1788
- const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } })
1789
- const messages = normalizeSDKResponse(messagesResp, [] as Array<{
1790
- info?: {
1791
- agent?: string
1792
- model?: { providerID: string; modelID: string }
1793
- modelID?: string
1794
- providerID?: string
1795
- tools?: Record<string, boolean | "allow" | "deny" | "ask">
1796
- }
1797
- }>)
1798
- promptContext = resolvePromptContextFromSessionMessages(
1799
- messages,
1800
- task.parentSessionID,
1801
- )
1802
- const normalizedTools = isRecord(promptContext?.tools)
1803
- ? normalizePromptTools(promptContext.tools)
1804
- : undefined
1805
-
1806
- if (promptContext?.agent || promptContext?.model || normalizedTools) {
1807
- agent = promptContext?.agent ?? task.parentAgent
1808
- model = promptContext?.model?.providerID && promptContext.model.modelID
1809
- ? { providerID: promptContext.model.providerID, modelID: promptContext.model.modelID }
1810
- : undefined
1811
- tools = normalizedTools ?? tools
1812
- }
1813
- } catch (error) {
1814
- if (isAbortedSessionError(error)) {
1815
- log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", {
1816
- taskId: task.id,
1817
- parentSessionID: task.parentSessionID,
1818
- })
1819
- }
1820
- const messageDir = join(MESSAGE_STORAGE, task.parentSessionID)
1821
- const currentMessage = messageDir
1822
- ? findNearestMessageExcludingCompaction(messageDir, task.parentSessionID)
1823
- : null
1824
- agent = currentMessage?.agent ?? task.parentAgent
1825
- model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
1826
- ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
1827
- : undefined
1828
- tools = normalizePromptTools(currentMessage?.tools) ?? tools
1829
- }
1830
-
1831
- const resolvedTools = resolveInheritedPromptTools(task.parentSessionID, tools)
1832
-
1833
- log("[background-agent] notifyParentSession context:", {
1834
- taskId: task.id,
1835
- resolvedAgent: agent,
1836
- resolvedModel: model,
1837
- })
1838
-
1839
- const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt"
1840
- const shouldReply = allComplete || isTaskFailure
1841
-
1842
- const variant = promptContext?.model?.variant
1843
-
1844
- try {
1845
- await this.client.session.promptAsync({
1846
- path: { id: task.parentSessionID },
1847
- body: {
1848
- noReply: !shouldReply,
1849
- ...(agent !== undefined ? { agent } : {}),
1850
- ...(model !== undefined ? { model } : {}),
1851
- ...(variant !== undefined ? { variant } : {}),
1852
- ...(resolvedTools ? { tools: resolvedTools } : {}),
1853
- parts: [createInternalAgentTextPart(notification)],
1854
- },
1855
- })
1856
- log("[background-agent] Sent notification to parent session:", {
1857
- taskId: task.id,
1858
- allComplete,
1859
- isTaskFailure,
1860
- noReply: !shouldReply,
1861
- })
1862
- } catch (error) {
1863
- if (isAbortedSessionError(error)) {
1864
- log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
1865
- taskId: task.id,
1866
- parentSessionID: task.parentSessionID,
1867
- })
1868
- this.queuePendingNotification(task.parentSessionID, notification)
1869
- } else {
1870
- log("[background-agent] Failed to send notification:", error)
1871
- }
1872
- }
1873
- } else {
1874
- log("[background-agent] Parent session notifications disabled, skipping prompt injection:", {
1875
- taskId: task.id,
1876
- parentSessionID: task.parentSessionID,
1877
- })
1878
- }
1879
-
1880
- if (task.status !== "running" && task.status !== "pending") {
1881
- this.scheduleTaskRemoval(task.id)
1882
- }
1883
- }
1884
-
1885
- private hasRunningTasks(): boolean {
1886
- for (const task of this.tasks.values()) {
1887
- if (task.status === "running") return true
1888
- }
1889
- return false
1890
- }
1891
-
1892
- private pruneStaleTasksAndNotifications(): void {
1893
- pruneStaleTasksAndNotifications({
1894
- tasks: this.tasks,
1895
- notifications: this.notifications,
1896
- taskTtlMs: this.config?.taskTtlMs,
1897
- onTaskPruned: (taskId, task, errorMessage) => {
1898
- const wasPending = task.status === "pending"
1899
- log("[background-agent] Pruning stale task:", { taskId, status: task.status, age: Math.round(((wasPending ? task.queuedAt?.getTime() : task.startedAt?.getTime()) ? (Date.now() - (wasPending ? task.queuedAt!.getTime() : task.startedAt!.getTime())) : 0) / 1000) + "s" })
1900
- task.status = "error"
1901
- task.error = errorMessage
1902
- task.completedAt = new Date()
1903
- if (!wasPending && task.rootSessionID) {
1904
- this.unregisterRootDescendant(task.rootSessionID)
1905
- }
1906
- this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
1907
- if (task.concurrencyKey) {
1908
- this.concurrencyManager.release(task.concurrencyKey)
1909
- task.concurrencyKey = undefined
1910
- }
1911
- removeTaskToastTracking(task.id)
1912
- const existingTimer = this.completionTimers.get(taskId)
1913
- if (existingTimer) {
1914
- clearTimeout(existingTimer)
1915
- this.completionTimers.delete(taskId)
1916
- }
1917
- const idleTimer = this.idleDeferralTimers.get(taskId)
1918
- if (idleTimer) {
1919
- clearTimeout(idleTimer)
1920
- this.idleDeferralTimers.delete(taskId)
1921
- }
1922
- if (wasPending) {
1923
- const key = task.model
1924
- ? `${task.model.providerID}/${task.model.modelID}`
1925
- : task.agent
1926
- const queue = this.queuesByKey.get(key)
1927
- if (queue) {
1928
- const index = queue.findIndex((item) => item.task.id === taskId)
1929
- if (index !== -1) {
1930
- queue.splice(index, 1)
1931
- if (queue.length === 0) {
1932
- this.queuesByKey.delete(key)
1933
- }
1934
- }
1935
- }
1936
- }
1937
- this.cleanupPendingByParent(task)
1938
- this.markForNotification(task)
1939
- this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => {
1940
- log("[background-agent] Error in notifyParentSession for stale-pruned task:", { taskId: task.id, error: err })
1941
- })
1942
- },
1943
- })
1944
- }
1945
-
1946
- private async checkAndInterruptStaleTasks(
1947
- allStatuses: Record<string, { type: string }> = {},
1948
- ): Promise<void> {
1949
- await checkAndInterruptStaleTasks({
1950
- tasks: this.tasks.values(),
1951
- client: this.client,
1952
- directory: this.directory,
1953
- config: this.config,
1954
- concurrencyManager: this.concurrencyManager,
1955
- notifyParentSession: (task) => this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)),
1956
- sessionStatuses: allStatuses,
1957
- })
1958
- }
1959
-
1960
- private async verifySessionExists(sessionID: string): Promise<boolean> {
1961
- return verifySessionStillExists(this.client, sessionID, this.directory)
1962
- }
1963
-
1964
- private async failCrashedTask(task: BackgroundTask, errorMessage: string): Promise<void> {
1965
- task.status = "error"
1966
- task.error = errorMessage
1967
- task.completedAt = new Date()
1968
- if (task.rootSessionID) {
1969
- this.unregisterRootDescendant(task.rootSessionID)
1970
- }
1971
- this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
1972
- if (task.concurrencyKey) {
1973
- this.concurrencyManager.release(task.concurrencyKey)
1974
- task.concurrencyKey = undefined
1975
- }
1976
-
1977
- const completionTimer = this.completionTimers.get(task.id)
1978
- if (completionTimer) {
1979
- clearTimeout(completionTimer)
1980
- this.completionTimers.delete(task.id)
1981
- }
1982
- const idleTimer = this.idleDeferralTimers.get(task.id)
1983
- if (idleTimer) {
1984
- clearTimeout(idleTimer)
1985
- this.idleDeferralTimers.delete(task.id)
1986
- }
1987
-
1988
- this.cleanupPendingByParent(task)
1989
- this.clearNotificationsForTask(task.id)
1990
- removeTaskToastTracking(task.id)
1991
- this.scheduleTaskRemoval(task.id)
1992
- if (task.sessionID) {
1993
- SessionCategoryRegistry.remove(task.sessionID)
1994
- }
1995
-
1996
- this.markForNotification(task)
1997
- this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => {
1998
- log("[background-agent] Error in notifyParentSession for crashed task:", { taskId: task.id, error: err })
1999
- })
2000
- }
2001
-
2002
- private async pollRunningTasks(): Promise<void> {
2003
- if (this.pollingInFlight) return
2004
- this.pollingInFlight = true
2005
- try {
2006
- this.pruneStaleTasksAndNotifications()
2007
-
2008
- const statusResult = await this.client.session.status()
2009
- const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
2010
-
2011
- await this.checkAndInterruptStaleTasks(allStatuses)
2012
-
2013
- for (const task of this.tasks.values()) {
2014
- if (task.status !== "running") continue
2015
-
2016
- const sessionID = task.sessionID
2017
- if (!sessionID) continue
2018
-
2019
- try {
2020
- const sessionStatus = allStatuses[sessionID]
2021
- // Handle retry before checking running state
2022
- if (sessionStatus?.type === "retry") {
2023
- const retryMessage = typeof (sessionStatus as { message?: string }).message === "string"
2024
- ? (sessionStatus as { message?: string }).message
2025
- : undefined
2026
- const errorInfo = { name: "SessionRetry", message: retryMessage }
2027
- if (await this.tryFallbackRetry(task, errorInfo, "polling:session.status")) {
2028
- continue
2029
- }
2030
- }
2031
-
2032
- // Only skip completion when session status is actively running.
2033
- // Unknown or terminal statuses (like "interrupted") fall through to completion.
2034
- if (sessionStatus && isActiveSessionStatus(sessionStatus.type)) {
2035
- log("[background-agent] Session still running, relying on event-based progress:", {
2036
- taskId: task.id,
2037
- sessionID,
2038
- sessionStatus: sessionStatus.type,
2039
- toolCalls: task.progress?.toolCalls ?? 0,
2040
- })
2041
- continue
2042
- }
2043
-
2044
- if (sessionStatus && isTerminalSessionStatus(sessionStatus.type)) {
2045
- await this.tryCompleteTask(task, `polling (terminal session status: ${sessionStatus.type})`)
2046
- continue
2047
- }
2048
-
2049
- if (sessionStatus && sessionStatus.type !== "idle") {
2050
- log("[background-agent] Unknown session status, treating as potentially idle:", {
2051
- taskId: task.id,
2052
- sessionID,
2053
- sessionStatus: sessionStatus.type,
2054
- })
2055
- }
2056
-
2057
- // Session is idle or no longer in status response (completed/disappeared)
2058
- const sessionGoneFromStatus = !sessionStatus
2059
- const sessionGoneThresholdReached = sessionGoneFromStatus
2060
- && (task.consecutiveMissedPolls ?? 0) >= MIN_SESSION_GONE_POLLS
2061
- const completionSource = sessionStatus?.type === "idle"
2062
- ? "polling (idle status)"
2063
- : "polling (session gone from status)"
2064
- const hasValidOutput = await this.validateSessionHasOutput(sessionID)
2065
- if (!hasValidOutput) {
2066
- if (sessionGoneThresholdReached) {
2067
- const sessionExists = await this.verifySessionExists(sessionID)
2068
- if (!sessionExists) {
2069
- log("[background-agent] Session no longer exists (crashed), marking task as error:", task.id)
2070
- await this.failCrashedTask(task, "Subagent session no longer exists (process likely crashed). The session disappeared without producing any output.")
2071
- continue
2072
- }
2073
-
2074
- task.consecutiveMissedPolls = 0
2075
- }
2076
- log("[background-agent] Polling idle/gone but no valid output yet, waiting:", task.id)
2077
- continue
2078
- }
2079
-
2080
- // Re-check status after async operation
2081
- if (task.status !== "running") continue
2082
-
2083
- const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
2084
- if (hasIncompleteTodos) {
2085
- log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
2086
- continue
2087
- }
2088
-
2089
- await this.tryCompleteTask(task, completionSource)
2090
- } catch (error) {
2091
- log("[background-agent] Poll error for task:", { taskId: task.id, error })
2092
- }
2093
- }
2094
-
2095
- if (!this.hasRunningTasks()) {
2096
- this.stopPolling()
2097
- }
2098
- } finally {
2099
- this.pollingInFlight = false
2100
- }
2101
- }
2102
-
2103
- /**
2104
- * Shutdown the manager gracefully.
2105
- * Cancels all pending concurrency waiters and clears timers.
2106
- * Should be called when the plugin is unloaded.
2107
- */
2108
- async shutdown(): Promise<void> {
2109
- if (this.shutdownTriggered) return
2110
- this.shutdownTriggered = true
2111
- log("[background-agent] Shutting down BackgroundManager")
2112
- this.stopPolling()
2113
- const trackedSessionIDs = new Set<string>()
2114
- const abortRequests: Array<{ sessionID: string; promise: Promise<unknown> }> = []
2115
-
2116
- // Abort all running sessions to prevent zombie processes (#1240)
2117
- for (const task of this.tasks.values()) {
2118
- if (task.sessionID) {
2119
- trackedSessionIDs.add(task.sessionID)
2120
- }
2121
-
2122
- if (task.status === "running" && task.sessionID) {
2123
- abortRequests.push({
2124
- sessionID: task.sessionID,
2125
- promise: abortWithTimeout(this.client, task.sessionID),
2126
- })
2127
- }
2128
- }
2129
-
2130
- if (abortRequests.length > 0) {
2131
- const abortResults = await Promise.allSettled(abortRequests.map((request) => request.promise))
2132
- for (const [index, abortResult] of abortResults.entries()) {
2133
- if (abortResult.status === "fulfilled") continue
2134
-
2135
- log("[background-agent] Error aborting session during shutdown:", {
2136
- error: abortResult.reason,
2137
- sessionID: abortRequests[index]?.sessionID,
2138
- })
2139
- }
2140
- }
2141
-
2142
- // Notify shutdown listeners (e.g., tmux cleanup)
2143
- if (this.onShutdown) {
2144
- try {
2145
- await this.onShutdown()
2146
- } catch (error) {
2147
- log("[background-agent] Error in onShutdown callback:", error)
2148
- }
2149
- }
2150
-
2151
- // Release concurrency for all running tasks
2152
- for (const task of this.tasks.values()) {
2153
- if (task.concurrencyKey) {
2154
- this.concurrencyManager.release(task.concurrencyKey)
2155
- task.concurrencyKey = undefined
2156
- }
2157
- }
2158
-
2159
- for (const timer of this.completionTimers.values()) {
2160
- clearTimeout(timer)
2161
- }
2162
- this.completionTimers.clear()
2163
-
2164
- for (const timer of this.idleDeferralTimers.values()) {
2165
- clearTimeout(timer)
2166
- }
2167
- this.idleDeferralTimers.clear()
2168
-
2169
- for (const sessionID of trackedSessionIDs) {
2170
- subagentSessions.delete(sessionID)
2171
- SessionCategoryRegistry.remove(sessionID)
2172
- }
2173
-
2174
- this.concurrencyManager.clear()
2175
- this.tasks.clear()
2176
- this.notifications.clear()
2177
- this.pendingNotifications.clear()
2178
- this.pendingByParent.clear()
2179
- this.notificationQueueByParent.clear()
2180
- this.rootDescendantCounts.clear()
2181
- this.queuesByKey.clear()
2182
- this.processingKeys.clear()
2183
- this.taskHistory.clearAll()
2184
- this.completedTaskSummaries.clear()
2185
- this.unregisterProcessCleanup()
2186
- log("[background-agent] Shutdown complete")
2187
-
2188
- }
2189
-
2190
- private enqueueNotificationForParent(
2191
- parentSessionID: string | undefined,
2192
- operation: () => Promise<void>
2193
- ): Promise<void> {
2194
- if (!parentSessionID) {
2195
- return operation()
2196
- }
2197
-
2198
- const previous = this.notificationQueueByParent.get(parentSessionID) ?? Promise.resolve()
2199
- const cleanupQueueEntry = (): void => {
2200
- if (this.notificationQueueByParent.get(parentSessionID) === current) {
2201
- this.notificationQueueByParent.delete(parentSessionID)
2202
- }
2203
- }
2204
-
2205
- const current = previous
2206
- .catch((error) => {
2207
- log("[background-agent] Continuing notification queue after previous failure:", {
2208
- parentSessionID,
2209
- error,
2210
- })
2211
- })
2212
- .then(operation)
2213
-
2214
- this.notificationQueueByParent.set(parentSessionID, current)
2215
-
2216
- void current.then(cleanupQueueEntry, cleanupQueueEntry)
2217
-
2218
- return current
2219
- }
2220
- }
1
+
2
+ import type { PluginInput } from "@opencode-ai/plugin"
3
+ import { isAgentNotFoundError, FALLBACK_AGENT, buildFallbackBody } from "./spawner"
4
+ import type {
5
+ BackgroundTask,
6
+ LaunchInput,
7
+ ResumeInput,
8
+ } from "./types"
9
+ import { TaskHistory } from "./task-history"
10
+ import {
11
+ log,
12
+ getAgentToolRestrictions,
13
+ normalizePromptTools,
14
+ normalizeSDKResponse,
15
+ promptWithModelSuggestionRetry,
16
+ resolveInheritedPromptTools,
17
+ createInternalAgentTextPart,
18
+ } from "../../shared"
19
+ import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
20
+ import { setSessionTools } from "../../shared/session-tools-store"
21
+ import { SessionCategoryRegistry } from "../../shared/session-category-registry"
22
+ import { ConcurrencyManager } from "./concurrency"
23
+ import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
24
+ import { isInsideTmux } from "../../shared/tmux"
25
+ import {
26
+ shouldRetryError,
27
+ hasMoreFallbacks,
28
+ } from "../../shared/model-error-classifier"
29
+ import {
30
+ POLLING_INTERVAL_MS,
31
+ TASK_CLEANUP_DELAY_MS,
32
+ TASK_TTL_MS,
33
+ } from "./constants"
34
+
35
+ import { subagentSessions } from "../claude-code-session-state"
36
+ import { getTaskToastManager } from "../task-toast-manager"
37
+ import { formatDuration } from "./duration-formatter"
38
+ import {
39
+ buildBackgroundTaskNotificationText,
40
+ type BackgroundTaskNotificationTask,
41
+ } from "./background-task-notification-template"
42
+ import {
43
+ isAbortedSessionError,
44
+ extractErrorName,
45
+ extractErrorMessage,
46
+ getSessionErrorMessage,
47
+ isRecord,
48
+ } from "./error-classifier"
49
+ import { tryFallbackRetry } from "./fallback-retry-handler"
50
+ import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup"
51
+ import {
52
+ findNearestMessageExcludingCompaction,
53
+ resolvePromptContextFromSessionMessages,
54
+ } from "./compaction-aware-message-resolver"
55
+ import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
56
+ import { MESSAGE_STORAGE } from "../hook-message-injector"
57
+ import { join } from "node:path"
58
+ import { pruneStaleTasksAndNotifications } from "./task-poller"
59
+ import { checkAndInterruptStaleTasks } from "./task-poller"
60
+ import { removeTaskToastTracking } from "./remove-task-toast-tracking"
61
+ import { abortWithTimeout } from "./abort-with-timeout"
62
+ import {
63
+ MIN_SESSION_GONE_POLLS,
64
+ verifySessionExists as verifySessionStillExists,
65
+ } from "./session-existence"
66
+ import { isActiveSessionStatus, isTerminalSessionStatus } from "./session-status-classifier"
67
+ import {
68
+ detectRepetitiveToolUse,
69
+ recordToolCall,
70
+ resolveCircuitBreakerSettings,
71
+ type CircuitBreakerSettings,
72
+ } from "./loop-detector"
73
+ import {
74
+ createSubagentDepthLimitError,
75
+ createSubagentDescendantLimitError,
76
+ getMaxRootSessionSpawnBudget,
77
+ getMaxSubagentDepth,
78
+ resolveSubagentSpawnContext,
79
+ type SubagentSpawnContext,
80
+ } from "./subagent-spawn-limits"
81
+
82
+ type OpencodeClient = PluginInput["client"]
83
+
84
+
85
+ interface MessagePartInfo {
86
+ id?: string
87
+ sessionID?: string
88
+ type?: string
89
+ tool?: string
90
+ state?: { status?: string; input?: Record<string, unknown> }
91
+ }
92
+
93
+ interface EventProperties {
94
+ sessionID?: string
95
+ info?: { id?: string }
96
+ [key: string]: unknown
97
+ }
98
+
99
+ interface Event {
100
+ type: string
101
+ properties?: EventProperties
102
+ }
103
+
104
+ function resolveMessagePartInfo(properties: EventProperties | undefined): MessagePartInfo | undefined {
105
+ if (!properties || typeof properties !== "object") {
106
+ return undefined
107
+ }
108
+
109
+ const nestedPart = properties.part
110
+ if (nestedPart && typeof nestedPart === "object") {
111
+ return nestedPart as MessagePartInfo
112
+ }
113
+
114
+ return properties as MessagePartInfo
115
+ }
116
+
117
+ interface Todo {
118
+ content: string
119
+ status: string
120
+ priority: string
121
+ id: string
122
+ }
123
+
124
+ interface QueueItem {
125
+ task: BackgroundTask
126
+ input: LaunchInput
127
+ }
128
+
129
+ export interface SubagentSessionCreatedEvent {
130
+ sessionID: string
131
+ parentID: string
132
+ title: string
133
+ }
134
+
135
+ export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise<void>
136
+
137
+ const MAX_TASK_REMOVAL_RESCHEDULES = 6
138
+
139
+ export class BackgroundManager {
140
+
141
+
142
+ private tasks: Map<string, BackgroundTask>
143
+ private notifications: Map<string, BackgroundTask[]>
144
+ private pendingNotifications: Map<string, string[]>
145
+ private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching
146
+ private client: OpencodeClient
147
+ private directory: string
148
+ private pollingInterval?: ReturnType<typeof setInterval>
149
+ private pollingInFlight = false
150
+ private concurrencyManager: ConcurrencyManager
151
+ private shutdownTriggered = false
152
+ private config?: BackgroundTaskConfig
153
+ private tmuxEnabled: boolean
154
+ private onSubagentSessionCreated?: OnSubagentSessionCreated
155
+ private onShutdown?: () => void | Promise<void>
156
+
157
+ private queuesByKey: Map<string, QueueItem[]> = new Map()
158
+ private processingKeys: Set<string> = new Set()
159
+ private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
160
+ private completedTaskSummaries: Map<string, BackgroundTaskNotificationTask[]> = new Map()
161
+ private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
162
+ private notificationQueueByParent: Map<string, Promise<void>> = new Map()
163
+ private observedOutputSessions: Set<string> = new Set()
164
+ private observedIncompleteTodosBySession: Map<string, boolean> = new Map()
165
+ private rootDescendantCounts: Map<string, number>
166
+ private preStartDescendantReservations: Set<string>
167
+ private enableParentSessionNotifications: boolean
168
+ readonly taskHistory = new TaskHistory()
169
+ private cachedCircuitBreakerSettings?: CircuitBreakerSettings
170
+
171
+ constructor(
172
+ ctx: PluginInput,
173
+ config?: BackgroundTaskConfig,
174
+ options?: {
175
+ tmuxConfig?: TmuxConfig
176
+ onSubagentSessionCreated?: OnSubagentSessionCreated
177
+ onShutdown?: () => void | Promise<void>
178
+ enableParentSessionNotifications?: boolean
179
+ }
180
+ ) {
181
+ this.tasks = new Map()
182
+ this.notifications = new Map()
183
+ this.pendingNotifications = new Map()
184
+ this.pendingByParent = new Map()
185
+ this.client = ctx.client
186
+ this.directory = ctx.directory
187
+ this.concurrencyManager = new ConcurrencyManager(config)
188
+ this.config = config
189
+ this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false
190
+ this.onSubagentSessionCreated = options?.onSubagentSessionCreated
191
+ this.onShutdown = options?.onShutdown
192
+ this.rootDescendantCounts = new Map()
193
+ this.preStartDescendantReservations = new Set()
194
+ this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true
195
+ this.registerProcessCleanup()
196
+ }
197
+
198
+ private async abortSessionWithLogging(sessionID: string, reason: string): Promise<void> {
199
+ try {
200
+ await abortWithTimeout(this.client, sessionID)
201
+ } catch (error) {
202
+ log(`[background-agent] Failed to abort session during ${reason}:`, {
203
+ sessionID,
204
+ error,
205
+ })
206
+ }
207
+ }
208
+
209
+ async assertCanSpawn(parentSessionID: string): Promise<SubagentSpawnContext> {
210
+ const spawnContext = await resolveSubagentSpawnContext(this.client, parentSessionID, this.directory)
211
+ const maxDepth = getMaxSubagentDepth(this.config)
212
+ if (spawnContext.childDepth > maxDepth) {
213
+ throw createSubagentDepthLimitError({
214
+ childDepth: spawnContext.childDepth,
215
+ maxDepth,
216
+ parentSessionID,
217
+ rootSessionID: spawnContext.rootSessionID,
218
+ })
219
+ }
220
+
221
+ const maxRootSessionSpawnBudget = getMaxRootSessionSpawnBudget(this.config)
222
+ const descendantCount = this.rootDescendantCounts.get(spawnContext.rootSessionID) ?? 0
223
+ if (descendantCount >= maxRootSessionSpawnBudget) {
224
+ throw createSubagentDescendantLimitError({
225
+ rootSessionID: spawnContext.rootSessionID,
226
+ descendantCount,
227
+ maxDescendants: maxRootSessionSpawnBudget,
228
+ })
229
+ }
230
+
231
+ return spawnContext
232
+ }
233
+
234
+ async reserveSubagentSpawn(parentSessionID: string): Promise<{
235
+ spawnContext: SubagentSpawnContext
236
+ descendantCount: number
237
+ commit: () => number
238
+ rollback: () => void
239
+ }> {
240
+ const spawnContext = await this.assertCanSpawn(parentSessionID)
241
+ const descendantCount = this.registerRootDescendant(spawnContext.rootSessionID)
242
+ let settled = false
243
+
244
+ return {
245
+ spawnContext,
246
+ descendantCount,
247
+ commit: () => {
248
+ settled = true
249
+ return descendantCount
250
+ },
251
+ rollback: () => {
252
+ if (settled) return
253
+ settled = true
254
+ this.unregisterRootDescendant(spawnContext.rootSessionID)
255
+ },
256
+ }
257
+ }
258
+
259
+ private registerRootDescendant(rootSessionID: string): number {
260
+ const nextCount = (this.rootDescendantCounts.get(rootSessionID) ?? 0) + 1
261
+ this.rootDescendantCounts.set(rootSessionID, nextCount)
262
+ return nextCount
263
+ }
264
+
265
+ private unregisterRootDescendant(rootSessionID: string): void {
266
+ const currentCount = this.rootDescendantCounts.get(rootSessionID) ?? 0
267
+ if (currentCount <= 1) {
268
+ this.rootDescendantCounts.delete(rootSessionID)
269
+ return
270
+ }
271
+
272
+ this.rootDescendantCounts.set(rootSessionID, currentCount - 1)
273
+ }
274
+
275
+ private markPreStartDescendantReservation(task: BackgroundTask): void {
276
+ this.preStartDescendantReservations.add(task.id)
277
+ }
278
+
279
+ private settlePreStartDescendantReservation(task: BackgroundTask): void {
280
+ this.preStartDescendantReservations.delete(task.id)
281
+ }
282
+
283
+ private rollbackPreStartDescendantReservation(task: BackgroundTask): void {
284
+ if (!this.preStartDescendantReservations.delete(task.id)) {
285
+ return
286
+ }
287
+
288
+ if (!task.rootSessionID) {
289
+ return
290
+ }
291
+
292
+ this.unregisterRootDescendant(task.rootSessionID)
293
+ }
294
+
295
+ async launch(input: LaunchInput): Promise<BackgroundTask> {
296
+ log("[background-agent] launch() called with:", {
297
+ agent: input.agent,
298
+ model: input.model,
299
+ description: input.description,
300
+ parentSessionID: input.parentSessionID,
301
+ })
302
+
303
+ if (!input.agent || input.agent.trim() === "") {
304
+ throw new Error("Agent parameter is required")
305
+ }
306
+
307
+ const spawnReservation = await this.reserveSubagentSpawn(input.parentSessionID)
308
+
309
+ try {
310
+ log("[background-agent] spawn guard passed", {
311
+ parentSessionID: input.parentSessionID,
312
+ rootSessionID: spawnReservation.spawnContext.rootSessionID,
313
+ childDepth: spawnReservation.spawnContext.childDepth,
314
+ descendantCount: spawnReservation.descendantCount,
315
+ })
316
+
317
+ // Create task immediately with status="pending"
318
+ const task: BackgroundTask = {
319
+ id: `bg_${crypto.randomUUID().slice(0, 8)}`,
320
+ status: "pending",
321
+ queuedAt: new Date(),
322
+ rootSessionID: spawnReservation.spawnContext.rootSessionID,
323
+ // Do NOT set startedAt - will be set when running
324
+ // Do NOT set sessionID - will be set when running
325
+ description: input.description,
326
+ prompt: input.prompt,
327
+ agent: input.agent,
328
+ spawnDepth: spawnReservation.spawnContext.childDepth,
329
+ parentSessionID: input.parentSessionID,
330
+ parentMessageID: input.parentMessageID,
331
+ parentModel: input.parentModel,
332
+ parentAgent: input.parentAgent,
333
+ parentTools: input.parentTools,
334
+ model: input.model,
335
+ fallbackChain: input.fallbackChain,
336
+ attemptCount: 0,
337
+ category: input.category,
338
+ }
339
+
340
+ this.tasks.set(task.id, task)
341
+ this.taskHistory.record(input.parentSessionID, { id: task.id, agent: input.agent, description: input.description, status: "pending", category: input.category })
342
+
343
+ // Track for batched notifications immediately (pending state)
344
+ if (input.parentSessionID) {
345
+ const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
346
+ pending.add(task.id)
347
+ this.pendingByParent.set(input.parentSessionID, pending)
348
+ }
349
+
350
+ // Add to queue
351
+ const key = this.getConcurrencyKeyFromInput(input)
352
+ const queue = this.queuesByKey.get(key) ?? []
353
+ queue.push({ task, input })
354
+ this.queuesByKey.set(key, queue)
355
+
356
+ log("[background-agent] Task queued:", { taskId: task.id, key, queueLength: queue.length })
357
+
358
+ const toastManager = getTaskToastManager()
359
+ if (toastManager) {
360
+ toastManager.addTask({
361
+ id: task.id,
362
+ description: input.description,
363
+ agent: input.agent,
364
+ isBackground: true,
365
+ status: "queued",
366
+ skills: input.skills,
367
+ })
368
+ }
369
+
370
+ spawnReservation.commit()
371
+ this.markPreStartDescendantReservation(task)
372
+
373
+ // Trigger processing (fire-and-forget)
374
+ void this.processKey(key)
375
+
376
+ return { ...task }
377
+ } catch (error) {
378
+ spawnReservation.rollback()
379
+ throw error
380
+ }
381
+ }
382
+
383
+ private async processKey(key: string): Promise<void> {
384
+ if (this.processingKeys.has(key)) {
385
+ return
386
+ }
387
+
388
+ this.processingKeys.add(key)
389
+
390
+ try {
391
+ const queue = this.queuesByKey.get(key)
392
+ while (queue && queue.length > 0) {
393
+ const item = queue.shift()
394
+ if (!item) {
395
+ continue
396
+ }
397
+
398
+ await this.concurrencyManager.acquire(key)
399
+
400
+ if (item.task.status === "cancelled" || item.task.status === "error" || item.task.status === "interrupt") {
401
+ this.rollbackPreStartDescendantReservation(item.task)
402
+ this.concurrencyManager.release(key)
403
+ continue
404
+ }
405
+
406
+ try {
407
+ await this.startTask(item)
408
+ } catch (error) {
409
+ log("[background-agent] Error starting task:", error)
410
+ this.rollbackPreStartDescendantReservation(item.task)
411
+
412
+ // Mark task as error so the parent polling loop detects the failure
413
+ // instead of leaving it in a zombie "running" state with no prompt sent
414
+ item.task.status = "error"
415
+ item.task.error = error instanceof Error ? error.message : String(error)
416
+ item.task.completedAt = new Date()
417
+
418
+ if (item.task.concurrencyKey) {
419
+ this.concurrencyManager.release(item.task.concurrencyKey)
420
+ item.task.concurrencyKey = undefined
421
+ } else {
422
+ this.concurrencyManager.release(key)
423
+ }
424
+
425
+ removeTaskToastTracking(item.task.id)
426
+
427
+ // Abort the orphaned session if one was created before the error
428
+ if (item.task.sessionID) {
429
+ await this.abortSessionWithLogging(item.task.sessionID, "startTask error cleanup")
430
+ }
431
+
432
+ this.markForNotification(item.task)
433
+ this.enqueueNotificationForParent(item.task.parentSessionID, () => this.notifyParentSession(item.task)).catch(err => {
434
+ log("[background-agent] Failed to notify on startTask error:", err)
435
+ })
436
+ }
437
+ }
438
+ } finally {
439
+ this.processingKeys.delete(key)
440
+ }
441
+ }
442
+
443
+ private async startTask(item: QueueItem): Promise<void> {
444
+ const { task, input } = item
445
+
446
+ log("[background-agent] Starting task:", {
447
+ taskId: task.id,
448
+ agent: input.agent,
449
+ model: input.model,
450
+ })
451
+
452
+ const concurrencyKey = this.getConcurrencyKeyFromInput(input)
453
+
454
+ const parentSession = await this.client.session.get({
455
+ path: { id: input.parentSessionID },
456
+ query: { directory: this.directory },
457
+ }).catch((err) => {
458
+ log(`[background-agent] Failed to get parent session: ${err}`)
459
+ return null
460
+ })
461
+ const parentDirectory = parentSession?.data?.directory ?? this.directory
462
+ log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`)
463
+
464
+ const createResult = await this.client.session.create({
465
+ body: {
466
+ parentID: input.parentSessionID,
467
+ title: `${input.description} (@${input.agent} subagent)`,
468
+ ...(input.sessionPermission ? { permission: input.sessionPermission } : {}),
469
+ } as Record<string, unknown>,
470
+ query: {
471
+ directory: parentDirectory,
472
+ },
473
+ })
474
+
475
+ if (createResult.error) {
476
+ throw new Error(`Failed to create background session: ${createResult.error}`)
477
+ }
478
+
479
+ if (!createResult.data?.id) {
480
+ throw new Error("Failed to create background session: API returned no session ID")
481
+ }
482
+
483
+ const sessionID = createResult.data.id
484
+
485
+ if (task.status === "cancelled") {
486
+ await this.abortSessionWithLogging(sessionID, "cancelled pre-start cleanup")
487
+ this.concurrencyManager.release(concurrencyKey)
488
+ return
489
+ }
490
+
491
+ this.settlePreStartDescendantReservation(task)
492
+ subagentSessions.add(sessionID)
493
+
494
+ log("[background-agent] tmux callback check", {
495
+ hasCallback: !!this.onSubagentSessionCreated,
496
+ tmuxEnabled: this.tmuxEnabled,
497
+ isInsideTmux: isInsideTmux(),
498
+ sessionID,
499
+ parentID: input.parentSessionID,
500
+ })
501
+
502
+ if (this.onSubagentSessionCreated && this.tmuxEnabled && isInsideTmux()) {
503
+ log("[background-agent] Invoking tmux callback NOW", { sessionID })
504
+ await this.onSubagentSessionCreated({
505
+ sessionID,
506
+ parentID: input.parentSessionID,
507
+ title: input.description,
508
+ }).catch((err) => {
509
+ log("[background-agent] Failed to spawn tmux pane:", err)
510
+ })
511
+ log("[background-agent] tmux callback completed, waiting 200ms")
512
+ await new Promise(r => setTimeout(r, 200))
513
+ } else {
514
+ log("[background-agent] SKIP tmux callback - conditions not met")
515
+ }
516
+
517
+ if (this.tasks.get(task.id)?.status === "cancelled") {
518
+ await this.abortSessionWithLogging(sessionID, "cancelled during tmux setup")
519
+ subagentSessions.delete(sessionID)
520
+ if (task.rootSessionID) {
521
+ this.unregisterRootDescendant(task.rootSessionID)
522
+ }
523
+ this.concurrencyManager.release(concurrencyKey)
524
+ return
525
+ }
526
+
527
+ task.status = "running"
528
+ task.startedAt = new Date()
529
+ task.sessionID = sessionID
530
+ task.progress = {
531
+ toolCalls: 0,
532
+ lastUpdate: new Date(),
533
+ }
534
+ task.concurrencyKey = concurrencyKey
535
+ task.concurrencyGroup = concurrencyKey
536
+
537
+ this.taskHistory.record(input.parentSessionID, { id: task.id, sessionID, agent: input.agent, description: input.description, status: "running", category: input.category, startedAt: task.startedAt })
538
+ this.startPolling()
539
+
540
+ log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
541
+
542
+ const toastManager = getTaskToastManager()
543
+ if (toastManager) {
544
+ toastManager.updateTask(task.id, "running")
545
+ }
546
+
547
+ log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
548
+ sessionID,
549
+ agent: input.agent,
550
+ model: input.model,
551
+ hasSkillContent: !!input.skillContent,
552
+ promptLength: input.prompt.length,
553
+ })
554
+
555
+ // Fire-and-forget prompt via promptAsync (no response body needed)
556
+ // OpenCode prompt payload accepts model provider/model IDs and top-level variant only.
557
+ // Temperature/topP and provider-specific options are applied through chat.params.
558
+ const launchModel = input.model
559
+ ? {
560
+ providerID: input.model.providerID,
561
+ modelID: input.model.modelID,
562
+ }
563
+ : undefined
564
+ const launchVariant = input.model?.variant
565
+
566
+ if (input.model) {
567
+ applySessionPromptParams(sessionID, input.model)
568
+ }
569
+
570
+ const promptBody = {
571
+ agent: input.agent,
572
+ ...(launchModel ? { model: launchModel } : {}),
573
+ ...(launchVariant ? { variant: launchVariant } : {}),
574
+ system: input.skillContent,
575
+ tools: (() => {
576
+ const tools = {
577
+ task: false,
578
+ call_omo_agent: true,
579
+ question: false,
580
+ ...getAgentToolRestrictions(input.agent),
581
+ }
582
+ setSessionTools(sessionID, tools)
583
+ return tools
584
+ })(),
585
+ parts: [createInternalAgentTextPart(input.prompt)],
586
+ }
587
+
588
+ promptWithModelSuggestionRetry(this.client, {
589
+ path: { id: sessionID },
590
+ body: promptBody,
591
+ }).catch(async (error) => {
592
+ // Retry with fallback agent if the original agent was unregistered (e.g., after a model switch)
593
+ if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) {
594
+ log("[background-agent] Agent not found, retrying with fallback agent", {
595
+ original: input.agent,
596
+ fallback: FALLBACK_AGENT,
597
+ taskId: task.id,
598
+ })
599
+ try {
600
+ const fallbackBody = buildFallbackBody(promptBody, FALLBACK_AGENT)
601
+ setSessionTools(sessionID, fallbackBody.tools as Record<string, boolean>)
602
+ await promptWithModelSuggestionRetry(this.client, {
603
+ path: { id: sessionID },
604
+ body: fallbackBody,
605
+ })
606
+ task.agent = FALLBACK_AGENT
607
+ return
608
+ } catch (retryError) {
609
+ log("[background-agent] Fallback agent also failed:", retryError)
610
+ }
611
+ }
612
+
613
+ log("[background-agent] promptAsync error:", error)
614
+ const existingTask = this.findBySession(sessionID)
615
+ if (existingTask) {
616
+ existingTask.status = "interrupt"
617
+ const errorMessage = error instanceof Error ? error.message : String(error)
618
+ if (errorMessage.includes("agent.name") || errorMessage.includes("undefined") || isAgentNotFoundError(error)) {
619
+ existingTask.error = `Agent "${input.agent}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`
620
+ } else {
621
+ existingTask.error = errorMessage
622
+ }
623
+ existingTask.completedAt = new Date()
624
+ if (existingTask.rootSessionID) {
625
+ this.unregisterRootDescendant(existingTask.rootSessionID)
626
+ }
627
+ if (existingTask.concurrencyKey) {
628
+ this.concurrencyManager.release(existingTask.concurrencyKey)
629
+ existingTask.concurrencyKey = undefined
630
+ }
631
+
632
+ removeTaskToastTracking(existingTask.id)
633
+
634
+ // Abort the session to prevent infinite polling hang
635
+ // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
636
+ await this.abortSessionWithLogging(sessionID, "launch error cleanup")
637
+
638
+ this.markForNotification(existingTask)
639
+ this.enqueueNotificationForParent(existingTask.parentSessionID, () => this.notifyParentSession(existingTask)).catch(err => {
640
+ log("[background-agent] Failed to notify on error:", err)
641
+ })
642
+ }
643
+ })
644
+ }
645
+
646
+ getTask(id: string): BackgroundTask | undefined {
647
+ return this.tasks.get(id)
648
+ }
649
+
650
+ getTasksByParentSession(sessionID: string): BackgroundTask[] {
651
+ const result: BackgroundTask[] = []
652
+ for (const task of this.tasks.values()) {
653
+ if (task.parentSessionID === sessionID) {
654
+ result.push(task)
655
+ }
656
+ }
657
+ return result
658
+ }
659
+
660
+ getAllDescendantTasks(sessionID: string): BackgroundTask[] {
661
+ const result: BackgroundTask[] = []
662
+ const directChildren = this.getTasksByParentSession(sessionID)
663
+
664
+ for (const child of directChildren) {
665
+ result.push(child)
666
+ if (child.sessionID) {
667
+ const descendants = this.getAllDescendantTasks(child.sessionID)
668
+ result.push(...descendants)
669
+ }
670
+ }
671
+
672
+ return result
673
+ }
674
+
675
+ findBySession(sessionID: string): BackgroundTask | undefined {
676
+ for (const task of this.tasks.values()) {
677
+ if (task.sessionID === sessionID) {
678
+ return task
679
+ }
680
+ }
681
+ return undefined
682
+ }
683
+
684
+ private getConcurrencyKeyFromInput(input: LaunchInput): string {
685
+ if (input.model) {
686
+ return `${input.model.providerID}/${input.model.modelID}`
687
+ }
688
+ return input.agent
689
+ }
690
+
691
+ /**
692
+ * Track a task created elsewhere (e.g., from task) for notification tracking.
693
+ * This allows tasks created by other tools to receive the same toast/prompt notifications.
694
+ */
695
+ async trackTask(input: {
696
+ taskId: string
697
+ sessionID: string
698
+ parentSessionID: string
699
+ description: string
700
+ agent?: string
701
+ parentAgent?: string
702
+ concurrencyKey?: string
703
+ }): Promise<BackgroundTask> {
704
+ const existingTask = this.tasks.get(input.taskId)
705
+ if (existingTask) {
706
+ // P2 fix: Clean up old parent's pending set BEFORE changing parent
707
+ // Otherwise cleanupPendingByParent would use the new parent ID
708
+ const parentChanged = input.parentSessionID !== existingTask.parentSessionID
709
+ if (parentChanged) {
710
+ this.cleanupPendingByParent(existingTask) // Clean from OLD parent
711
+ existingTask.parentSessionID = input.parentSessionID
712
+ }
713
+ if (input.parentAgent !== undefined) {
714
+ existingTask.parentAgent = input.parentAgent
715
+ }
716
+ if (!existingTask.concurrencyGroup) {
717
+ existingTask.concurrencyGroup = input.concurrencyKey ?? existingTask.agent
718
+ }
719
+
720
+ if (existingTask.sessionID) {
721
+ subagentSessions.add(existingTask.sessionID)
722
+ }
723
+ this.startPolling()
724
+
725
+ // Track for batched notifications if task is pending or running
726
+ if (existingTask.status === "pending" || existingTask.status === "running") {
727
+ const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
728
+ pending.add(existingTask.id)
729
+ this.pendingByParent.set(input.parentSessionID, pending)
730
+ } else if (!parentChanged) {
731
+ // Only clean up if parent didn't change (already cleaned above if it did)
732
+ this.cleanupPendingByParent(existingTask)
733
+ }
734
+
735
+ log("[background-agent] External task already registered:", { taskId: existingTask.id, sessionID: existingTask.sessionID, status: existingTask.status })
736
+
737
+ return existingTask
738
+ }
739
+
740
+ const concurrencyGroup = input.concurrencyKey ?? input.agent ?? "task"
741
+
742
+ // Acquire concurrency slot if a key is provided
743
+ if (input.concurrencyKey) {
744
+ await this.concurrencyManager.acquire(input.concurrencyKey)
745
+ }
746
+
747
+ const task: BackgroundTask = {
748
+ id: input.taskId,
749
+ sessionID: input.sessionID,
750
+ parentSessionID: input.parentSessionID,
751
+ parentMessageID: "",
752
+ description: input.description,
753
+ prompt: "",
754
+ agent: input.agent || "task",
755
+ status: "running",
756
+ startedAt: new Date(),
757
+ progress: {
758
+ toolCalls: 0,
759
+ lastUpdate: new Date(),
760
+ },
761
+ parentAgent: input.parentAgent,
762
+ concurrencyKey: input.concurrencyKey,
763
+ concurrencyGroup,
764
+ }
765
+
766
+ this.tasks.set(task.id, task)
767
+ subagentSessions.add(input.sessionID)
768
+ this.startPolling()
769
+ this.taskHistory.record(input.parentSessionID, { id: task.id, sessionID: input.sessionID, agent: input.agent || "task", description: input.description, status: "running", startedAt: task.startedAt })
770
+
771
+ if (input.parentSessionID) {
772
+ const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
773
+ pending.add(task.id)
774
+ this.pendingByParent.set(input.parentSessionID, pending)
775
+ }
776
+
777
+ log("[background-agent] Registered external task:", { taskId: task.id, sessionID: input.sessionID })
778
+
779
+ return task
780
+ }
781
+
782
+ async resume(input: ResumeInput): Promise<BackgroundTask> {
783
+ const existingTask = this.findBySession(input.sessionId)
784
+ if (!existingTask) {
785
+ throw new Error(`Task not found for session: ${input.sessionId}`)
786
+ }
787
+
788
+ if (!existingTask.sessionID) {
789
+ throw new Error(`Task has no sessionID: ${existingTask.id}`)
790
+ }
791
+
792
+ if (existingTask.status === "running") {
793
+ log("[background-agent] Resume skipped - task already running:", {
794
+ taskId: existingTask.id,
795
+ sessionID: existingTask.sessionID,
796
+ })
797
+ return existingTask
798
+ }
799
+
800
+ const completionTimer = this.completionTimers.get(existingTask.id)
801
+ if (completionTimer) {
802
+ clearTimeout(completionTimer)
803
+ this.completionTimers.delete(existingTask.id)
804
+ }
805
+
806
+ // Re-acquire concurrency using the persisted concurrency group
807
+ const concurrencyKey = existingTask.concurrencyGroup ?? existingTask.agent
808
+ await this.concurrencyManager.acquire(concurrencyKey)
809
+ existingTask.concurrencyKey = concurrencyKey
810
+ existingTask.concurrencyGroup = concurrencyKey
811
+
812
+
813
+ existingTask.status = "running"
814
+ existingTask.completedAt = undefined
815
+ existingTask.error = undefined
816
+ existingTask.parentSessionID = input.parentSessionID
817
+ existingTask.parentMessageID = input.parentMessageID
818
+ existingTask.parentModel = input.parentModel
819
+ existingTask.parentAgent = input.parentAgent
820
+ if (input.parentTools) {
821
+ existingTask.parentTools = input.parentTools
822
+ }
823
+ // Reset startedAt on resume to prevent immediate completion
824
+ // The MIN_IDLE_TIME_MS check uses startedAt, so resumed tasks need fresh timing
825
+ existingTask.startedAt = new Date()
826
+
827
+ existingTask.progress = {
828
+ toolCalls: existingTask.progress?.toolCalls ?? 0,
829
+ toolCallWindow: existingTask.progress?.toolCallWindow,
830
+ countedToolPartIDs: existingTask.progress?.countedToolPartIDs,
831
+ lastUpdate: new Date(),
832
+ }
833
+
834
+ this.startPolling()
835
+ if (existingTask.sessionID) {
836
+ subagentSessions.add(existingTask.sessionID)
837
+ }
838
+
839
+ if (input.parentSessionID) {
840
+ const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
841
+ pending.add(existingTask.id)
842
+ this.pendingByParent.set(input.parentSessionID, pending)
843
+ }
844
+
845
+ const toastManager = getTaskToastManager()
846
+ if (toastManager) {
847
+ toastManager.addTask({
848
+ id: existingTask.id,
849
+ description: existingTask.description,
850
+ agent: existingTask.agent,
851
+ isBackground: true,
852
+ })
853
+ }
854
+
855
+ log("[background-agent] Resuming task:", { taskId: existingTask.id, sessionID: existingTask.sessionID })
856
+
857
+ log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", {
858
+ sessionID: existingTask.sessionID,
859
+ agent: existingTask.agent,
860
+ model: existingTask.model,
861
+ promptLength: input.prompt.length,
862
+ })
863
+
864
+ // Fire-and-forget prompt via promptAsync (no response body needed)
865
+ // Resume uses the same PromptInput contract as launch: model IDs plus top-level variant.
866
+ const resumeModel = existingTask.model
867
+ ? {
868
+ providerID: existingTask.model.providerID,
869
+ modelID: existingTask.model.modelID,
870
+ }
871
+ : undefined
872
+ const resumeVariant = existingTask.model?.variant
873
+
874
+ if (existingTask.model) {
875
+ applySessionPromptParams(existingTask.sessionID!, existingTask.model)
876
+ }
877
+
878
+ this.client.session.promptAsync({
879
+ path: { id: existingTask.sessionID },
880
+ body: {
881
+ agent: existingTask.agent,
882
+ ...(resumeModel ? { model: resumeModel } : {}),
883
+ ...(resumeVariant ? { variant: resumeVariant } : {}),
884
+ tools: (() => {
885
+ const tools = {
886
+ task: false,
887
+ call_omo_agent: true,
888
+ question: false,
889
+ ...getAgentToolRestrictions(existingTask.agent),
890
+ }
891
+ setSessionTools(existingTask.sessionID!, tools)
892
+ return tools
893
+ })(),
894
+ parts: [createInternalAgentTextPart(input.prompt)],
895
+ },
896
+ }).catch(async (error) => {
897
+ log("[background-agent] resume prompt error:", error)
898
+ existingTask.status = "interrupt"
899
+ const errorMessage = error instanceof Error ? error.message : String(error)
900
+ existingTask.error = errorMessage
901
+ existingTask.completedAt = new Date()
902
+ if (existingTask.rootSessionID) {
903
+ this.unregisterRootDescendant(existingTask.rootSessionID)
904
+ }
905
+
906
+ // Release concurrency on error to prevent slot leaks
907
+ if (existingTask.concurrencyKey) {
908
+ this.concurrencyManager.release(existingTask.concurrencyKey)
909
+ existingTask.concurrencyKey = undefined
910
+ }
911
+
912
+ removeTaskToastTracking(existingTask.id)
913
+
914
+ // Abort the session to prevent infinite polling hang
915
+ // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
916
+ if (existingTask.sessionID) {
917
+ await this.abortSessionWithLogging(existingTask.sessionID, "resume error cleanup")
918
+ }
919
+
920
+ this.markForNotification(existingTask)
921
+ this.enqueueNotificationForParent(existingTask.parentSessionID, () => this.notifyParentSession(existingTask)).catch(err => {
922
+ log("[background-agent] Failed to notify on resume error:", err)
923
+ })
924
+ })
925
+
926
+ return existingTask
927
+ }
928
+
929
+ private async checkSessionTodos(sessionID: string): Promise<boolean> {
930
+ const observedIncompleteTodos = this.observedIncompleteTodosBySession.get(sessionID)
931
+ if (observedIncompleteTodos !== undefined) {
932
+ return observedIncompleteTodos
933
+ }
934
+
935
+ try {
936
+ const response = await this.client.session.todo({
937
+ path: { id: sessionID },
938
+ })
939
+ const todos = normalizeSDKResponse(response, [] as Todo[], { preferResponseOnMissingData: true })
940
+ if (!todos || todos.length === 0) {
941
+ this.observedIncompleteTodosBySession.set(sessionID, false)
942
+ return false
943
+ }
944
+
945
+ const incomplete = todos.filter(
946
+ (t) => t.status !== "completed" && t.status !== "cancelled"
947
+ )
948
+ const hasIncompleteTodos = incomplete.length > 0
949
+ this.observedIncompleteTodosBySession.set(sessionID, hasIncompleteTodos)
950
+ return hasIncompleteTodos
951
+ } catch (error) {
952
+ log("[background-agent] Failed to check session todos:", {
953
+ sessionID,
954
+ error,
955
+ })
956
+ return false
957
+ }
958
+ }
959
+
960
+ private markSessionOutputObserved(sessionID: string): void {
961
+ this.observedOutputSessions.add(sessionID)
962
+ }
963
+
964
+ private clearSessionOutputObserved(sessionID: string): void {
965
+ this.observedOutputSessions.delete(sessionID)
966
+ }
967
+
968
+ private clearSessionTodoObservation(sessionID: string): void {
969
+ this.observedIncompleteTodosBySession.delete(sessionID)
970
+ }
971
+
972
+ private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined): boolean {
973
+ if (!partInfo?.sessionID) return false
974
+ if (partInfo.tool) return true
975
+ if (partInfo.type === "tool" || partInfo.type === "tool_result") return true
976
+ if (partInfo.type === "text" || partInfo.type === "reasoning") return true
977
+
978
+ const field = typeof (partInfo as { field?: unknown }).field === "string"
979
+ ? (partInfo as { field?: string }).field
980
+ : undefined
981
+ return field === "text" || field === "reasoning"
982
+ }
983
+
984
+ handleEvent(event: Event): void {
985
+ const props = event.properties
986
+
987
+ if (event.type === "message.updated") {
988
+ const info = props?.info
989
+ if (!info || typeof info !== "object") return
990
+
991
+ const sessionID = (info as Record<string, unknown>)["sessionID"]
992
+ const role = (info as Record<string, unknown>)["role"]
993
+ if (typeof sessionID !== "string") return
994
+
995
+ if (role === "tool") {
996
+ this.markSessionOutputObserved(sessionID)
997
+ }
998
+
999
+ if (role !== "assistant") return
1000
+
1001
+ const task = this.findBySession(sessionID)
1002
+ if (!task || task.status !== "running") return
1003
+
1004
+ const assistantError = (info as Record<string, unknown>)["error"]
1005
+ if (!assistantError) return
1006
+
1007
+ const errorInfo = {
1008
+ name: extractErrorName(assistantError),
1009
+ message: extractErrorMessage(assistantError),
1010
+ }
1011
+ void this.tryFallbackRetry(task, errorInfo, "message.updated").catch((error) => {
1012
+ log("[background-agent] Error handling message.updated fallback retry:", {
1013
+ error,
1014
+ taskId: task.id,
1015
+ })
1016
+ })
1017
+ }
1018
+
1019
+ if (event.type === "message.part.updated" || event.type === "message.part.delta") {
1020
+ const partInfo = resolveMessagePartInfo(props)
1021
+ const sessionID = partInfo?.sessionID
1022
+ if (!sessionID) return
1023
+
1024
+ const task = this.findBySession(sessionID)
1025
+ if (!task) return
1026
+
1027
+ if (this.hasOutputSignalFromPart(partInfo)) {
1028
+ this.markSessionOutputObserved(sessionID)
1029
+ }
1030
+
1031
+ // Clear any pending idle deferral timer since the task is still active
1032
+ const existingTimer = this.idleDeferralTimers.get(task.id)
1033
+ if (existingTimer) {
1034
+ clearTimeout(existingTimer)
1035
+ this.idleDeferralTimers.delete(task.id)
1036
+ }
1037
+
1038
+ if (!task.progress) {
1039
+ task.progress = {
1040
+ toolCalls: 0,
1041
+ lastUpdate: new Date(),
1042
+ }
1043
+ }
1044
+ task.progress.lastUpdate = new Date()
1045
+
1046
+ if (partInfo?.type === "tool" || partInfo?.tool) {
1047
+ const countedToolPartIDs = task.progress.countedToolPartIDs ?? new Set<string>()
1048
+ const shouldCountToolCall =
1049
+ !partInfo.id ||
1050
+ partInfo.state?.status !== "running" ||
1051
+ !countedToolPartIDs.has(partInfo.id)
1052
+
1053
+ if (!shouldCountToolCall) {
1054
+ return
1055
+ }
1056
+
1057
+ if (partInfo.id && partInfo.state?.status === "running") {
1058
+ countedToolPartIDs.add(partInfo.id)
1059
+ task.progress.countedToolPartIDs = countedToolPartIDs
1060
+ }
1061
+
1062
+ task.progress.toolCalls += 1
1063
+ task.progress.lastTool = partInfo.tool
1064
+ const circuitBreaker = this.cachedCircuitBreakerSettings ?? resolveCircuitBreakerSettings(this.config)
1065
+ this.cachedCircuitBreakerSettings = circuitBreaker
1066
+ if (partInfo.tool) {
1067
+ task.progress.toolCallWindow = recordToolCall(
1068
+ task.progress.toolCallWindow,
1069
+ partInfo.tool,
1070
+ circuitBreaker,
1071
+ partInfo.state?.input
1072
+ )
1073
+
1074
+ if (circuitBreaker.enabled) {
1075
+ const loopDetection = detectRepetitiveToolUse(task.progress.toolCallWindow)
1076
+ if (loopDetection.triggered) {
1077
+ log("[background-agent] Circuit breaker: consecutive tool usage detected", {
1078
+ taskId: task.id,
1079
+ agent: task.agent,
1080
+ sessionID,
1081
+ toolName: loopDetection.toolName,
1082
+ repeatedCount: loopDetection.repeatedCount,
1083
+ })
1084
+ void this.cancelTask(task.id, {
1085
+ source: "circuit-breaker",
1086
+ reason: `Subagent called ${loopDetection.toolName} ${loopDetection.repeatedCount} consecutive times (threshold: ${circuitBreaker.consecutiveThreshold}). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage.`,
1087
+ })
1088
+ return
1089
+ }
1090
+ }
1091
+ }
1092
+
1093
+ const maxToolCalls = circuitBreaker.maxToolCalls
1094
+ if (task.progress.toolCalls >= maxToolCalls) {
1095
+ log("[background-agent] Circuit breaker: tool call limit reached", {
1096
+ taskId: task.id,
1097
+ toolCalls: task.progress.toolCalls,
1098
+ maxToolCalls,
1099
+ agent: task.agent,
1100
+ sessionID,
1101
+ })
1102
+ void this.cancelTask(task.id, {
1103
+ source: "circuit-breaker",
1104
+ reason: `Subagent exceeded maximum tool call limit (${maxToolCalls}). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage.`,
1105
+ })
1106
+ }
1107
+ }
1108
+ }
1109
+
1110
+ if (event.type === "todo.updated") {
1111
+ const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined
1112
+ const todos = Array.isArray(props?.todos) ? props.todos : undefined
1113
+ if (!sessionID || !todos) return
1114
+
1115
+ const hasIncompleteTodos = todos.some((todo) => {
1116
+ if (!todo || typeof todo !== "object") return false
1117
+ const status = (todo as { status?: unknown }).status
1118
+ return status !== "completed" && status !== "cancelled"
1119
+ })
1120
+ this.observedIncompleteTodosBySession.set(sessionID, hasIncompleteTodos)
1121
+ return
1122
+ }
1123
+
1124
+ if (event.type === "session.idle") {
1125
+ if (!props || typeof props !== "object") return
1126
+ handleSessionIdleBackgroundEvent({
1127
+ properties: props as Record<string, unknown>,
1128
+ findBySession: (id) => this.findBySession(id),
1129
+ idleDeferralTimers: this.idleDeferralTimers,
1130
+ validateSessionHasOutput: (id) => this.validateSessionHasOutput(id),
1131
+ checkSessionTodos: (id) => this.checkSessionTodos(id),
1132
+ tryCompleteTask: (task, source) => this.tryCompleteTask(task, source),
1133
+ emitIdleEvent: (sessionID) => this.handleEvent({ type: "session.idle", properties: { sessionID } }),
1134
+ })
1135
+ }
1136
+
1137
+ if (event.type === "session.error") {
1138
+ const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined
1139
+ if (!sessionID) return
1140
+
1141
+ const task = this.findBySession(sessionID)
1142
+ if (!task || task.status !== "running") return
1143
+
1144
+ const errorObj = props?.error as { name?: string; message?: string } | undefined
1145
+ const errorName = errorObj?.name
1146
+ const errorMessage = props ? getSessionErrorMessage(props) : undefined
1147
+
1148
+ const errorInfo = { name: errorName, message: errorMessage }
1149
+ void this.handleSessionErrorEvent({
1150
+ errorInfo,
1151
+ errorMessage,
1152
+ errorName,
1153
+ task,
1154
+ }).catch((error) => {
1155
+ log("[background-agent] Error handling session.error event:", {
1156
+ error,
1157
+ taskId: task.id,
1158
+ })
1159
+ })
1160
+ return
1161
+ }
1162
+
1163
+ if (event.type === "session.deleted") {
1164
+ const info = props?.info
1165
+ if (!info || typeof info.id !== "string") return
1166
+ const sessionID = info.id
1167
+ this.clearSessionOutputObserved(sessionID)
1168
+ this.clearSessionTodoObservation(sessionID)
1169
+
1170
+ const tasksToCancel = new Map<string, BackgroundTask>()
1171
+ const directTask = this.findBySession(sessionID)
1172
+ if (directTask) {
1173
+ tasksToCancel.set(directTask.id, directTask)
1174
+ }
1175
+ for (const descendant of this.getAllDescendantTasks(sessionID)) {
1176
+ tasksToCancel.set(descendant.id, descendant)
1177
+ }
1178
+
1179
+ this.pendingNotifications.delete(sessionID)
1180
+
1181
+ if (tasksToCancel.size === 0) {
1182
+ this.clearTaskHistoryWhenParentTasksGone(sessionID)
1183
+ return
1184
+ }
1185
+
1186
+ const parentSessionsToClear = new Set<string>()
1187
+
1188
+ const deletedSessionIDs = new Set<string>([sessionID])
1189
+ for (const task of tasksToCancel.values()) {
1190
+ if (task.sessionID) {
1191
+ deletedSessionIDs.add(task.sessionID)
1192
+ }
1193
+ }
1194
+
1195
+ for (const task of tasksToCancel.values()) {
1196
+ parentSessionsToClear.add(task.parentSessionID)
1197
+
1198
+ if (task.status === "running" || task.status === "pending") {
1199
+ void this.cancelTask(task.id, {
1200
+ source: "session.deleted",
1201
+ reason: "Session deleted",
1202
+ }).then(() => {
1203
+ if (deletedSessionIDs.has(task.parentSessionID)) {
1204
+ this.pendingNotifications.delete(task.parentSessionID)
1205
+ }
1206
+ }).catch(err => {
1207
+ if (deletedSessionIDs.has(task.parentSessionID)) {
1208
+ this.pendingNotifications.delete(task.parentSessionID)
1209
+ }
1210
+ log("[background-agent] Failed to cancel task on session.deleted:", { taskId: task.id, error: err })
1211
+ })
1212
+ }
1213
+ }
1214
+
1215
+ for (const parentSessionID of parentSessionsToClear) {
1216
+ this.clearTaskHistoryWhenParentTasksGone(parentSessionID)
1217
+ }
1218
+
1219
+ this.rootDescendantCounts.delete(sessionID)
1220
+ SessionCategoryRegistry.remove(sessionID)
1221
+ }
1222
+
1223
+ if (event.type === "session.status") {
1224
+ const sessionID = props?.sessionID as string | undefined
1225
+ const status = props?.status as { type?: string; message?: string } | undefined
1226
+ if (!sessionID || status?.type !== "retry") return
1227
+
1228
+ const task = this.findBySession(sessionID)
1229
+ if (!task || task.status !== "running") return
1230
+
1231
+ const errorMessage = typeof status.message === "string" ? status.message : undefined
1232
+ const errorInfo = { name: "SessionRetry", message: errorMessage }
1233
+ void this.tryFallbackRetry(task, errorInfo, "session.status").catch((error) => {
1234
+ log("[background-agent] Error handling session.status fallback retry:", {
1235
+ error,
1236
+ taskId: task.id,
1237
+ })
1238
+ })
1239
+ }
1240
+ }
1241
+
1242
+ private async handleSessionErrorEvent(args: {
1243
+ task: BackgroundTask
1244
+ errorInfo: { name?: string; message?: string }
1245
+ errorName: string | undefined
1246
+ errorMessage: string | undefined
1247
+ }): Promise<void> {
1248
+ const { task, errorInfo, errorMessage, errorName } = args
1249
+
1250
+ // Agent-not-found errors are handled by the prompt catch block with agent fallback.
1251
+ // Do not also trigger model fallback retry — that would race with the agent retry.
1252
+ if (isAgentNotFoundError({ message: errorInfo.message } as Error)) {
1253
+ log("[background-agent] Skipping session.error fallback for agent-not-found (handled by prompt catch)", {
1254
+ taskId: task.id,
1255
+ errorMessage: errorInfo.message?.slice(0, 100),
1256
+ })
1257
+ return
1258
+ }
1259
+
1260
+ if (await this.tryFallbackRetry(task, errorInfo, "session.error")) {
1261
+ return
1262
+ }
1263
+
1264
+ const errorMsg = errorMessage ?? "Session error"
1265
+ const canRetry =
1266
+ shouldRetryError(errorInfo) &&
1267
+ !!task.fallbackChain &&
1268
+ hasMoreFallbacks(task.fallbackChain, task.attemptCount ?? 0)
1269
+ log("[background-agent] Session error - no retry:", {
1270
+ taskId: task.id,
1271
+ errorName,
1272
+ errorMessage: errorMsg?.slice(0, 100),
1273
+ hasFallbackChain: !!task.fallbackChain,
1274
+ canRetry,
1275
+ })
1276
+
1277
+ task.status = "error"
1278
+ task.error = errorMsg
1279
+ task.completedAt = new Date()
1280
+ if (task.rootSessionID) {
1281
+ this.unregisterRootDescendant(task.rootSessionID)
1282
+ }
1283
+ this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
1284
+
1285
+ if (task.concurrencyKey) {
1286
+ this.concurrencyManager.release(task.concurrencyKey)
1287
+ task.concurrencyKey = undefined
1288
+ }
1289
+
1290
+ const completionTimer = this.completionTimers.get(task.id)
1291
+ if (completionTimer) {
1292
+ clearTimeout(completionTimer)
1293
+ this.completionTimers.delete(task.id)
1294
+ }
1295
+
1296
+ const idleTimer = this.idleDeferralTimers.get(task.id)
1297
+ if (idleTimer) {
1298
+ clearTimeout(idleTimer)
1299
+ this.idleDeferralTimers.delete(task.id)
1300
+ }
1301
+
1302
+ this.cleanupPendingByParent(task)
1303
+ this.clearNotificationsForTask(task.id)
1304
+ const toastManager = getTaskToastManager()
1305
+ if (toastManager) {
1306
+ toastManager.removeTask(task.id)
1307
+ }
1308
+ this.scheduleTaskRemoval(task.id)
1309
+ if (task.sessionID) {
1310
+ SessionCategoryRegistry.remove(task.sessionID)
1311
+ }
1312
+
1313
+ this.markForNotification(task)
1314
+ this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => {
1315
+ log("[background-agent] Error in notifyParentSession for errored task:", { taskId: task.id, error: err })
1316
+ })
1317
+ }
1318
+
1319
+ private tryFallbackRetry(
1320
+ task: BackgroundTask,
1321
+ errorInfo: { name?: string; message?: string },
1322
+ source: string,
1323
+ ): Promise<boolean> {
1324
+ const previousSessionID = task.sessionID
1325
+ const result = tryFallbackRetry({
1326
+ task,
1327
+ errorInfo,
1328
+ source,
1329
+ concurrencyManager: this.concurrencyManager,
1330
+ client: this.client,
1331
+ idleDeferralTimers: this.idleDeferralTimers,
1332
+ queuesByKey: this.queuesByKey,
1333
+ processKey: (key: string) => this.processKey(key),
1334
+ })
1335
+ return result.then((retried) => {
1336
+ if (retried && previousSessionID) {
1337
+ this.clearSessionOutputObserved(previousSessionID)
1338
+ this.clearSessionTodoObservation(previousSessionID)
1339
+ subagentSessions.delete(previousSessionID)
1340
+ }
1341
+ return retried
1342
+ })
1343
+ }
1344
+
1345
+ markForNotification(task: BackgroundTask): void {
1346
+ const queue = this.notifications.get(task.parentSessionID) ?? []
1347
+ queue.push(task)
1348
+ this.notifications.set(task.parentSessionID, queue)
1349
+ }
1350
+
1351
+ getPendingNotifications(sessionID: string): BackgroundTask[] {
1352
+ return this.notifications.get(sessionID) ?? []
1353
+ }
1354
+
1355
+ clearNotifications(sessionID: string): void {
1356
+ this.notifications.delete(sessionID)
1357
+ }
1358
+
1359
+ queuePendingNotification(sessionID: string | undefined, notification: string): void {
1360
+ if (!sessionID) return
1361
+ const existingNotifications = this.pendingNotifications.get(sessionID) ?? []
1362
+ existingNotifications.push(notification)
1363
+ this.pendingNotifications.set(sessionID, existingNotifications)
1364
+ }
1365
+
1366
+ injectPendingNotificationsIntoChatMessage(output: { parts: Array<{ type: string; text?: string; [key: string]: unknown }> }, sessionID: string): void {
1367
+ const pendingNotifications = this.pendingNotifications.get(sessionID)
1368
+ if (!pendingNotifications || pendingNotifications.length === 0) {
1369
+ return
1370
+ }
1371
+
1372
+ this.pendingNotifications.delete(sessionID)
1373
+ const notificationContent = pendingNotifications.join("\n\n")
1374
+ const firstTextPartIndex = output.parts.findIndex((part) => part.type === "text")
1375
+
1376
+ if (firstTextPartIndex === -1) {
1377
+ output.parts.unshift(createInternalAgentTextPart(notificationContent))
1378
+ return
1379
+ }
1380
+
1381
+ const originalText = output.parts[firstTextPartIndex].text ?? ""
1382
+ output.parts[firstTextPartIndex].text = `${notificationContent}\n\n---\n\n${originalText}`
1383
+ }
1384
+
1385
+ /**
1386
+ * Validates that a session has actual assistant/tool output before marking complete.
1387
+ * Prevents premature completion when session.idle fires before agent responds.
1388
+ */
1389
+ private async validateSessionHasOutput(sessionID: string): Promise<boolean> {
1390
+ if (this.observedOutputSessions.has(sessionID)) {
1391
+ return true
1392
+ }
1393
+
1394
+ try {
1395
+ const response = await this.client.session.messages({
1396
+ path: { id: sessionID },
1397
+ })
1398
+
1399
+ const messages = normalizeSDKResponse(response, [] as Array<{ info?: { role?: string } }>, { preferResponseOnMissingData: true })
1400
+
1401
+ // Check for at least one assistant or tool message
1402
+ const hasAssistantOrToolMessage = messages.some(
1403
+ (m: { info?: { role?: string } }) =>
1404
+ m.info?.role === "assistant" || m.info?.role === "tool"
1405
+ )
1406
+
1407
+ if (!hasAssistantOrToolMessage) {
1408
+ log("[background-agent] No assistant/tool messages found in session:", sessionID)
1409
+ return false
1410
+ }
1411
+
1412
+ // OpenCode API uses different part types than Anthropic's API:
1413
+ // - "reasoning" with .text property (thinking/reasoning content)
1414
+ // - "tool" with .state.output property (tool call results)
1415
+ // - "text" with .text property (final text output)
1416
+ // - "step-start"/"step-finish" (metadata, no content)
1417
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1418
+ const hasContent = messages.some((m: any) => {
1419
+ if (m.info?.role !== "assistant" && m.info?.role !== "tool") return false
1420
+ const parts = m.parts ?? []
1421
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1422
+ return parts.some((p: any) =>
1423
+ // Text content (final output)
1424
+ (p.type === "text" && p.text && p.text.trim().length > 0) ||
1425
+ // Reasoning content (thinking blocks)
1426
+ (p.type === "reasoning" && p.text && p.text.trim().length > 0) ||
1427
+ // Tool calls (indicates work was done)
1428
+ p.type === "tool" ||
1429
+ // Tool results (output from executed tools) - important for tool-only tasks
1430
+ (p.type === "tool_result" && p.content &&
1431
+ (typeof p.content === "string" ? p.content.trim().length > 0 : p.content.length > 0))
1432
+ )
1433
+ })
1434
+
1435
+ if (!hasContent) {
1436
+ log("[background-agent] Messages exist but no content found in session:", sessionID)
1437
+ return false
1438
+ }
1439
+
1440
+ this.markSessionOutputObserved(sessionID)
1441
+ return true
1442
+ } catch (error) {
1443
+ log("[background-agent] Error validating session output:", error)
1444
+ // On error, allow completion to proceed (don't block indefinitely)
1445
+ return true
1446
+ }
1447
+ }
1448
+
1449
+ private clearNotificationsForTask(taskId: string): void {
1450
+ for (const [sessionID, tasks] of this.notifications.entries()) {
1451
+ const filtered = tasks.filter((t) => t.id !== taskId)
1452
+ if (filtered.length === 0) {
1453
+ this.notifications.delete(sessionID)
1454
+ } else {
1455
+ this.notifications.set(sessionID, filtered)
1456
+ }
1457
+ }
1458
+ }
1459
+
1460
+ /**
1461
+ * Remove task from pending tracking for its parent session.
1462
+ * Cleans up the parent entry if no pending tasks remain.
1463
+ */
1464
+ private cleanupPendingByParent(task: BackgroundTask): void {
1465
+ if (!task.parentSessionID) return
1466
+ const pending = this.pendingByParent.get(task.parentSessionID)
1467
+ if (pending) {
1468
+ pending.delete(task.id)
1469
+ if (pending.size === 0) {
1470
+ this.pendingByParent.delete(task.parentSessionID)
1471
+ }
1472
+ }
1473
+ }
1474
+
1475
+ private clearTaskHistoryWhenParentTasksGone(parentSessionID: string | undefined): void {
1476
+ if (!parentSessionID) return
1477
+ if (this.getTasksByParentSession(parentSessionID).length > 0) return
1478
+ this.taskHistory.clearSession(parentSessionID)
1479
+ this.completedTaskSummaries.delete(parentSessionID)
1480
+ }
1481
+
1482
+ private scheduleTaskRemoval(taskId: string, rescheduleCount = 0): void {
1483
+ const existingTimer = this.completionTimers.get(taskId)
1484
+ if (existingTimer) {
1485
+ clearTimeout(existingTimer)
1486
+ this.completionTimers.delete(taskId)
1487
+ }
1488
+
1489
+ const timer = setTimeout(() => {
1490
+ this.completionTimers.delete(taskId)
1491
+ const task = this.tasks.get(taskId)
1492
+ if (!task) return
1493
+
1494
+ if (task.parentSessionID) {
1495
+ const siblings = this.getTasksByParentSession(task.parentSessionID)
1496
+ const runningOrPendingSiblings = siblings.filter(
1497
+ sibling => sibling.id !== taskId && (sibling.status === "running" || sibling.status === "pending"),
1498
+ )
1499
+ const completedAtTimestamp = task.completedAt?.getTime()
1500
+ const reachedTaskTtl = completedAtTimestamp !== undefined && (Date.now() - completedAtTimestamp) >= TASK_TTL_MS
1501
+ if (runningOrPendingSiblings.length > 0 && rescheduleCount < MAX_TASK_REMOVAL_RESCHEDULES && !reachedTaskTtl) {
1502
+ this.scheduleTaskRemoval(taskId, rescheduleCount + 1)
1503
+ return
1504
+ }
1505
+ }
1506
+
1507
+ this.clearNotificationsForTask(taskId)
1508
+ this.tasks.delete(taskId)
1509
+ this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
1510
+ if (task.sessionID) {
1511
+ subagentSessions.delete(task.sessionID)
1512
+ SessionCategoryRegistry.remove(task.sessionID)
1513
+ }
1514
+ log("[background-agent] Removed completed task from memory:", taskId)
1515
+ }, TASK_CLEANUP_DELAY_MS)
1516
+
1517
+ this.completionTimers.set(taskId, timer)
1518
+ }
1519
+
1520
+ async cancelTask(
1521
+ taskId: string,
1522
+ options?: { source?: string; reason?: string; abortSession?: boolean; skipNotification?: boolean }
1523
+ ): Promise<boolean> {
1524
+ const task = this.tasks.get(taskId)
1525
+ if (!task || (task.status !== "running" && task.status !== "pending")) {
1526
+ return false
1527
+ }
1528
+
1529
+ const source = options?.source ?? "cancel"
1530
+ const abortSession = options?.abortSession !== false
1531
+ const reason = options?.reason
1532
+
1533
+ if (task.status === "pending") {
1534
+ const key = task.model
1535
+ ? `${task.model.providerID}/${task.model.modelID}`
1536
+ : task.agent
1537
+ const queue = this.queuesByKey.get(key)
1538
+ if (queue) {
1539
+ const index = queue.findIndex(item => item.task.id === taskId)
1540
+ if (index !== -1) {
1541
+ queue.splice(index, 1)
1542
+ if (queue.length === 0) {
1543
+ this.queuesByKey.delete(key)
1544
+ }
1545
+ }
1546
+ }
1547
+ this.rollbackPreStartDescendantReservation(task)
1548
+ log("[background-agent] Cancelled pending task:", { taskId, key })
1549
+ }
1550
+
1551
+ const wasRunning = task.status === "running"
1552
+ task.status = "cancelled"
1553
+ task.completedAt = new Date()
1554
+ if (wasRunning && task.rootSessionID) {
1555
+ this.unregisterRootDescendant(task.rootSessionID)
1556
+ }
1557
+ if (reason) {
1558
+ task.error = reason
1559
+ }
1560
+ this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "cancelled", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
1561
+
1562
+ if (task.concurrencyKey) {
1563
+ this.concurrencyManager.release(task.concurrencyKey)
1564
+ task.concurrencyKey = undefined
1565
+ }
1566
+
1567
+ const existingTimer = this.completionTimers.get(task.id)
1568
+ if (existingTimer) {
1569
+ clearTimeout(existingTimer)
1570
+ this.completionTimers.delete(task.id)
1571
+ }
1572
+
1573
+ const idleTimer = this.idleDeferralTimers.get(task.id)
1574
+ if (idleTimer) {
1575
+ clearTimeout(idleTimer)
1576
+ this.idleDeferralTimers.delete(task.id)
1577
+ }
1578
+
1579
+ if (abortSession && task.sessionID) {
1580
+ // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
1581
+ await this.abortSessionWithLogging(task.sessionID, `task cancellation (${source})`)
1582
+
1583
+ SessionCategoryRegistry.remove(task.sessionID)
1584
+ }
1585
+
1586
+ removeTaskToastTracking(task.id)
1587
+
1588
+ if (options?.skipNotification) {
1589
+ this.cleanupPendingByParent(task)
1590
+ this.scheduleTaskRemoval(task.id)
1591
+ log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id)
1592
+ return true
1593
+ }
1594
+
1595
+ this.markForNotification(task)
1596
+
1597
+ try {
1598
+ await this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task))
1599
+ log(`[background-agent] Task cancelled via ${source}:`, task.id)
1600
+ } catch (err) {
1601
+ log("[background-agent] Error in notifyParentSession for cancelled task:", { taskId: task.id, error: err })
1602
+ }
1603
+
1604
+ return true
1605
+ }
1606
+
1607
+ /**
1608
+ * Cancels a pending task by removing it from queue and marking as cancelled.
1609
+ * Does NOT abort session (no session exists yet) or release concurrency slot (wasn't acquired).
1610
+ */
1611
+ cancelPendingTask(taskId: string): boolean {
1612
+ const task = this.tasks.get(taskId)
1613
+ if (!task || task.status !== "pending") {
1614
+ return false
1615
+ }
1616
+
1617
+ void this.cancelTask(taskId, { source: "cancelPendingTask", abortSession: false })
1618
+ return true
1619
+ }
1620
+
1621
+ private startPolling(): void {
1622
+ if (this.pollingInterval) return
1623
+
1624
+ this.pollingInterval = setInterval(() => {
1625
+ this.pollRunningTasks()
1626
+ }, POLLING_INTERVAL_MS)
1627
+ this.pollingInterval.unref()
1628
+ }
1629
+
1630
+ private stopPolling(): void {
1631
+ if (this.pollingInterval) {
1632
+ clearInterval(this.pollingInterval)
1633
+ this.pollingInterval = undefined
1634
+ }
1635
+ }
1636
+
1637
+ private registerProcessCleanup(): void {
1638
+ registerManagerForCleanup(this)
1639
+ }
1640
+
1641
+ private unregisterProcessCleanup(): void {
1642
+ unregisterManagerForCleanup(this)
1643
+ }
1644
+
1645
+
1646
+ /**
1647
+ * Get all running tasks (for compaction hook)
1648
+ */
1649
+ getRunningTasks(): BackgroundTask[] {
1650
+ return Array.from(this.tasks.values()).filter(t => t.status === "running")
1651
+ }
1652
+
1653
+ /**
1654
+ * Get all non-running tasks still in memory (for compaction hook)
1655
+ */
1656
+ getNonRunningTasks(): BackgroundTask[] {
1657
+ return Array.from(this.tasks.values()).filter(t => t.status !== "running")
1658
+ }
1659
+
1660
+ /**
1661
+ * Safely complete a task with race condition protection.
1662
+ * Returns true if task was successfully completed, false if already completed by another path.
1663
+ */
1664
+ private async tryCompleteTask(task: BackgroundTask, source: string): Promise<boolean> {
1665
+ // Guard: Check if task is still running (could have been completed by another path)
1666
+ if (task.status !== "running") {
1667
+ log("[background-agent] Task already completed, skipping:", { taskId: task.id, status: task.status, source })
1668
+ return false
1669
+ }
1670
+
1671
+ // Atomically mark as completed to prevent race conditions
1672
+ task.status = "completed"
1673
+ task.completedAt = new Date()
1674
+ this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "completed", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
1675
+
1676
+ if (task.rootSessionID) {
1677
+ this.unregisterRootDescendant(task.rootSessionID)
1678
+ }
1679
+
1680
+ removeTaskToastTracking(task.id)
1681
+
1682
+ // Release concurrency BEFORE any async operations to prevent slot leaks
1683
+ if (task.concurrencyKey) {
1684
+ this.concurrencyManager.release(task.concurrencyKey)
1685
+ task.concurrencyKey = undefined
1686
+ }
1687
+
1688
+ this.markForNotification(task)
1689
+
1690
+ const idleTimer = this.idleDeferralTimers.get(task.id)
1691
+ if (idleTimer) {
1692
+ clearTimeout(idleTimer)
1693
+ this.idleDeferralTimers.delete(task.id)
1694
+ }
1695
+
1696
+ if (task.sessionID) {
1697
+ // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT)
1698
+ await this.abortSessionWithLogging(task.sessionID, `task completion (${source})`)
1699
+
1700
+ SessionCategoryRegistry.remove(task.sessionID)
1701
+ }
1702
+
1703
+ try {
1704
+ await this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task))
1705
+ log(`[background-agent] Task completed via ${source}:`, task.id)
1706
+ } catch (err) {
1707
+ log("[background-agent] Error in notifyParentSession:", { taskId: task.id, error: err })
1708
+ // Concurrency already released, notification failed but task is complete
1709
+ }
1710
+
1711
+ return true
1712
+ }
1713
+
1714
+ private async notifyParentSession(task: BackgroundTask): Promise<void> {
1715
+ const duration = formatDuration(task.startedAt ?? new Date(), task.completedAt)
1716
+
1717
+ log("[background-agent] notifyParentSession called for task:", task.id)
1718
+
1719
+ // Show toast notification
1720
+ const toastManager = getTaskToastManager()
1721
+ if (toastManager) {
1722
+ toastManager.showCompletionToast({
1723
+ id: task.id,
1724
+ description: task.description,
1725
+ duration,
1726
+ })
1727
+ }
1728
+
1729
+ if (!this.completedTaskSummaries.has(task.parentSessionID)) {
1730
+ this.completedTaskSummaries.set(task.parentSessionID, [])
1731
+ }
1732
+ this.completedTaskSummaries.get(task.parentSessionID)!.push({
1733
+ id: task.id,
1734
+ description: task.description,
1735
+ status: task.status,
1736
+ error: task.error,
1737
+ })
1738
+
1739
+ // Update pending tracking and check if all tasks complete
1740
+ const pendingSet = this.pendingByParent.get(task.parentSessionID)
1741
+ let allComplete = false
1742
+ let remainingCount = 0
1743
+ if (pendingSet) {
1744
+ pendingSet.delete(task.id)
1745
+ remainingCount = pendingSet.size
1746
+ allComplete = remainingCount === 0
1747
+ if (allComplete) {
1748
+ this.pendingByParent.delete(task.parentSessionID)
1749
+ }
1750
+ } else {
1751
+ remainingCount = Array.from(this.tasks.values())
1752
+ .filter(t => t.parentSessionID === task.parentSessionID && t.id !== task.id && (t.status === "running" || t.status === "pending"))
1753
+ .length
1754
+ allComplete = remainingCount === 0
1755
+ }
1756
+
1757
+ const completedTasks = allComplete
1758
+ ? (this.completedTaskSummaries.get(task.parentSessionID) ?? [{ id: task.id, description: task.description, status: task.status, error: task.error }])
1759
+ : []
1760
+
1761
+ if (allComplete) {
1762
+ this.completedTaskSummaries.delete(task.parentSessionID)
1763
+ }
1764
+
1765
+ const statusText = task.status === "completed"
1766
+ ? "COMPLETED"
1767
+ : task.status === "interrupt"
1768
+ ? "INTERRUPTED"
1769
+ : task.status === "error"
1770
+ ? "ERROR"
1771
+ : "CANCELLED"
1772
+ const notification = buildBackgroundTaskNotificationText({
1773
+ task,
1774
+ duration,
1775
+ statusText,
1776
+ allComplete,
1777
+ remainingCount,
1778
+ completedTasks,
1779
+ })
1780
+
1781
+ let agent: string | undefined = task.parentAgent
1782
+ let model: { providerID: string; modelID: string } | undefined
1783
+ let tools: Record<string, boolean> | undefined = task.parentTools
1784
+ let promptContext: ReturnType<typeof resolvePromptContextFromSessionMessages> = null
1785
+
1786
+ if (this.enableParentSessionNotifications) {
1787
+ try {
1788
+ const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } })
1789
+ const messages = normalizeSDKResponse(messagesResp, [] as Array<{
1790
+ info?: {
1791
+ agent?: string
1792
+ model?: { providerID: string; modelID: string }
1793
+ modelID?: string
1794
+ providerID?: string
1795
+ tools?: Record<string, boolean | "allow" | "deny" | "ask">
1796
+ }
1797
+ }>)
1798
+ promptContext = resolvePromptContextFromSessionMessages(
1799
+ messages,
1800
+ task.parentSessionID,
1801
+ )
1802
+ const normalizedTools = isRecord(promptContext?.tools)
1803
+ ? normalizePromptTools(promptContext.tools)
1804
+ : undefined
1805
+
1806
+ if (promptContext?.agent || promptContext?.model || normalizedTools) {
1807
+ agent = promptContext?.agent ?? task.parentAgent
1808
+ model = promptContext?.model?.providerID && promptContext.model.modelID
1809
+ ? { providerID: promptContext.model.providerID, modelID: promptContext.model.modelID }
1810
+ : undefined
1811
+ tools = normalizedTools ?? tools
1812
+ }
1813
+ } catch (error) {
1814
+ if (isAbortedSessionError(error)) {
1815
+ log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", {
1816
+ taskId: task.id,
1817
+ parentSessionID: task.parentSessionID,
1818
+ })
1819
+ }
1820
+ const messageDir = join(MESSAGE_STORAGE, task.parentSessionID)
1821
+ const currentMessage = messageDir
1822
+ ? findNearestMessageExcludingCompaction(messageDir, task.parentSessionID)
1823
+ : null
1824
+ agent = currentMessage?.agent ?? task.parentAgent
1825
+ model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
1826
+ ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
1827
+ : undefined
1828
+ tools = normalizePromptTools(currentMessage?.tools) ?? tools
1829
+ }
1830
+
1831
+ const resolvedTools = resolveInheritedPromptTools(task.parentSessionID, tools)
1832
+
1833
+ log("[background-agent] notifyParentSession context:", {
1834
+ taskId: task.id,
1835
+ resolvedAgent: agent,
1836
+ resolvedModel: model,
1837
+ })
1838
+
1839
+ const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt"
1840
+ const shouldReply = allComplete || isTaskFailure
1841
+
1842
+ const variant = promptContext?.model?.variant
1843
+
1844
+ try {
1845
+ await this.client.session.promptAsync({
1846
+ path: { id: task.parentSessionID },
1847
+ body: {
1848
+ noReply: !shouldReply,
1849
+ ...(agent !== undefined ? { agent } : {}),
1850
+ ...(model !== undefined ? { model } : {}),
1851
+ ...(variant !== undefined ? { variant } : {}),
1852
+ ...(resolvedTools ? { tools: resolvedTools } : {}),
1853
+ parts: [createInternalAgentTextPart(notification)],
1854
+ },
1855
+ })
1856
+ log("[background-agent] Sent notification to parent session:", {
1857
+ taskId: task.id,
1858
+ allComplete,
1859
+ isTaskFailure,
1860
+ noReply: !shouldReply,
1861
+ })
1862
+ } catch (error) {
1863
+ if (isAbortedSessionError(error)) {
1864
+ log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
1865
+ taskId: task.id,
1866
+ parentSessionID: task.parentSessionID,
1867
+ })
1868
+ this.queuePendingNotification(task.parentSessionID, notification)
1869
+ } else {
1870
+ log("[background-agent] Failed to send notification:", error)
1871
+ }
1872
+ }
1873
+ } else {
1874
+ log("[background-agent] Parent session notifications disabled, skipping prompt injection:", {
1875
+ taskId: task.id,
1876
+ parentSessionID: task.parentSessionID,
1877
+ })
1878
+ }
1879
+
1880
+ if (task.status !== "running" && task.status !== "pending") {
1881
+ this.scheduleTaskRemoval(task.id)
1882
+ }
1883
+ }
1884
+
1885
+ private hasRunningTasks(): boolean {
1886
+ for (const task of this.tasks.values()) {
1887
+ if (task.status === "running") return true
1888
+ }
1889
+ return false
1890
+ }
1891
+
1892
+ private pruneStaleTasksAndNotifications(): void {
1893
+ pruneStaleTasksAndNotifications({
1894
+ tasks: this.tasks,
1895
+ notifications: this.notifications,
1896
+ taskTtlMs: this.config?.taskTtlMs,
1897
+ onTaskPruned: (taskId, task, errorMessage) => {
1898
+ const wasPending = task.status === "pending"
1899
+ log("[background-agent] Pruning stale task:", { taskId, status: task.status, age: Math.round(((wasPending ? task.queuedAt?.getTime() : task.startedAt?.getTime()) ? (Date.now() - (wasPending ? task.queuedAt!.getTime() : task.startedAt!.getTime())) : 0) / 1000) + "s" })
1900
+ task.status = "error"
1901
+ task.error = errorMessage
1902
+ task.completedAt = new Date()
1903
+ if (!wasPending && task.rootSessionID) {
1904
+ this.unregisterRootDescendant(task.rootSessionID)
1905
+ }
1906
+ this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
1907
+ if (task.concurrencyKey) {
1908
+ this.concurrencyManager.release(task.concurrencyKey)
1909
+ task.concurrencyKey = undefined
1910
+ }
1911
+ removeTaskToastTracking(task.id)
1912
+ const existingTimer = this.completionTimers.get(taskId)
1913
+ if (existingTimer) {
1914
+ clearTimeout(existingTimer)
1915
+ this.completionTimers.delete(taskId)
1916
+ }
1917
+ const idleTimer = this.idleDeferralTimers.get(taskId)
1918
+ if (idleTimer) {
1919
+ clearTimeout(idleTimer)
1920
+ this.idleDeferralTimers.delete(taskId)
1921
+ }
1922
+ if (wasPending) {
1923
+ const key = task.model
1924
+ ? `${task.model.providerID}/${task.model.modelID}`
1925
+ : task.agent
1926
+ const queue = this.queuesByKey.get(key)
1927
+ if (queue) {
1928
+ const index = queue.findIndex((item) => item.task.id === taskId)
1929
+ if (index !== -1) {
1930
+ queue.splice(index, 1)
1931
+ if (queue.length === 0) {
1932
+ this.queuesByKey.delete(key)
1933
+ }
1934
+ }
1935
+ }
1936
+ }
1937
+ this.cleanupPendingByParent(task)
1938
+ this.markForNotification(task)
1939
+ this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => {
1940
+ log("[background-agent] Error in notifyParentSession for stale-pruned task:", { taskId: task.id, error: err })
1941
+ })
1942
+ },
1943
+ })
1944
+ }
1945
+
1946
+ private async checkAndInterruptStaleTasks(
1947
+ allStatuses: Record<string, { type: string }> = {},
1948
+ ): Promise<void> {
1949
+ await checkAndInterruptStaleTasks({
1950
+ tasks: this.tasks.values(),
1951
+ client: this.client,
1952
+ directory: this.directory,
1953
+ config: this.config,
1954
+ concurrencyManager: this.concurrencyManager,
1955
+ notifyParentSession: (task) => this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)),
1956
+ sessionStatuses: allStatuses,
1957
+ })
1958
+ }
1959
+
1960
+ private async verifySessionExists(sessionID: string): Promise<boolean> {
1961
+ return verifySessionStillExists(this.client, sessionID, this.directory)
1962
+ }
1963
+
1964
+ private async failCrashedTask(task: BackgroundTask, errorMessage: string): Promise<void> {
1965
+ task.status = "error"
1966
+ task.error = errorMessage
1967
+ task.completedAt = new Date()
1968
+ if (task.rootSessionID) {
1969
+ this.unregisterRootDescendant(task.rootSessionID)
1970
+ }
1971
+ this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
1972
+ if (task.concurrencyKey) {
1973
+ this.concurrencyManager.release(task.concurrencyKey)
1974
+ task.concurrencyKey = undefined
1975
+ }
1976
+
1977
+ const completionTimer = this.completionTimers.get(task.id)
1978
+ if (completionTimer) {
1979
+ clearTimeout(completionTimer)
1980
+ this.completionTimers.delete(task.id)
1981
+ }
1982
+ const idleTimer = this.idleDeferralTimers.get(task.id)
1983
+ if (idleTimer) {
1984
+ clearTimeout(idleTimer)
1985
+ this.idleDeferralTimers.delete(task.id)
1986
+ }
1987
+
1988
+ this.cleanupPendingByParent(task)
1989
+ this.clearNotificationsForTask(task.id)
1990
+ removeTaskToastTracking(task.id)
1991
+ this.scheduleTaskRemoval(task.id)
1992
+ if (task.sessionID) {
1993
+ SessionCategoryRegistry.remove(task.sessionID)
1994
+ }
1995
+
1996
+ this.markForNotification(task)
1997
+ this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task)).catch(err => {
1998
+ log("[background-agent] Error in notifyParentSession for crashed task:", { taskId: task.id, error: err })
1999
+ })
2000
+ }
2001
+
2002
+ private async pollRunningTasks(): Promise<void> {
2003
+ if (this.pollingInFlight) return
2004
+ this.pollingInFlight = true
2005
+ try {
2006
+ this.pruneStaleTasksAndNotifications()
2007
+
2008
+ const statusResult = await this.client.session.status()
2009
+ const allStatuses = normalizeSDKResponse(statusResult, {} as Record<string, { type: string }>)
2010
+
2011
+ await this.checkAndInterruptStaleTasks(allStatuses)
2012
+
2013
+ for (const task of this.tasks.values()) {
2014
+ if (task.status !== "running") continue
2015
+
2016
+ const sessionID = task.sessionID
2017
+ if (!sessionID) continue
2018
+
2019
+ try {
2020
+ const sessionStatus = allStatuses[sessionID]
2021
+ // Handle retry before checking running state
2022
+ if (sessionStatus?.type === "retry") {
2023
+ const retryMessage = typeof (sessionStatus as { message?: string }).message === "string"
2024
+ ? (sessionStatus as { message?: string }).message
2025
+ : undefined
2026
+ const errorInfo = { name: "SessionRetry", message: retryMessage }
2027
+ if (await this.tryFallbackRetry(task, errorInfo, "polling:session.status")) {
2028
+ continue
2029
+ }
2030
+ }
2031
+
2032
+ // Only skip completion when session status is actively running.
2033
+ // Unknown or terminal statuses (like "interrupted") fall through to completion.
2034
+ if (sessionStatus && isActiveSessionStatus(sessionStatus.type)) {
2035
+ log("[background-agent] Session still running, relying on event-based progress:", {
2036
+ taskId: task.id,
2037
+ sessionID,
2038
+ sessionStatus: sessionStatus.type,
2039
+ toolCalls: task.progress?.toolCalls ?? 0,
2040
+ })
2041
+ continue
2042
+ }
2043
+
2044
+ if (sessionStatus && isTerminalSessionStatus(sessionStatus.type)) {
2045
+ await this.tryCompleteTask(task, `polling (terminal session status: ${sessionStatus.type})`)
2046
+ continue
2047
+ }
2048
+
2049
+ if (sessionStatus && sessionStatus.type !== "idle") {
2050
+ log("[background-agent] Unknown session status, treating as potentially idle:", {
2051
+ taskId: task.id,
2052
+ sessionID,
2053
+ sessionStatus: sessionStatus.type,
2054
+ })
2055
+ }
2056
+
2057
+ // Session is idle or no longer in status response (completed/disappeared)
2058
+ const sessionGoneFromStatus = !sessionStatus
2059
+ const sessionGoneThresholdReached = sessionGoneFromStatus
2060
+ && (task.consecutiveMissedPolls ?? 0) >= MIN_SESSION_GONE_POLLS
2061
+ const completionSource = sessionStatus?.type === "idle"
2062
+ ? "polling (idle status)"
2063
+ : "polling (session gone from status)"
2064
+ const hasValidOutput = await this.validateSessionHasOutput(sessionID)
2065
+ if (!hasValidOutput) {
2066
+ if (sessionGoneThresholdReached) {
2067
+ const sessionExists = await this.verifySessionExists(sessionID)
2068
+ if (!sessionExists) {
2069
+ log("[background-agent] Session no longer exists (crashed), marking task as error:", task.id)
2070
+ await this.failCrashedTask(task, "Subagent session no longer exists (process likely crashed). The session disappeared without producing any output.")
2071
+ continue
2072
+ }
2073
+
2074
+ task.consecutiveMissedPolls = 0
2075
+ }
2076
+ log("[background-agent] Polling idle/gone but no valid output yet, waiting:", task.id)
2077
+ continue
2078
+ }
2079
+
2080
+ // Re-check status after async operation
2081
+ if (task.status !== "running") continue
2082
+
2083
+ const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
2084
+ if (hasIncompleteTodos) {
2085
+ log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
2086
+ continue
2087
+ }
2088
+
2089
+ await this.tryCompleteTask(task, completionSource)
2090
+ } catch (error) {
2091
+ log("[background-agent] Poll error for task:", { taskId: task.id, error })
2092
+ }
2093
+ }
2094
+
2095
+ if (!this.hasRunningTasks()) {
2096
+ this.stopPolling()
2097
+ }
2098
+ } finally {
2099
+ this.pollingInFlight = false
2100
+ }
2101
+ }
2102
+
2103
+ /**
2104
+ * Shutdown the manager gracefully.
2105
+ * Cancels all pending concurrency waiters and clears timers.
2106
+ * Should be called when the plugin is unloaded.
2107
+ */
2108
+ async shutdown(): Promise<void> {
2109
+ if (this.shutdownTriggered) return
2110
+ this.shutdownTriggered = true
2111
+ log("[background-agent] Shutting down BackgroundManager")
2112
+ this.stopPolling()
2113
+ const trackedSessionIDs = new Set<string>()
2114
+ const abortRequests: Array<{ sessionID: string; promise: Promise<unknown> }> = []
2115
+
2116
+ // Abort all running sessions to prevent zombie processes (#1240)
2117
+ for (const task of this.tasks.values()) {
2118
+ if (task.sessionID) {
2119
+ trackedSessionIDs.add(task.sessionID)
2120
+ }
2121
+
2122
+ if (task.status === "running" && task.sessionID) {
2123
+ abortRequests.push({
2124
+ sessionID: task.sessionID,
2125
+ promise: abortWithTimeout(this.client, task.sessionID),
2126
+ })
2127
+ }
2128
+ }
2129
+
2130
+ if (abortRequests.length > 0) {
2131
+ const abortResults = await Promise.allSettled(abortRequests.map((request) => request.promise))
2132
+ for (const [index, abortResult] of abortResults.entries()) {
2133
+ if (abortResult.status === "fulfilled") continue
2134
+
2135
+ log("[background-agent] Error aborting session during shutdown:", {
2136
+ error: abortResult.reason,
2137
+ sessionID: abortRequests[index]?.sessionID,
2138
+ })
2139
+ }
2140
+ }
2141
+
2142
+ // Notify shutdown listeners (e.g., tmux cleanup)
2143
+ if (this.onShutdown) {
2144
+ try {
2145
+ await this.onShutdown()
2146
+ } catch (error) {
2147
+ log("[background-agent] Error in onShutdown callback:", error)
2148
+ }
2149
+ }
2150
+
2151
+ // Release concurrency for all running tasks
2152
+ for (const task of this.tasks.values()) {
2153
+ if (task.concurrencyKey) {
2154
+ this.concurrencyManager.release(task.concurrencyKey)
2155
+ task.concurrencyKey = undefined
2156
+ }
2157
+ }
2158
+
2159
+ for (const timer of this.completionTimers.values()) {
2160
+ clearTimeout(timer)
2161
+ }
2162
+ this.completionTimers.clear()
2163
+
2164
+ for (const timer of this.idleDeferralTimers.values()) {
2165
+ clearTimeout(timer)
2166
+ }
2167
+ this.idleDeferralTimers.clear()
2168
+
2169
+ for (const sessionID of trackedSessionIDs) {
2170
+ subagentSessions.delete(sessionID)
2171
+ SessionCategoryRegistry.remove(sessionID)
2172
+ }
2173
+
2174
+ this.concurrencyManager.clear()
2175
+ this.tasks.clear()
2176
+ this.notifications.clear()
2177
+ this.pendingNotifications.clear()
2178
+ this.pendingByParent.clear()
2179
+ this.notificationQueueByParent.clear()
2180
+ this.rootDescendantCounts.clear()
2181
+ this.queuesByKey.clear()
2182
+ this.processingKeys.clear()
2183
+ this.taskHistory.clearAll()
2184
+ this.completedTaskSummaries.clear()
2185
+ this.unregisterProcessCleanup()
2186
+ log("[background-agent] Shutdown complete")
2187
+
2188
+ }
2189
+
2190
+ private enqueueNotificationForParent(
2191
+ parentSessionID: string | undefined,
2192
+ operation: () => Promise<void>
2193
+ ): Promise<void> {
2194
+ if (!parentSessionID) {
2195
+ return operation()
2196
+ }
2197
+
2198
+ const previous = this.notificationQueueByParent.get(parentSessionID) ?? Promise.resolve()
2199
+ const cleanupQueueEntry = (): void => {
2200
+ if (this.notificationQueueByParent.get(parentSessionID) === current) {
2201
+ this.notificationQueueByParent.delete(parentSessionID)
2202
+ }
2203
+ }
2204
+
2205
+ const current = previous
2206
+ .catch((error) => {
2207
+ log("[background-agent] Continuing notification queue after previous failure:", {
2208
+ parentSessionID,
2209
+ error,
2210
+ })
2211
+ })
2212
+ .then(operation)
2213
+
2214
+ this.notificationQueueByParent.set(parentSessionID, current)
2215
+
2216
+ void current.then(cleanupQueueEntry, cleanupQueueEntry)
2217
+
2218
+ return current
2219
+ }
2220
+ }