@hiai-gg/hiai-opencode 0.1.1 → 0.1.2

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 (1220) hide show
  1. package/.env.example +57 -57
  2. package/AGENTS.md +280 -281
  3. package/ARCHITECTURE.md +280 -281
  4. package/LICENSE.md +59 -59
  5. package/README.md +301 -301
  6. package/assets/mcp/mempalace.mjs +153 -153
  7. package/assets/mcp/rag.mjs +236 -236
  8. package/assets/runtime/npm-package-runner.mjs +54 -54
  9. package/config/hiai-opencode.schema.json +82 -82
  10. package/config/opencode.json +4 -4
  11. package/dist/index.js +243 -243
  12. package/hiai-opencode.json +57 -57
  13. package/package.json +86 -91
  14. package/skills/api-and-interface-design/SKILL.md +294 -294
  15. package/skills/brainstorming/SKILL.md +164 -164
  16. package/skills/brainstorming/scripts/frame-template.html +214 -214
  17. package/skills/brainstorming/scripts/helper.js +88 -88
  18. package/skills/brainstorming/scripts/server.cjs +354 -354
  19. package/skills/brainstorming/scripts/start-server.sh +148 -148
  20. package/skills/brainstorming/scripts/stop-server.sh +56 -56
  21. package/skills/brainstorming/spec-document-reviewer-prompt.md +49 -49
  22. package/skills/brainstorming/visual-companion.md +287 -287
  23. package/skills/browser-testing-with-devtools/SKILL.md +302 -302
  24. package/skills/ci-cd-and-automation/SKILL.md +390 -390
  25. package/skills/code-review-and-quality/SKILL.md +347 -347
  26. package/skills/code-simplification/SKILL.md +331 -331
  27. package/skills/context-engineering/SKILL.md +289 -289
  28. package/skills/deprecation-and-migration/SKILL.md +206 -206
  29. package/skills/dispatching-parallel-agents/SKILL.md +182 -182
  30. package/skills/documentation-and-adrs/SKILL.md +278 -278
  31. package/skills/executing-plans/SKILL.md +70 -70
  32. package/skills/finishing-a-development-branch/SKILL.md +200 -200
  33. package/skills/frontend-ui-engineering/SKILL.md +322 -322
  34. package/skills/git-workflow-and-versioning/SKILL.md +300 -300
  35. package/skills/incremental-implementation/SKILL.md +241 -241
  36. package/skills/performance-optimization/SKILL.md +350 -350
  37. package/skills/receiving-code-review/SKILL.md +213 -213
  38. package/skills/requesting-code-review/SKILL.md +105 -105
  39. package/skills/requesting-code-review/code-reviewer.md +146 -146
  40. package/skills/security-and-hardening/SKILL.md +349 -349
  41. package/skills/shipping-and-launch/SKILL.md +309 -309
  42. package/skills/source-driven-development/SKILL.md +194 -194
  43. package/skills/spec-driven-development/SKILL.md +200 -200
  44. package/skills/subagent-driven-development/SKILL.md +277 -277
  45. package/skills/subagent-driven-development/code-quality-reviewer-prompt.md +26 -26
  46. package/skills/subagent-driven-development/implementer-prompt.md +113 -113
  47. package/skills/subagent-driven-development/spec-reviewer-prompt.md +61 -61
  48. package/skills/systematic-debugging/CREATION-LOG.md +119 -119
  49. package/skills/systematic-debugging/SKILL.md +596 -596
  50. package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
  51. package/skills/systematic-debugging/condition-based-waiting.md +115 -115
  52. package/skills/systematic-debugging/defense-in-depth.md +122 -122
  53. package/skills/systematic-debugging/find-polluter.sh +63 -63
  54. package/skills/systematic-debugging/root-cause-tracing.md +169 -169
  55. package/skills/systematic-debugging/test-academic.md +14 -14
  56. package/skills/systematic-debugging/test-pressure-1.md +58 -58
  57. package/skills/systematic-debugging/test-pressure-2.md +68 -68
  58. package/skills/systematic-debugging/test-pressure-3.md +69 -69
  59. package/skills/test-driven-development/SKILL.md +379 -379
  60. package/skills/using-agent-skills/SKILL.md +174 -174
  61. package/skills/using-git-worktrees/SKILL.md +218 -218
  62. package/skills/using-superpowers/SKILL.md +117 -117
  63. package/skills/using-superpowers/references/codex-tools.md +100 -100
  64. package/skills/using-superpowers/references/copilot-tools.md +52 -52
  65. package/skills/using-superpowers/references/gemini-tools.md +33 -33
  66. package/skills/verification-before-completion/SKILL.md +139 -139
  67. package/skills/writing-plans/SKILL.md +152 -152
  68. package/skills/writing-plans/plan-document-reviewer-prompt.md +49 -49
  69. package/skills/writing-skills/SKILL.md +655 -655
  70. package/skills/writing-skills/anthropic-best-practices.md +1150 -1150
  71. package/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +189 -189
  72. package/skills/writing-skills/graphviz-conventions.dot +171 -171
  73. package/skills/writing-skills/persuasion-principles.md +187 -187
  74. package/skills/writing-skills/render-graphs.js +168 -168
  75. package/skills/writing-skills/testing-skills-with-subagents.md +384 -384
  76. package/src/AGENTS.md +41 -41
  77. package/src/agents/AGENTS.md +74 -74
  78. package/src/agents/agent-builder.ts +50 -50
  79. package/src/agents/bob/AGENTS.md +29 -29
  80. package/src/agents/bob/default.ts +128 -128
  81. package/src/agents/bob/gemini.ts +237 -237
  82. package/src/agents/bob/gpt-pro.ts +430 -430
  83. package/src/agents/bob/index.ts +19 -19
  84. package/src/agents/bob.ts +528 -528
  85. package/src/agents/builtin-agents/agent-overrides.ts +75 -75
  86. package/src/agents/builtin-agents/available-skills.ts +35 -35
  87. package/src/agents/builtin-agents/bob-agent.ts +96 -96
  88. package/src/agents/builtin-agents/coder-agent.ts +98 -98
  89. package/src/agents/builtin-agents/environment-context.ts +16 -16
  90. package/src/agents/builtin-agents/general-agents.ts +122 -122
  91. package/src/agents/builtin-agents/guard-agent.ts +66 -66
  92. package/src/agents/builtin-agents/model-resolution.ts +31 -31
  93. package/src/agents/builtin-agents/resolve-file-uri.ts +42 -42
  94. package/src/agents/builtin-agents.ts +194 -194
  95. package/src/agents/coder/AGENTS.md +34 -34
  96. package/src/agents/coder/agent.ts +162 -162
  97. package/src/agents/coder/gpt-codex.ts +404 -404
  98. package/src/agents/coder/gpt-pro.ts +319 -319
  99. package/src/agents/coder/gpt.ts +253 -253
  100. package/src/agents/coder/index.ts +8 -8
  101. package/src/agents/critic/agent.ts +105 -105
  102. package/src/agents/custom-agent-summaries.ts +61 -61
  103. package/src/agents/dynamic-agent-category-skills-guide.ts +138 -138
  104. package/src/agents/dynamic-agent-core-sections.ts +237 -237
  105. package/src/agents/dynamic-agent-policy-sections.ts +182 -182
  106. package/src/agents/dynamic-agent-prompt-builder.ts +31 -31
  107. package/src/agents/dynamic-agent-prompt-types.ts +24 -24
  108. package/src/agents/dynamic-agent-tool-categorization.ts +45 -45
  109. package/src/agents/env-context.ts +16 -16
  110. package/src/agents/gpt-apply-patch-guard.ts +7 -7
  111. package/src/agents/guard/agent.ts +146 -146
  112. package/src/agents/guard/default-prompt-sections.ts +305 -305
  113. package/src/agents/guard/default.ts +22 -22
  114. package/src/agents/guard/gemini-prompt-sections.ts +293 -293
  115. package/src/agents/guard/gemini.ts +22 -22
  116. package/src/agents/guard/gpt-prompt-sections.ts +296 -296
  117. package/src/agents/guard/gpt.ts +22 -22
  118. package/src/agents/guard/index.ts +2 -2
  119. package/src/agents/guard/prompt-section-builder.ts +104 -104
  120. package/src/agents/guard/shared-prompt.ts +172 -172
  121. package/src/agents/index.ts +5 -5
  122. package/src/agents/platform-adapter.ts +236 -236
  123. package/src/agents/platform-manager.ts +57 -57
  124. package/src/agents/prompt-library/identity.ts +14 -14
  125. package/src/agents/prompt-library/index.ts +7 -7
  126. package/src/agents/prompt-library/intent-gate.ts +149 -149
  127. package/src/agents/prompt-library/orchestration.ts +60 -60
  128. package/src/agents/prompt-library/platform.ts +36 -36
  129. package/src/agents/prompt-library/specialized.ts +39 -39
  130. package/src/agents/prompt-library/strategy.ts +80 -80
  131. package/src/agents/prompt-library/todo-discipline.ts +22 -22
  132. package/src/agents/quality-guardian.ts +76 -76
  133. package/src/agents/researcher.ts +73 -73
  134. package/src/agents/strategist/AGENTS.md +37 -37
  135. package/src/agents/strategist/behavioral-summary.ts +79 -79
  136. package/src/agents/strategist/gemini.ts +333 -333
  137. package/src/agents/strategist/gpt.ts +460 -460
  138. package/src/agents/strategist/high-accuracy-mode.ts +78 -78
  139. package/src/agents/strategist/identity-constraints.ts +336 -336
  140. package/src/agents/strategist/index.ts +6 -6
  141. package/src/agents/strategist/interview-mode.ts +335 -335
  142. package/src/agents/strategist/plan-generation.ts +213 -213
  143. package/src/agents/strategist/plan-template.ts +325 -325
  144. package/src/agents/strategist/system-prompt.ts +68 -68
  145. package/src/agents/sub/agent.ts +141 -141
  146. package/src/agents/sub/default.ts +52 -52
  147. package/src/agents/sub/gemini.ts +194 -194
  148. package/src/agents/sub/gpt-codex.ts +156 -156
  149. package/src/agents/sub/gpt-pro.ts +161 -161
  150. package/src/agents/sub/gpt.ts +157 -157
  151. package/src/agents/sub/index.ts +13 -13
  152. package/src/agents/types.ts +144 -144
  153. package/src/agents/ui.ts +58 -58
  154. package/src/config/data/model-capabilities.json +40690 -40690
  155. package/src/config/defaults.ts +146 -146
  156. package/src/config/hiai-opencode.schema.json +12 -12
  157. package/src/config/index.ts +67 -67
  158. package/src/config/loader.test.ts +65 -65
  159. package/src/config/loader.ts +183 -183
  160. package/src/config/models.ts +32 -32
  161. package/src/config/platform-schema.ts +192 -192
  162. package/src/config/schema/agent-definitions.ts +5 -5
  163. package/src/config/schema/agent-names.ts +66 -66
  164. package/src/config/schema/agent-overrides.ts +95 -95
  165. package/src/config/schema/babysitting.ts +7 -7
  166. package/src/config/schema/background-task.ts +29 -29
  167. package/src/config/schema/bob-agent.ts +11 -11
  168. package/src/config/schema/bob.ts +17 -17
  169. package/src/config/schema/browser-automation.ts +24 -24
  170. package/src/config/schema/categories.ts +45 -45
  171. package/src/config/schema/claude-code.ts +13 -13
  172. package/src/config/schema/commands.ts +14 -14
  173. package/src/config/schema/comment-checker.ts +8 -8
  174. package/src/config/schema/dynamic-context-pruning.ts +53 -53
  175. package/src/config/schema/experimental.ts +27 -27
  176. package/src/config/schema/fallback-models.ts +31 -31
  177. package/src/config/schema/fast-apply.ts +14 -14
  178. package/src/config/schema/git-env-prefix.ts +28 -28
  179. package/src/config/schema/git-master.ts +14 -14
  180. package/src/config/schema/hooks.ts +61 -61
  181. package/src/config/schema/index.ts +52 -52
  182. package/src/config/schema/internal/permission.ts +20 -20
  183. package/src/config/schema/model-capabilities.ts +10 -10
  184. package/src/config/schema/notification.ts +8 -8
  185. package/src/config/schema/oh-my-opencode-config.ts +90 -90
  186. package/src/config/schema/openclaw.ts +50 -50
  187. package/src/config/schema/ralph-loop.ts +11 -11
  188. package/src/config/schema/runtime-fallback.ts +18 -18
  189. package/src/config/schema/skills.ts +39 -39
  190. package/src/config/schema/start-work.ts +7 -7
  191. package/src/config/schema/tmux.ts +28 -28
  192. package/src/config/schema/websearch.ts +15 -15
  193. package/src/config/types.ts +174 -174
  194. package/src/create-hooks.ts +93 -93
  195. package/src/create-managers.ts +116 -116
  196. package/src/create-runtime-tmux-config.ts +18 -18
  197. package/src/create-tools.ts +53 -53
  198. package/src/features/background-agent/AGENTS.md +56 -56
  199. package/src/features/background-agent/abort-with-timeout.ts +35 -35
  200. package/src/features/background-agent/background-task-notification-template.ts +74 -74
  201. package/src/features/background-agent/compaction-aware-message-resolver.ts +164 -164
  202. package/src/features/background-agent/concurrency.ts +137 -137
  203. package/src/features/background-agent/constants.ts +58 -58
  204. package/src/features/background-agent/duration-formatter.ts +14 -14
  205. package/src/features/background-agent/error-classifier.ts +83 -83
  206. package/src/features/background-agent/fallback-retry-handler.ts +134 -134
  207. package/src/features/background-agent/index.ts +2 -2
  208. package/src/features/background-agent/loop-detector.ts +102 -102
  209. package/src/features/background-agent/manager.ts +2220 -2220
  210. package/src/features/background-agent/opencode-client.ts +3 -3
  211. package/src/features/background-agent/process-cleanup.ts +98 -98
  212. package/src/features/background-agent/remove-task-toast-tracking.ts +8 -8
  213. package/src/features/background-agent/session-existence.ts +57 -57
  214. package/src/features/background-agent/session-idle-event-handler.ts +93 -93
  215. package/src/features/background-agent/session-status-classifier.ts +20 -20
  216. package/src/features/background-agent/spawner/parent-directory-resolver.ts +24 -24
  217. package/src/features/background-agent/spawner.ts +327 -327
  218. package/src/features/background-agent/state.ts +199 -199
  219. package/src/features/background-agent/subagent-spawn-limits.ts +97 -97
  220. package/src/features/background-agent/task-history.ts +79 -79
  221. package/src/features/background-agent/task-poller.ts +225 -225
  222. package/src/features/background-agent/types.ts +100 -100
  223. package/src/features/boulder-state/constants.ts +13 -13
  224. package/src/features/boulder-state/index.ts +4 -4
  225. package/src/features/boulder-state/storage.ts +336 -336
  226. package/src/features/boulder-state/top-level-task.ts +78 -78
  227. package/src/features/boulder-state/types.ts +61 -61
  228. package/src/features/builtin-commands/commands.ts +143 -143
  229. package/src/features/builtin-commands/index.ts +2 -2
  230. package/src/features/builtin-commands/templates/handoff.ts +177 -177
  231. package/src/features/builtin-commands/templates/init-deep.ts +305 -305
  232. package/src/features/builtin-commands/templates/ralph-loop.ts +66 -66
  233. package/src/features/builtin-commands/templates/refactor.ts +619 -619
  234. package/src/features/builtin-commands/templates/remove-ai-slops.ts +96 -96
  235. package/src/features/builtin-commands/templates/start-work.ts +128 -128
  236. package/src/features/builtin-commands/templates/stop-continuation.ts +13 -13
  237. package/src/features/builtin-commands/types.ts +9 -9
  238. package/src/features/builtin-skills/index.ts +2 -2
  239. package/src/features/builtin-skills/materialize.ts +338 -338
  240. package/src/features/builtin-skills/skills/ai-slop-remover.ts +145 -145
  241. package/src/features/builtin-skills/skills/dev-browser.ts +221 -221
  242. package/src/features/builtin-skills/skills/frontend-ui-ux.ts +79 -79
  243. package/src/features/builtin-skills/skills/git-master-sections/commit-workflow.ts +509 -509
  244. package/src/features/builtin-skills/skills/git-master-sections/history-search-workflow.ts +229 -229
  245. package/src/features/builtin-skills/skills/git-master-sections/overview.ts +64 -64
  246. package/src/features/builtin-skills/skills/git-master-sections/quick-reference.ts +86 -86
  247. package/src/features/builtin-skills/skills/git-master-sections/rebase-workflow.ts +181 -181
  248. package/src/features/builtin-skills/skills/git-master-skill-metadata.ts +4 -4
  249. package/src/features/builtin-skills/skills/git-master.ts +28 -28
  250. package/src/features/builtin-skills/skills/index.ts +7 -7
  251. package/src/features/builtin-skills/skills/playwright-cli.ts +268 -268
  252. package/src/features/builtin-skills/skills/playwright.ts +466 -466
  253. package/src/features/builtin-skills/skills/review-work.ts +536 -536
  254. package/src/features/builtin-skills/skills.ts +39 -39
  255. package/src/features/builtin-skills/types.ts +16 -16
  256. package/src/features/claude-code-agent-loader/agent-definitions-loader.ts +87 -87
  257. package/src/features/claude-code-agent-loader/claude-model-mapper.ts +53 -53
  258. package/src/features/claude-code-agent-loader/index.ts +5 -5
  259. package/src/features/claude-code-agent-loader/json-agent-loader.ts +53 -53
  260. package/src/features/claude-code-agent-loader/loader.ts +86 -86
  261. package/src/features/claude-code-agent-loader/opencode-config-agents-reader.ts +125 -125
  262. package/src/features/claude-code-agent-loader/types.ts +31 -31
  263. package/src/features/claude-code-command-loader/index.ts +2 -2
  264. package/src/features/claude-code-command-loader/loader.ts +169 -169
  265. package/src/features/claude-code-command-loader/types.ts +46 -46
  266. package/src/features/claude-code-mcp-loader/configure-allowed-env-vars.ts +48 -48
  267. package/src/features/claude-code-mcp-loader/env-expander.ts +51 -51
  268. package/src/features/claude-code-mcp-loader/index.ts +12 -12
  269. package/src/features/claude-code-mcp-loader/loader.ts +156 -156
  270. package/src/features/claude-code-mcp-loader/scope-filter.ts +17 -17
  271. package/src/features/claude-code-mcp-loader/transformer.ts +57 -57
  272. package/src/features/claude-code-mcp-loader/types.ts +51 -51
  273. package/src/features/claude-code-plugin-loader/agent-loader.ts +59 -59
  274. package/src/features/claude-code-plugin-loader/command-loader.ts +53 -53
  275. package/src/features/claude-code-plugin-loader/discovery.ts +251 -251
  276. package/src/features/claude-code-plugin-loader/hook-loader.ts +26 -26
  277. package/src/features/claude-code-plugin-loader/index.ts +10 -10
  278. package/src/features/claude-code-plugin-loader/loader.ts +134 -134
  279. package/src/features/claude-code-plugin-loader/mcp-server-loader.ts +59 -59
  280. package/src/features/claude-code-plugin-loader/plugin-path-resolver.ts +23 -23
  281. package/src/features/claude-code-plugin-loader/scope-filter.ts +29 -29
  282. package/src/features/claude-code-plugin-loader/skill-loader.ts +62 -62
  283. package/src/features/claude-code-plugin-loader/types.ts +255 -255
  284. package/src/features/claude-code-session-state/index.ts +1 -1
  285. package/src/features/claude-code-session-state/state.ts +154 -154
  286. package/src/features/claude-tasks/session-storage.ts +52 -52
  287. package/src/features/claude-tasks/storage.ts +169 -169
  288. package/src/features/claude-tasks/types.ts +20 -20
  289. package/src/features/context-injector/collector.ts +91 -91
  290. package/src/features/context-injector/index.ts +14 -14
  291. package/src/features/context-injector/injector.ts +167 -167
  292. package/src/features/context-injector/types.ts +91 -91
  293. package/src/features/hook-message-injector/constants.ts +1 -1
  294. package/src/features/hook-message-injector/index.ts +11 -11
  295. package/src/features/hook-message-injector/injector.ts +437 -437
  296. package/src/features/hook-message-injector/types.ts +49 -49
  297. package/src/features/mcp-oauth/AGENTS.md +54 -54
  298. package/src/features/mcp-oauth/callback-server.ts +106 -106
  299. package/src/features/mcp-oauth/dcr.ts +98 -98
  300. package/src/features/mcp-oauth/discovery.ts +134 -134
  301. package/src/features/mcp-oauth/oauth-authorization-flow.ts +150 -150
  302. package/src/features/mcp-oauth/provider.ts +215 -215
  303. package/src/features/mcp-oauth/refresh-mutex.ts +58 -58
  304. package/src/features/mcp-oauth/resource-indicator.ts +16 -16
  305. package/src/features/mcp-oauth/schema.ts +8 -8
  306. package/src/features/mcp-oauth/step-up.ts +79 -79
  307. package/src/features/mcp-oauth/storage.ts +155 -155
  308. package/src/features/opencode-skill-loader/AGENTS.md +59 -59
  309. package/src/features/opencode-skill-loader/allowed-tools-parser.ts +9 -9
  310. package/src/features/opencode-skill-loader/async-loader.ts +213 -213
  311. package/src/features/opencode-skill-loader/blocking.ts +62 -62
  312. package/src/features/opencode-skill-loader/config-source-discovery.ts +114 -114
  313. package/src/features/opencode-skill-loader/discover-worker.ts +56 -56
  314. package/src/features/opencode-skill-loader/git-master-template-injection.ts +150 -150
  315. package/src/features/opencode-skill-loader/index.ts +17 -17
  316. package/src/features/opencode-skill-loader/loaded-skill-from-path.ts +73 -73
  317. package/src/features/opencode-skill-loader/loaded-skill-template-extractor.ts +16 -16
  318. package/src/features/opencode-skill-loader/loader.ts +172 -172
  319. package/src/features/opencode-skill-loader/merger/builtin-skill-converter.ts +26 -26
  320. package/src/features/opencode-skill-loader/merger/config-skill-entry-loader.ts +117 -117
  321. package/src/features/opencode-skill-loader/merger/scope-priority.ts +10 -10
  322. package/src/features/opencode-skill-loader/merger/skill-definition-merger.ts +31 -31
  323. package/src/features/opencode-skill-loader/merger/skills-config-normalizer.ts +19 -19
  324. package/src/features/opencode-skill-loader/merger.ts +96 -96
  325. package/src/features/opencode-skill-loader/skill-content.ts +11 -11
  326. package/src/features/opencode-skill-loader/skill-deduplication.ts +13 -13
  327. package/src/features/opencode-skill-loader/skill-definition-record.ts +11 -11
  328. package/src/features/opencode-skill-loader/skill-directory-loader.ts +112 -112
  329. package/src/features/opencode-skill-loader/skill-discovery.ts +76 -76
  330. package/src/features/opencode-skill-loader/skill-mcp-config.ts +45 -45
  331. package/src/features/opencode-skill-loader/skill-resolution-options.ts +9 -9
  332. package/src/features/opencode-skill-loader/skill-template-resolver.ts +97 -97
  333. package/src/features/opencode-skill-loader/types.ts +38 -38
  334. package/src/features/run-continuation-state/constants.ts +1 -1
  335. package/src/features/run-continuation-state/index.ts +3 -3
  336. package/src/features/run-continuation-state/storage.ts +80 -80
  337. package/src/features/run-continuation-state/types.ts +15 -15
  338. package/src/features/skill-mcp-manager/AGENTS.md +111 -111
  339. package/src/features/skill-mcp-manager/cleanup.ts +153 -153
  340. package/src/features/skill-mcp-manager/connection-type.ts +26 -26
  341. package/src/features/skill-mcp-manager/connection.ts +146 -146
  342. package/src/features/skill-mcp-manager/env-cleaner.ts +59 -59
  343. package/src/features/skill-mcp-manager/error-redaction.ts +47 -47
  344. package/src/features/skill-mcp-manager/http-client.ts +126 -126
  345. package/src/features/skill-mcp-manager/index.ts +2 -2
  346. package/src/features/skill-mcp-manager/manager.ts +178 -178
  347. package/src/features/skill-mcp-manager/oauth-handler.ts +160 -160
  348. package/src/features/skill-mcp-manager/stdio-client.ts +112 -112
  349. package/src/features/skill-mcp-manager/types.ts +96 -96
  350. package/src/features/task-toast-manager/index.ts +2 -2
  351. package/src/features/task-toast-manager/manager.ts +251 -251
  352. package/src/features/task-toast-manager/types.ts +29 -29
  353. package/src/features/tmux-subagent/action-executor-core.ts +82 -82
  354. package/src/features/tmux-subagent/action-executor.ts +137 -137
  355. package/src/features/tmux-subagent/cleanup.ts +42 -42
  356. package/src/features/tmux-subagent/decision-engine.ts +22 -22
  357. package/src/features/tmux-subagent/event-handlers.ts +6 -6
  358. package/src/features/tmux-subagent/grid-planning.ts +137 -137
  359. package/src/features/tmux-subagent/index.ts +16 -16
  360. package/src/features/tmux-subagent/manager.ts +969 -969
  361. package/src/features/tmux-subagent/oldest-agent-pane.ts +37 -37
  362. package/src/features/tmux-subagent/pane-split-availability.ts +77 -77
  363. package/src/features/tmux-subagent/pane-state-parser.ts +135 -135
  364. package/src/features/tmux-subagent/pane-state-querier.ts +76 -76
  365. package/src/features/tmux-subagent/polling-constants.ts +6 -6
  366. package/src/features/tmux-subagent/polling-manager.ts +167 -167
  367. package/src/features/tmux-subagent/polling.ts +183 -183
  368. package/src/features/tmux-subagent/session-created-event.ts +44 -44
  369. package/src/features/tmux-subagent/session-created-handler.ts +175 -175
  370. package/src/features/tmux-subagent/session-deleted-handler.ts +50 -50
  371. package/src/features/tmux-subagent/session-message-count.ts +3 -3
  372. package/src/features/tmux-subagent/session-ready-waiter.ts +44 -44
  373. package/src/features/tmux-subagent/session-status-parser.ts +17 -17
  374. package/src/features/tmux-subagent/spawn-action-decider.ts +147 -147
  375. package/src/features/tmux-subagent/spawn-target-finder.ts +146 -146
  376. package/src/features/tmux-subagent/tmux-grid-constants.ts +57 -57
  377. package/src/features/tmux-subagent/tracked-session-state.ts +29 -29
  378. package/src/features/tmux-subagent/types.ts +54 -54
  379. package/src/features/tool-metadata-store/index.ts +7 -7
  380. package/src/features/tool-metadata-store/store.ts +84 -84
  381. package/src/hooks/agent-usage-reminder/constants.ts +52 -52
  382. package/src/hooks/agent-usage-reminder/hook.ts +134 -134
  383. package/src/hooks/agent-usage-reminder/index.ts +1 -1
  384. package/src/hooks/agent-usage-reminder/storage.ts +42 -42
  385. package/src/hooks/agent-usage-reminder/types.ts +6 -6
  386. package/src/hooks/anthropic-context-window-limit-recovery/AGENTS.md +49 -49
  387. package/src/hooks/anthropic-context-window-limit-recovery/aggressive-truncation-strategy.ts +87 -87
  388. package/src/hooks/anthropic-context-window-limit-recovery/client.ts +21 -21
  389. package/src/hooks/anthropic-context-window-limit-recovery/deduplication-recovery.ts +77 -77
  390. package/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.ts +199 -199
  391. package/src/hooks/anthropic-context-window-limit-recovery/empty-content-recovery.ts +149 -149
  392. package/src/hooks/anthropic-context-window-limit-recovery/executor.ts +83 -83
  393. package/src/hooks/anthropic-context-window-limit-recovery/index.ts +8 -8
  394. package/src/hooks/anthropic-context-window-limit-recovery/message-builder.ts +190 -190
  395. package/src/hooks/anthropic-context-window-limit-recovery/message-storage-directory.ts +40 -40
  396. package/src/hooks/anthropic-context-window-limit-recovery/parser.ts +209 -209
  397. package/src/hooks/anthropic-context-window-limit-recovery/pruning-deduplication.ts +189 -189
  398. package/src/hooks/anthropic-context-window-limit-recovery/pruning-tool-output-truncation.ts +142 -142
  399. package/src/hooks/anthropic-context-window-limit-recovery/pruning-types.ts +44 -44
  400. package/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.test-support.ts +119 -119
  401. package/src/hooks/anthropic-context-window-limit-recovery/recovery-hook.ts +193 -193
  402. package/src/hooks/anthropic-context-window-limit-recovery/recovery-strategy.ts +2 -2
  403. package/src/hooks/anthropic-context-window-limit-recovery/session-timeout-map.ts +20 -20
  404. package/src/hooks/anthropic-context-window-limit-recovery/state.ts +78 -78
  405. package/src/hooks/anthropic-context-window-limit-recovery/storage-paths.ts +6 -6
  406. package/src/hooks/anthropic-context-window-limit-recovery/storage.ts +18 -18
  407. package/src/hooks/anthropic-context-window-limit-recovery/summarize-retry-strategy.ts +218 -218
  408. package/src/hooks/anthropic-context-window-limit-recovery/target-token-truncation.ts +196 -196
  409. package/src/hooks/anthropic-context-window-limit-recovery/tool-part-types.ts +38 -38
  410. package/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage-sdk.ts +123 -123
  411. package/src/hooks/anthropic-context-window-limit-recovery/tool-result-storage.ts +119 -119
  412. package/src/hooks/anthropic-context-window-limit-recovery/types.ts +44 -44
  413. package/src/hooks/anthropic-effort/hook.ts +93 -93
  414. package/src/hooks/anthropic-effort/index.ts +1 -1
  415. package/src/hooks/auto-slash-command/constants.ts +12 -12
  416. package/src/hooks/auto-slash-command/detector.ts +88 -88
  417. package/src/hooks/auto-slash-command/executor.ts +165 -165
  418. package/src/hooks/auto-slash-command/hook.ts +238 -238
  419. package/src/hooks/auto-slash-command/index.ts +7 -7
  420. package/src/hooks/auto-slash-command/processed-command-store.ts +74 -74
  421. package/src/hooks/auto-slash-command/types.ts +42 -42
  422. package/src/hooks/background-notification/hook.ts +54 -54
  423. package/src/hooks/background-notification/index.ts +2 -2
  424. package/src/hooks/background-notification/types.ts +5 -5
  425. package/src/hooks/bash-file-read-guard.ts +44 -44
  426. package/src/hooks/category-skill-reminder/formatter.ts +37 -37
  427. package/src/hooks/category-skill-reminder/hook.ts +142 -142
  428. package/src/hooks/category-skill-reminder/index.ts +1 -1
  429. package/src/hooks/claude-code-hooks/AGENTS.md +41 -41
  430. package/src/hooks/claude-code-hooks/claude-code-hooks-hook.ts +28 -28
  431. package/src/hooks/claude-code-hooks/config-loader.ts +151 -151
  432. package/src/hooks/claude-code-hooks/config.ts +147 -147
  433. package/src/hooks/claude-code-hooks/dispatch-hook.ts +27 -27
  434. package/src/hooks/claude-code-hooks/execute-http-hook.ts +116 -116
  435. package/src/hooks/claude-code-hooks/handlers/chat-message-handler.ts +140 -140
  436. package/src/hooks/claude-code-hooks/handlers/pre-compact-handler.ts +41 -41
  437. package/src/hooks/claude-code-hooks/handlers/session-event-handler.ts +137 -137
  438. package/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts +160 -160
  439. package/src/hooks/claude-code-hooks/handlers/tool-execute-before-handler.ts +93 -93
  440. package/src/hooks/claude-code-hooks/index.ts +1 -1
  441. package/src/hooks/claude-code-hooks/plugin-config.ts +12 -12
  442. package/src/hooks/claude-code-hooks/post-tool-use.ts +195 -195
  443. package/src/hooks/claude-code-hooks/pre-compact.ts +105 -105
  444. package/src/hooks/claude-code-hooks/pre-tool-use.ts +168 -168
  445. package/src/hooks/claude-code-hooks/session-hook-state.ts +17 -17
  446. package/src/hooks/claude-code-hooks/stop.ts +118 -118
  447. package/src/hooks/claude-code-hooks/todo.ts +76 -76
  448. package/src/hooks/claude-code-hooks/tool-input-cache.ts +82 -82
  449. package/src/hooks/claude-code-hooks/transcript.ts +248 -248
  450. package/src/hooks/claude-code-hooks/types.ts +214 -214
  451. package/src/hooks/claude-code-hooks/user-prompt-submit.ts +121 -121
  452. package/src/hooks/comment-checker/cli-runner.ts +127 -127
  453. package/src/hooks/comment-checker/cli.ts +269 -269
  454. package/src/hooks/comment-checker/downloader.ts +170 -170
  455. package/src/hooks/comment-checker/hook.ts +192 -192
  456. package/src/hooks/comment-checker/index.ts +1 -1
  457. package/src/hooks/comment-checker/pending-calls.ts +45 -45
  458. package/src/hooks/comment-checker/types.ts +33 -33
  459. package/src/hooks/compaction-context-injector/compaction-context-prompt.ts +56 -56
  460. package/src/hooks/compaction-context-injector/constants.ts +5 -5
  461. package/src/hooks/compaction-context-injector/hook.ts +164 -164
  462. package/src/hooks/compaction-context-injector/index.ts +1 -1
  463. package/src/hooks/compaction-context-injector/recovery-prompt-config.ts +77 -77
  464. package/src/hooks/compaction-context-injector/recovery.ts +163 -163
  465. package/src/hooks/compaction-context-injector/session-id.ts +8 -8
  466. package/src/hooks/compaction-context-injector/session-prompt-config-resolver.ts +120 -120
  467. package/src/hooks/compaction-context-injector/tail-monitor.ts +52 -52
  468. package/src/hooks/compaction-context-injector/types.ts +25 -25
  469. package/src/hooks/compaction-context-injector/validated-model.ts +47 -47
  470. package/src/hooks/compaction-todo-preserver/hook.ts +127 -127
  471. package/src/hooks/compaction-todo-preserver/index.ts +2 -2
  472. package/src/hooks/context-window-monitor.ts +113 -113
  473. package/src/hooks/delegate-task-retry/guidance.ts +45 -45
  474. package/src/hooks/delegate-task-retry/hook.ts +22 -22
  475. package/src/hooks/delegate-task-retry/index.ts +4 -4
  476. package/src/hooks/delegate-task-retry/patterns.ts +77 -77
  477. package/src/hooks/directory-agents-injector/constants.ts +7 -7
  478. package/src/hooks/directory-agents-injector/finder.ts +38 -38
  479. package/src/hooks/directory-agents-injector/hook.ts +80 -80
  480. package/src/hooks/directory-agents-injector/index.ts +1 -1
  481. package/src/hooks/directory-agents-injector/injector.ts +59 -59
  482. package/src/hooks/directory-agents-injector/storage.ts +8 -8
  483. package/src/hooks/directory-readme-injector/constants.ts +7 -7
  484. package/src/hooks/directory-readme-injector/finder.ts +33 -33
  485. package/src/hooks/directory-readme-injector/hook.ts +80 -80
  486. package/src/hooks/directory-readme-injector/index.ts +1 -1
  487. package/src/hooks/directory-readme-injector/injector.ts +59 -59
  488. package/src/hooks/directory-readme-injector/storage.ts +8 -8
  489. package/src/hooks/edit-error-recovery/hook.ts +58 -58
  490. package/src/hooks/edit-error-recovery/index.ts +5 -5
  491. package/src/hooks/empty-task-response-detector.ts +27 -27
  492. package/src/hooks/fast-apply/hook.ts +11 -11
  493. package/src/hooks/fast-apply/index.ts +1 -1
  494. package/src/hooks/fast-apply/ollama-client.ts +53 -53
  495. package/src/hooks/fast-apply/tool-execute-before-handler.ts +86 -86
  496. package/src/hooks/guard/AGENTS.md +64 -64
  497. package/src/hooks/guard/background-launch-session-tracking.ts +97 -97
  498. package/src/hooks/guard/bob-path.ts +8 -8
  499. package/src/hooks/guard/boulder-continuation-injector.ts +109 -109
  500. package/src/hooks/guard/boulder-session-lineage.ts +44 -44
  501. package/src/hooks/guard/event-handler.ts +104 -104
  502. package/src/hooks/guard/final-wave-approval-gate.ts +47 -47
  503. package/src/hooks/guard/final-wave-plan-state.ts +60 -60
  504. package/src/hooks/guard/guard-hook.ts +27 -27
  505. package/src/hooks/guard/hook-name.ts +1 -1
  506. package/src/hooks/guard/idle-event.ts +341 -341
  507. package/src/hooks/guard/index.ts +3 -3
  508. package/src/hooks/guard/is-abort-error.ts +20 -20
  509. package/src/hooks/guard/recent-model-resolver.ts +89 -89
  510. package/src/hooks/guard/resolve-active-boulder-session.ts +29 -29
  511. package/src/hooks/guard/session-last-agent.ts +153 -153
  512. package/src/hooks/guard/subagent-session-id.ts +54 -54
  513. package/src/hooks/guard/system-reminder-templates.ts +249 -249
  514. package/src/hooks/guard/task-context.ts +45 -45
  515. package/src/hooks/guard/tool-execute-after.ts +209 -209
  516. package/src/hooks/guard/tool-execute-before.ts +102 -102
  517. package/src/hooks/guard/tsconfig.json +9 -9
  518. package/src/hooks/guard/types.ts +45 -45
  519. package/src/hooks/guard/verification-reminders.ts +197 -197
  520. package/src/hooks/guard/write-edit-tool-policy.ts +5 -5
  521. package/src/hooks/hashline-edit-diff-enhancer/hook.ts +106 -106
  522. package/src/hooks/hashline-read-enhancer/hook.ts +193 -193
  523. package/src/hooks/hashline-read-enhancer/index.ts +1 -1
  524. package/src/hooks/index.ts +58 -58
  525. package/src/hooks/interactive-bash-session/constants.ts +13 -13
  526. package/src/hooks/interactive-bash-session/hook.ts +125 -125
  527. package/src/hooks/interactive-bash-session/index.ts +3 -3
  528. package/src/hooks/interactive-bash-session/interactive-bash-session-tracker.ts +119 -119
  529. package/src/hooks/interactive-bash-session/parser.ts +118 -118
  530. package/src/hooks/interactive-bash-session/state-manager.ts +35 -35
  531. package/src/hooks/interactive-bash-session/storage.ts +59 -59
  532. package/src/hooks/interactive-bash-session/tmux-command-parser.ts +125 -125
  533. package/src/hooks/interactive-bash-session/types.ts +11 -11
  534. package/src/hooks/json-error-recovery/hook.ts +58 -58
  535. package/src/hooks/json-error-recovery/index.ts +6 -6
  536. package/src/hooks/keyword-detector/AGENTS.md +57 -57
  537. package/src/hooks/keyword-detector/analyze/default.ts +28 -28
  538. package/src/hooks/keyword-detector/analyze/index.ts +1 -1
  539. package/src/hooks/keyword-detector/constants.ts +45 -45
  540. package/src/hooks/keyword-detector/detector.ts +53 -53
  541. package/src/hooks/keyword-detector/hook.ts +143 -143
  542. package/src/hooks/keyword-detector/index.ts +5 -5
  543. package/src/hooks/keyword-detector/search/default.ts +20 -20
  544. package/src/hooks/keyword-detector/search/index.ts +1 -1
  545. package/src/hooks/keyword-detector/types.ts +4 -4
  546. package/src/hooks/keyword-detector/ultrawork/default.ts +302 -302
  547. package/src/hooks/keyword-detector/ultrawork/gemini.ts +290 -290
  548. package/src/hooks/keyword-detector/ultrawork/gpt.ts +173 -173
  549. package/src/hooks/keyword-detector/ultrawork/index.ts +56 -56
  550. package/src/hooks/keyword-detector/ultrawork/planner.ts +140 -140
  551. package/src/hooks/keyword-detector/ultrawork/source-detector.ts +65 -65
  552. package/src/hooks/legacy-plugin-toast/auto-migrate-runner.ts +2 -2
  553. package/src/hooks/legacy-plugin-toast/auto-migrate.ts +64 -64
  554. package/src/hooks/legacy-plugin-toast/hook.ts +68 -68
  555. package/src/hooks/legacy-plugin-toast/index.ts +1 -1
  556. package/src/hooks/legacy-plugin-toast/plugin-entry-migrator.ts +1 -1
  557. package/src/hooks/model-fallback/chat-message-fallback-handler.ts +74 -74
  558. package/src/hooks/model-fallback/hook.ts +201 -201
  559. package/src/hooks/model-fallback/next-fallback.ts +84 -84
  560. package/src/hooks/no-bob-gpt/hook.ts +56 -56
  561. package/src/hooks/no-bob-gpt/index.ts +1 -1
  562. package/src/hooks/no-coder-non-gpt/hook.ts +67 -67
  563. package/src/hooks/no-coder-non-gpt/index.ts +1 -1
  564. package/src/hooks/non-interactive-env/constants.ts +70 -70
  565. package/src/hooks/non-interactive-env/detector.ts +19 -19
  566. package/src/hooks/non-interactive-env/index.ts +5 -5
  567. package/src/hooks/non-interactive-env/non-interactive-env-hook.ts +73 -73
  568. package/src/hooks/non-interactive-env/types.ts +3 -3
  569. package/src/hooks/preemptive-compaction-degradation-monitor.ts +212 -212
  570. package/src/hooks/preemptive-compaction-no-text-tail.ts +70 -70
  571. package/src/hooks/preemptive-compaction.ts +218 -218
  572. package/src/hooks/question-label-truncator/hook.ts +62 -62
  573. package/src/hooks/question-label-truncator/index.ts +1 -1
  574. package/src/hooks/ralph-loop/AGENTS.md +62 -62
  575. package/src/hooks/ralph-loop/command-arguments.ts +30 -30
  576. package/src/hooks/ralph-loop/completion-handler.ts +65 -65
  577. package/src/hooks/ralph-loop/completion-promise-detector-test-input.ts +23 -23
  578. package/src/hooks/ralph-loop/completion-promise-detector.ts +165 -165
  579. package/src/hooks/ralph-loop/constants.ts +7 -7
  580. package/src/hooks/ralph-loop/continuation-prompt-builder.ts +77 -77
  581. package/src/hooks/ralph-loop/continuation-prompt-injector.ts +91 -91
  582. package/src/hooks/ralph-loop/index.ts +6 -6
  583. package/src/hooks/ralph-loop/iteration-continuation.ts +64 -64
  584. package/src/hooks/ralph-loop/logician-verification-detector.ts +88 -88
  585. package/src/hooks/ralph-loop/loop-session-recovery.ts +33 -33
  586. package/src/hooks/ralph-loop/loop-state-controller.ts +178 -178
  587. package/src/hooks/ralph-loop/message-storage-directory.ts +1 -1
  588. package/src/hooks/ralph-loop/pending-verification-handler.ts +152 -152
  589. package/src/hooks/ralph-loop/ralph-loop-event-handler.ts +231 -231
  590. package/src/hooks/ralph-loop/ralph-loop-hook.ts +90 -90
  591. package/src/hooks/ralph-loop/session-event-handler.ts +56 -56
  592. package/src/hooks/ralph-loop/session-reset-strategy.ts +69 -69
  593. package/src/hooks/ralph-loop/storage.ts +164 -164
  594. package/src/hooks/ralph-loop/types.ts +25 -25
  595. package/src/hooks/ralph-loop/verification-failure-handler.ts +103 -103
  596. package/src/hooks/ralph-loop/with-timeout.ts +20 -20
  597. package/src/hooks/read-image-resizer/hook.ts +209 -209
  598. package/src/hooks/read-image-resizer/image-dimensions.ts +191 -191
  599. package/src/hooks/read-image-resizer/image-resizer.ts +191 -191
  600. package/src/hooks/read-image-resizer/index.ts +1 -1
  601. package/src/hooks/read-image-resizer/png-fallback-resizer.ts +359 -359
  602. package/src/hooks/read-image-resizer/types.ts +16 -16
  603. package/src/hooks/rules-injector/AGENTS.md +53 -53
  604. package/src/hooks/rules-injector/cache.ts +27 -27
  605. package/src/hooks/rules-injector/constants.ts +31 -31
  606. package/src/hooks/rules-injector/finder.ts +3 -3
  607. package/src/hooks/rules-injector/hook.ts +94 -94
  608. package/src/hooks/rules-injector/index.ts +2 -2
  609. package/src/hooks/rules-injector/injector.ts +189 -189
  610. package/src/hooks/rules-injector/matcher.ts +63 -63
  611. package/src/hooks/rules-injector/output-path.ts +22 -22
  612. package/src/hooks/rules-injector/parser.ts +211 -211
  613. package/src/hooks/rules-injector/project-root-finder.ts +36 -36
  614. package/src/hooks/rules-injector/rule-distance.ts +53 -53
  615. package/src/hooks/rules-injector/rule-file-finder.ts +139 -139
  616. package/src/hooks/rules-injector/rule-file-scanner.ts +55 -55
  617. package/src/hooks/rules-injector/storage.ts +59 -59
  618. package/src/hooks/rules-injector/types.ts +57 -57
  619. package/src/hooks/runtime-fallback/AGENTS.md +102 -102
  620. package/src/hooks/runtime-fallback/agent-resolver.ts +50 -50
  621. package/src/hooks/runtime-fallback/auto-retry-signal.ts +32 -32
  622. package/src/hooks/runtime-fallback/auto-retry.ts +228 -228
  623. package/src/hooks/runtime-fallback/chat-message-handler.ts +62 -62
  624. package/src/hooks/runtime-fallback/constants.ts +47 -47
  625. package/src/hooks/runtime-fallback/error-classifier.ts +183 -183
  626. package/src/hooks/runtime-fallback/event-handler.ts +213 -213
  627. package/src/hooks/runtime-fallback/fallback-bootstrap-model.ts +63 -63
  628. package/src/hooks/runtime-fallback/fallback-models.ts +86 -86
  629. package/src/hooks/runtime-fallback/fallback-retry-dispatcher.ts +55 -55
  630. package/src/hooks/runtime-fallback/fallback-state.ts +74 -74
  631. package/src/hooks/runtime-fallback/hook.ts +87 -87
  632. package/src/hooks/runtime-fallback/index.ts +2 -2
  633. package/src/hooks/runtime-fallback/last-user-retry-parts.ts +20 -20
  634. package/src/hooks/runtime-fallback/message-update-handler.ts +168 -168
  635. package/src/hooks/runtime-fallback/retry-model-payload.ts +30 -30
  636. package/src/hooks/runtime-fallback/session-messages.ts +38 -38
  637. package/src/hooks/runtime-fallback/session-status-handler.ts +126 -126
  638. package/src/hooks/runtime-fallback/types.ts +77 -77
  639. package/src/hooks/runtime-fallback/visible-assistant-response.ts +80 -80
  640. package/src/hooks/session-notification-content.ts +145 -145
  641. package/src/hooks/session-notification-formatting.ts +25 -25
  642. package/src/hooks/session-notification-scheduler.ts +188 -188
  643. package/src/hooks/session-notification-sender.ts +117 -117
  644. package/src/hooks/session-notification-utils.ts +80 -80
  645. package/src/hooks/session-notification.ts +219 -219
  646. package/src/hooks/session-recovery/AGENTS.md +59 -59
  647. package/src/hooks/session-recovery/constants.ts +5 -5
  648. package/src/hooks/session-recovery/detect-error-type.ts +102 -102
  649. package/src/hooks/session-recovery/hook.ts +166 -166
  650. package/src/hooks/session-recovery/index.ts +7 -7
  651. package/src/hooks/session-recovery/recover-empty-content-message-sdk.ts +201 -201
  652. package/src/hooks/session-recovery/recover-thinking-block-order.ts +137 -137
  653. package/src/hooks/session-recovery/recover-thinking-disabled-violation.ts +75 -75
  654. package/src/hooks/session-recovery/recover-tool-result-missing.ts +108 -108
  655. package/src/hooks/session-recovery/recover-unavailable-tool.ts +108 -108
  656. package/src/hooks/session-recovery/resume.ts +49 -49
  657. package/src/hooks/session-recovery/storage/empty-messages.ts +47 -47
  658. package/src/hooks/session-recovery/storage/empty-text.ts +118 -118
  659. package/src/hooks/session-recovery/storage/message-dir.ts +1 -1
  660. package/src/hooks/session-recovery/storage/messages-reader.ts +83 -83
  661. package/src/hooks/session-recovery/storage/orphan-thinking-search.ts +43 -43
  662. package/src/hooks/session-recovery/storage/part-content.ts +28 -28
  663. package/src/hooks/session-recovery/storage/part-id.ts +5 -5
  664. package/src/hooks/session-recovery/storage/parts-reader.ts +56 -56
  665. package/src/hooks/session-recovery/storage/text-part-injector.ts +63 -63
  666. package/src/hooks/session-recovery/storage/thinking-block-search.ts +42 -42
  667. package/src/hooks/session-recovery/storage/thinking-prepend.ts +223 -223
  668. package/src/hooks/session-recovery/storage/thinking-strip.ts +67 -67
  669. package/src/hooks/session-recovery/storage.ts +34 -34
  670. package/src/hooks/session-recovery/types.ts +101 -101
  671. package/src/hooks/session-todo-status.ts +20 -20
  672. package/src/hooks/shared/compaction-model-resolver.ts +34 -34
  673. package/src/hooks/shared/shared/compaction-model-resolver.ts +34 -34
  674. package/src/hooks/start-work/context-info-builder.ts +319 -319
  675. package/src/hooks/start-work/index.ts +4 -4
  676. package/src/hooks/start-work/parse-user-request.ts +32 -32
  677. package/src/hooks/start-work/start-work-hook.ts +135 -135
  678. package/src/hooks/start-work/worktree-block.ts +11 -11
  679. package/src/hooks/start-work/worktree-detector.ts +77 -77
  680. package/src/hooks/stop-continuation-guard/hook.ts +122 -122
  681. package/src/hooks/stop-continuation-guard/index.ts +2 -2
  682. package/src/hooks/strategist-md-only/agent-matcher.ts +5 -5
  683. package/src/hooks/strategist-md-only/agent-resolution.ts +70 -70
  684. package/src/hooks/strategist-md-only/constants.ts +78 -78
  685. package/src/hooks/strategist-md-only/hook.ts +82 -82
  686. package/src/hooks/strategist-md-only/index.ts +2 -2
  687. package/src/hooks/strategist-md-only/path-policy.ts +41 -41
  688. package/src/hooks/sub-notepad/constants.ts +29 -29
  689. package/src/hooks/sub-notepad/hook.ts +44 -44
  690. package/src/hooks/sub-notepad/index.ts +3 -3
  691. package/src/hooks/task-reminder/hook.ts +59 -59
  692. package/src/hooks/task-reminder/index.ts +1 -1
  693. package/src/hooks/task-resume-info/hook.ts +39 -39
  694. package/src/hooks/task-resume-info/index.ts +1 -1
  695. package/src/hooks/tasks-todowrite-disabler/constants.ts +30 -30
  696. package/src/hooks/tasks-todowrite-disabler/hook.ts +34 -34
  697. package/src/hooks/tasks-todowrite-disabler/index.ts +2 -2
  698. package/src/hooks/think-mode/detector.ts +59 -59
  699. package/src/hooks/think-mode/hook.ts +76 -76
  700. package/src/hooks/think-mode/index.ts +5 -5
  701. package/src/hooks/think-mode/switcher.ts +100 -100
  702. package/src/hooks/think-mode/types.ts +16 -16
  703. package/src/hooks/thinking-block-validator/hook.ts +181 -181
  704. package/src/hooks/thinking-block-validator/index.ts +1 -1
  705. package/src/hooks/todo-continuation-enforcer/AGENTS.md +65 -65
  706. package/src/hooks/todo-continuation-enforcer/abort-detection.ts +17 -17
  707. package/src/hooks/todo-continuation-enforcer/compaction-guard.ts +39 -39
  708. package/src/hooks/todo-continuation-enforcer/constants.ts +25 -25
  709. package/src/hooks/todo-continuation-enforcer/continuation-injection.ts +222 -222
  710. package/src/hooks/todo-continuation-enforcer/countdown.ts +86 -86
  711. package/src/hooks/todo-continuation-enforcer/handler.ts +99 -99
  712. package/src/hooks/todo-continuation-enforcer/idle-event.ts +225 -225
  713. package/src/hooks/todo-continuation-enforcer/index.ts +59 -59
  714. package/src/hooks/todo-continuation-enforcer/message-directory.ts +1 -1
  715. package/src/hooks/todo-continuation-enforcer/non-idle-events.ts +107 -107
  716. package/src/hooks/todo-continuation-enforcer/pending-question-detection.ts +40 -40
  717. package/src/hooks/todo-continuation-enforcer/resolve-message-info.ts +48 -48
  718. package/src/hooks/todo-continuation-enforcer/session-state.ts +283 -283
  719. package/src/hooks/todo-continuation-enforcer/stagnation-detection.ts +36 -36
  720. package/src/hooks/todo-continuation-enforcer/todo.ts +11 -11
  721. package/src/hooks/todo-continuation-enforcer/token-limit-detection.ts +38 -38
  722. package/src/hooks/todo-continuation-enforcer/types.ts +74 -74
  723. package/src/hooks/todo-description-override/description.ts +28 -28
  724. package/src/hooks/todo-description-override/hook.ts +14 -14
  725. package/src/hooks/todo-description-override/index.ts +1 -1
  726. package/src/hooks/tool-output-truncator.ts +66 -66
  727. package/src/hooks/tool-pair-validator/hook.ts +184 -184
  728. package/src/hooks/tool-pair-validator/index.ts +1 -1
  729. package/src/hooks/unstable-agent-babysitter/index.ts +9 -9
  730. package/src/hooks/unstable-agent-babysitter/task-message-analyzer.ts +110 -110
  731. package/src/hooks/unstable-agent-babysitter/unstable-agent-babysitter-hook.ts +238 -238
  732. package/src/hooks/webfetch-redirect-guard/constants.ts +11 -11
  733. package/src/hooks/webfetch-redirect-guard/hook.ts +123 -123
  734. package/src/hooks/webfetch-redirect-guard/index.ts +1 -1
  735. package/src/hooks/webfetch-redirect-guard/redirect-resolution.ts +89 -89
  736. package/src/hooks/write-existing-file-guard/hook.ts +108 -108
  737. package/src/hooks/write-existing-file-guard/index.ts +1 -1
  738. package/src/hooks/write-existing-file-guard/session-read-permissions.ts +36 -36
  739. package/src/hooks/write-existing-file-guard/tool-execute-before-handler.ts +176 -176
  740. package/src/index.ts +284 -284
  741. package/src/internals/plugins/pty/LICENSE +21 -21
  742. package/src/internals/plugins/pty/constants.ts +7 -7
  743. package/src/internals/plugins/pty/plugin.ts +28 -28
  744. package/src/internals/plugins/pty/pty/buffer.ts +75 -75
  745. package/src/internals/plugins/pty/pty/formatters.ts +22 -22
  746. package/src/internals/plugins/pty/pty/manager.ts +175 -175
  747. package/src/internals/plugins/pty/pty/notification-manager.ts +75 -75
  748. package/src/internals/plugins/pty/pty/output-manager.ts +29 -29
  749. package/src/internals/plugins/pty/pty/permissions.ts +115 -115
  750. package/src/internals/plugins/pty/pty/session-lifecycle.ts +161 -161
  751. package/src/internals/plugins/pty/pty/tools/kill.ts +41 -41
  752. package/src/internals/plugins/pty/pty/tools/kill.txt +25 -25
  753. package/src/internals/plugins/pty/pty/tools/list.ts +25 -25
  754. package/src/internals/plugins/pty/pty/tools/list.txt +22 -22
  755. package/src/internals/plugins/pty/pty/tools/read.ts +234 -234
  756. package/src/internals/plugins/pty/pty/tools/read.txt +39 -39
  757. package/src/internals/plugins/pty/pty/tools/spawn.ts +71 -71
  758. package/src/internals/plugins/pty/pty/tools/spawn.txt +47 -47
  759. package/src/internals/plugins/pty/pty/tools/write.ts +96 -96
  760. package/src/internals/plugins/pty/pty/tools/write.txt +28 -28
  761. package/src/internals/plugins/pty/pty/types.ts +67 -67
  762. package/src/internals/plugins/pty/pty/utils.ts +21 -21
  763. package/src/internals/plugins/pty/pty/wildcard.ts +62 -62
  764. package/src/internals/plugins/pty/shared/constants.ts +7 -7
  765. package/src/internals/plugins/pty/types.ts +7 -7
  766. package/src/internals/plugins/subtask2/LICENSE +128 -128
  767. package/src/internals/plugins/subtask2/commands/index.ts +7 -7
  768. package/src/internals/plugins/subtask2/commands/loader.ts +39 -39
  769. package/src/internals/plugins/subtask2/commands/manifest.ts +64 -64
  770. package/src/internals/plugins/subtask2/commands/resolver.ts +28 -28
  771. package/src/internals/plugins/subtask2/core/plugin.ts +52 -52
  772. package/src/internals/plugins/subtask2/core/state.ts +764 -764
  773. package/src/internals/plugins/subtask2/features/auto.ts +57 -57
  774. package/src/internals/plugins/subtask2/features/index.ts +9 -9
  775. package/src/internals/plugins/subtask2/features/inline-subtasks.ts +205 -205
  776. package/src/internals/plugins/subtask2/features/parallel.ts +148 -148
  777. package/src/internals/plugins/subtask2/features/results.ts +48 -48
  778. package/src/internals/plugins/subtask2/features/returns.ts +273 -273
  779. package/src/internals/plugins/subtask2/features/turns.ts +190 -190
  780. package/src/internals/plugins/subtask2/hooks/command-hooks.ts +283 -283
  781. package/src/internals/plugins/subtask2/hooks/message-hooks.ts +603 -603
  782. package/src/internals/plugins/subtask2/hooks/session-idle-hook.ts +358 -358
  783. package/src/internals/plugins/subtask2/hooks/tool-hooks.ts +309 -309
  784. package/src/internals/plugins/subtask2/loop.ts +122 -122
  785. package/src/internals/plugins/subtask2/parsing/auto.ts +33 -33
  786. package/src/internals/plugins/subtask2/parsing/commands.ts +154 -154
  787. package/src/internals/plugins/subtask2/parsing/frontmatter.ts +20 -20
  788. package/src/internals/plugins/subtask2/parsing/index.ts +10 -10
  789. package/src/internals/plugins/subtask2/parsing/overrides.ts +68 -68
  790. package/src/internals/plugins/subtask2/parsing/parallel.ts +88 -88
  791. package/src/internals/plugins/subtask2/parsing/turns.ts +78 -78
  792. package/src/internals/plugins/subtask2/types.ts +41 -41
  793. package/src/internals/plugins/subtask2/utils/config.ts +100 -100
  794. package/src/internals/plugins/subtask2/utils/index.ts +7 -7
  795. package/src/internals/plugins/subtask2/utils/logger.ts +67 -67
  796. package/src/internals/plugins/subtask2/utils/prompts.ts +117 -117
  797. package/src/internals/plugins/websearch-cited/LICENSE +214 -214
  798. package/src/internals/plugins/websearch-cited/codex_prompt.txt +79 -79
  799. package/src/internals/plugins/websearch-cited/google.ts +749 -749
  800. package/src/internals/plugins/websearch-cited/index.ts +301 -301
  801. package/src/internals/plugins/websearch-cited/openai.ts +407 -407
  802. package/src/internals/plugins/websearch-cited/openrouter.ts +190 -190
  803. package/src/internals/plugins/websearch-cited/types.ts +7 -7
  804. package/src/lsp/index.ts +15 -15
  805. package/src/mcp/context7.ts +9 -9
  806. package/src/mcp/grep-app.ts +6 -6
  807. package/src/mcp/index.ts +87 -87
  808. package/src/mcp/omo-mcp-index.ts +35 -35
  809. package/src/mcp/types.ts +9 -9
  810. package/src/mcp/websearch.ts +44 -44
  811. package/src/permissions/index.ts +25 -25
  812. package/src/plugin/AGENTS.md +54 -54
  813. package/src/plugin/available-categories.ts +24 -24
  814. package/src/plugin/chat-headers.ts +141 -141
  815. package/src/plugin/chat-message.ts +309 -309
  816. package/src/plugin/chat-params.ts +182 -182
  817. package/src/plugin/command-execute-before.ts +80 -80
  818. package/src/plugin/event.ts +639 -639
  819. package/src/plugin/hooks/create-continuation-hooks.ts +128 -128
  820. package/src/plugin/hooks/create-core-hooks.ts +47 -47
  821. package/src/plugin/hooks/create-session-hooks.ts +286 -286
  822. package/src/plugin/hooks/create-skill-hooks.ts +50 -50
  823. package/src/plugin/hooks/create-tool-guard-hooks.ts +159 -159
  824. package/src/plugin/hooks/create-transform-hooks.ts +85 -85
  825. package/src/plugin/messages-transform.ts +28 -28
  826. package/src/plugin/normalize-tool-arg-schemas.ts +75 -75
  827. package/src/plugin/recent-synthetic-idles.ts +20 -20
  828. package/src/plugin/session-agent-resolver.ts +37 -37
  829. package/src/plugin/session-status-normalizer.ts +22 -22
  830. package/src/plugin/skill-context.ts +132 -132
  831. package/src/plugin/system-transform.ts +6 -6
  832. package/src/plugin/tool-execute-after.ts +178 -178
  833. package/src/plugin/tool-execute-before.ts +222 -222
  834. package/src/plugin/tool-registry.ts +282 -282
  835. package/src/plugin/types.ts +26 -26
  836. package/src/plugin/ultrawork-db-model-override.ts +142 -142
  837. package/src/plugin/ultrawork-model-override.ts +196 -196
  838. package/src/plugin/ultrawork-variant-availability.ts +51 -51
  839. package/src/plugin/unstable-agent-babysitter.ts +41 -41
  840. package/src/plugin-config.ts +314 -314
  841. package/src/plugin-dispose.ts +51 -51
  842. package/src/plugin-handlers/AGENTS.md +92 -92
  843. package/src/plugin-handlers/agent-config-handler.ts +502 -502
  844. package/src/plugin-handlers/agent-key-remapper.ts +39 -39
  845. package/src/plugin-handlers/agent-override-protection.ts +38 -38
  846. package/src/plugin-handlers/agent-priority-order.ts +63 -63
  847. package/src/plugin-handlers/category-config-resolver.ts +9 -9
  848. package/src/plugin-handlers/command-config-handler.ts +105 -105
  849. package/src/plugin-handlers/config-handler.ts +61 -61
  850. package/src/plugin-handlers/index.ts +10 -10
  851. package/src/plugin-handlers/mcp-config-handler.ts +205 -205
  852. package/src/plugin-handlers/plan-model-inheritance.ts +27 -27
  853. package/src/plugin-handlers/plugin-components-loader.ts +70 -70
  854. package/src/plugin-handlers/provider-config-handler.ts +73 -73
  855. package/src/plugin-handlers/strategist-agent-config-builder.ts +128 -128
  856. package/src/plugin-handlers/tool-config-handler.ts +193 -193
  857. package/src/plugin-interface.ts +83 -83
  858. package/src/plugin-state.ts +18 -18
  859. package/src/shared/AGENTS.md +54 -54
  860. package/src/shared/agent-display-names.ts +182 -182
  861. package/src/shared/agent-tool-restrictions.ts +80 -80
  862. package/src/shared/agent-variant.ts +101 -101
  863. package/src/shared/agents-config-dir.ts +23 -23
  864. package/src/shared/archive-entry-validator.ts +83 -83
  865. package/src/shared/background-output-consumption.ts +69 -69
  866. package/src/shared/binary-downloader.ts +127 -127
  867. package/src/shared/claude-config-dir.ts +16 -16
  868. package/src/shared/closure-protocol.ts +53 -53
  869. package/src/shared/command-executor/embedded-commands.ts +26 -26
  870. package/src/shared/command-executor/execute-command.ts +28 -28
  871. package/src/shared/command-executor/execute-hook-command.ts +129 -129
  872. package/src/shared/command-executor/home-directory.ts +5 -5
  873. package/src/shared/command-executor/resolve-commands-in-text.ts +49 -49
  874. package/src/shared/command-executor/shell-path.ts +27 -27
  875. package/src/shared/command-executor.ts +5 -5
  876. package/src/shared/compaction-agent-config-checkpoint.ts +42 -42
  877. package/src/shared/compaction-marker.ts +61 -61
  878. package/src/shared/config-errors.ts +18 -18
  879. package/src/shared/connected-providers-cache.ts +215 -215
  880. package/src/shared/contains-path.ts +50 -50
  881. package/src/shared/context-limit-resolver.ts +42 -42
  882. package/src/shared/data-path.ts +64 -64
  883. package/src/shared/deep-merge.ts +53 -53
  884. package/src/shared/disabled-tools.ts +19 -19
  885. package/src/shared/dynamic-truncator.ts +222 -222
  886. package/src/shared/external-plugin-detector.ts +139 -139
  887. package/src/shared/fallback-chain-from-models.ts +124 -124
  888. package/src/shared/fallback-model-availability.ts +102 -102
  889. package/src/shared/file-reference-resolver.ts +99 -99
  890. package/src/shared/file-utils.ts +34 -34
  891. package/src/shared/first-message-variant.ts +28 -28
  892. package/src/shared/frontmatter.ts +31 -31
  893. package/src/shared/git-worktree/collect-git-diff-stats.ts +56 -56
  894. package/src/shared/git-worktree/format-file-changes.ts +46 -46
  895. package/src/shared/git-worktree/index.ts +7 -7
  896. package/src/shared/git-worktree/parse-diff-numstat.ts +27 -27
  897. package/src/shared/git-worktree/parse-status-porcelain-line.ts +27 -27
  898. package/src/shared/git-worktree/parse-status-porcelain.ts +15 -15
  899. package/src/shared/git-worktree/types.ts +8 -8
  900. package/src/shared/hook-disabled.ts +22 -22
  901. package/src/shared/index.ts +80 -80
  902. package/src/shared/internal-initiator-marker.ts +18 -18
  903. package/src/shared/is-abort-error.ts +20 -20
  904. package/src/shared/json-file-cache-store.ts +98 -98
  905. package/src/shared/jsonc-parser.ts +98 -98
  906. package/src/shared/known-variants.ts +16 -16
  907. package/src/shared/legacy-plugin-warning.ts +68 -68
  908. package/src/shared/load-opencode-plugins.ts +60 -60
  909. package/src/shared/log-legacy-plugin-startup-warning.ts +46 -46
  910. package/src/shared/logger.ts +48 -48
  911. package/src/shared/merge-categories.ts +18 -18
  912. package/src/shared/migrate-legacy-config-file.ts +66 -66
  913. package/src/shared/migrate-legacy-plugin-entry.ts +75 -75
  914. package/src/shared/migration/agent-category.ts +60 -60
  915. package/src/shared/migration/agent-names.ts +100 -100
  916. package/src/shared/migration/config-migration.ts +210 -210
  917. package/src/shared/migration/hook-names.ts +40 -40
  918. package/src/shared/migration/migrations-sidecar.ts +92 -92
  919. package/src/shared/migration/model-versions.ts +50 -50
  920. package/src/shared/migration.ts +5 -5
  921. package/src/shared/model-availability.ts +294 -294
  922. package/src/shared/model-capabilities/bundled-snapshot.ts +15 -15
  923. package/src/shared/model-capabilities/get-model-capabilities.ts +140 -140
  924. package/src/shared/model-capabilities/index.ts +9 -9
  925. package/src/shared/model-capabilities/runtime-model-readers.ts +190 -190
  926. package/src/shared/model-capabilities/types.ts +80 -80
  927. package/src/shared/model-capabilities-cache.ts +213 -213
  928. package/src/shared/model-capability-aliases.ts +108 -108
  929. package/src/shared/model-capability-guardrails.ts +149 -149
  930. package/src/shared/model-capability-heuristics.ts +32 -32
  931. package/src/shared/model-error-classifier.ts +214 -214
  932. package/src/shared/model-format-normalizer.ts +20 -20
  933. package/src/shared/model-normalization.ts +8 -8
  934. package/src/shared/model-requirements.ts +26 -26
  935. package/src/shared/model-resolution-pipeline.ts +216 -216
  936. package/src/shared/model-resolution-types.ts +41 -41
  937. package/src/shared/model-resolver.ts +106 -106
  938. package/src/shared/model-sanitizer.ts +12 -12
  939. package/src/shared/model-settings-compatibility.ts +200 -200
  940. package/src/shared/model-suggestion-retry.ts +182 -182
  941. package/src/shared/normalize-sdk-response.ts +36 -36
  942. package/src/shared/opencode-command-dirs.ts +36 -36
  943. package/src/shared/opencode-config-dir-types.ts +15 -15
  944. package/src/shared/opencode-config-dir.ts +135 -135
  945. package/src/shared/opencode-http-api.ts +139 -139
  946. package/src/shared/opencode-message-dir.ts +29 -29
  947. package/src/shared/opencode-server-auth.ts +190 -190
  948. package/src/shared/opencode-storage-detection.ts +33 -33
  949. package/src/shared/opencode-storage-paths.ts +6 -6
  950. package/src/shared/opencode-version.ts +80 -80
  951. package/src/shared/parse-tools-config.ts +25 -25
  952. package/src/shared/pattern-matcher.ts +46 -46
  953. package/src/shared/permission-compat.ts +86 -86
  954. package/src/shared/plugin-command-discovery.ts +28 -28
  955. package/src/shared/plugin-entry-migrator.ts +21 -21
  956. package/src/shared/plugin-identity.ts +8 -8
  957. package/src/shared/port-utils.ts +48 -48
  958. package/src/shared/project-discovery-dirs.ts +101 -101
  959. package/src/shared/prompt-timeout-context.ts +49 -49
  960. package/src/shared/prompt-tools.ts +35 -35
  961. package/src/shared/provider-model-id-transform.ts +58 -58
  962. package/src/shared/question-denied-session-permission.ts +9 -9
  963. package/src/shared/record-type-guard.ts +3 -3
  964. package/src/shared/resolve-agent-definition-paths.ts +22 -22
  965. package/src/shared/retry-status-utils.ts +19 -19
  966. package/src/shared/runtime-plugin-config.ts +98 -98
  967. package/src/shared/safe-create-hook.ts +24 -24
  968. package/src/shared/session-category-registry.ts +27 -27
  969. package/src/shared/session-cursor.ts +108 -108
  970. package/src/shared/session-directory-resolver.ts +41 -41
  971. package/src/shared/session-injected-paths.ts +59 -59
  972. package/src/shared/session-model-state.ts +15 -15
  973. package/src/shared/session-prompt-params-helpers.ts +31 -31
  974. package/src/shared/session-prompt-params-state.ts +37 -37
  975. package/src/shared/session-tools-store.ts +18 -18
  976. package/src/shared/session-utils.ts +25 -25
  977. package/src/shared/shell-env.ts +175 -175
  978. package/src/shared/skill-path-resolver.ts +26 -26
  979. package/src/shared/snake-case.ts +44 -44
  980. package/src/shared/spawn-with-windows-hide.ts +84 -84
  981. package/src/shared/system-directive.ts +67 -67
  982. package/src/shared/task-system-enabled.ts +9 -9
  983. package/src/shared/tmux/constants.ts +12 -12
  984. package/src/shared/tmux/index.ts +3 -3
  985. package/src/shared/tmux/tmux-utils/environment.ts +13 -13
  986. package/src/shared/tmux/tmux-utils/layout.ts +96 -96
  987. package/src/shared/tmux/tmux-utils/pane-close.ts +48 -48
  988. package/src/shared/tmux/tmux-utils/pane-dimensions.ts +28 -28
  989. package/src/shared/tmux/tmux-utils/pane-replace.ts +73 -73
  990. package/src/shared/tmux/tmux-utils/pane-spawn.ts +94 -94
  991. package/src/shared/tmux/tmux-utils/server-health.ts +62 -62
  992. package/src/shared/tmux/tmux-utils/session-spawn.ts +145 -145
  993. package/src/shared/tmux/tmux-utils/window-spawn.ts +93 -93
  994. package/src/shared/tmux/tmux-utils.ts +15 -15
  995. package/src/shared/tmux/types.ts +4 -4
  996. package/src/shared/tool-name.ts +27 -27
  997. package/src/shared/truncate-description.ts +11 -11
  998. package/src/shared/vision-capable-models-cache.ts +17 -17
  999. package/src/shared/write-file-atomically.ts +31 -31
  1000. package/src/shared/zip-entry-listing/powershell-zip-entry-listing.ts +99 -99
  1001. package/src/shared/zip-entry-listing/python-zip-entry-listing.ts +55 -55
  1002. package/src/shared/zip-entry-listing/read-zip-symlink-target.ts +23 -23
  1003. package/src/shared/zip-entry-listing/tar-zip-entry-listing.ts +93 -93
  1004. package/src/shared/zip-entry-listing/zipinfo-zip-entry-listing.ts +72 -72
  1005. package/src/shared/zip-entry-listing.ts +13 -13
  1006. package/src/shared/zip-extractor.ts +118 -118
  1007. package/src/skills/index.ts +56 -56
  1008. package/src/testing/module-mock-lifecycle.ts +143 -143
  1009. package/src/tools/AGENTS.md +108 -108
  1010. package/src/tools/ast-grep/cli-binary-path-resolution.ts +60 -60
  1011. package/src/tools/ast-grep/cli.ts +177 -177
  1012. package/src/tools/ast-grep/constants.ts +5 -5
  1013. package/src/tools/ast-grep/downloader.ts +119 -119
  1014. package/src/tools/ast-grep/environment-check.ts +89 -89
  1015. package/src/tools/ast-grep/index.ts +5 -5
  1016. package/src/tools/ast-grep/language-support.ts +63 -63
  1017. package/src/tools/ast-grep/process-output-timeout.ts +28 -28
  1018. package/src/tools/ast-grep/result-formatter.ts +102 -102
  1019. package/src/tools/ast-grep/sg-cli-path.ts +102 -102
  1020. package/src/tools/ast-grep/sg-compact-json-output.ts +54 -54
  1021. package/src/tools/ast-grep/tools.ts +117 -117
  1022. package/src/tools/ast-grep/types.ts +61 -61
  1023. package/src/tools/background-task/AGENTS.md +53 -53
  1024. package/src/tools/background-task/clients.ts +32 -32
  1025. package/src/tools/background-task/constants.ts +9 -9
  1026. package/src/tools/background-task/create-background-cancel.ts +115 -115
  1027. package/src/tools/background-task/create-background-output.ts +159 -159
  1028. package/src/tools/background-task/create-background-task.ts +126 -126
  1029. package/src/tools/background-task/delay.ts +3 -3
  1030. package/src/tools/background-task/full-session-format.ts +148 -148
  1031. package/src/tools/background-task/index.ts +8 -8
  1032. package/src/tools/background-task/message-dir.ts +1 -1
  1033. package/src/tools/background-task/session-messages.ts +22 -22
  1034. package/src/tools/background-task/task-result-format.ts +113 -113
  1035. package/src/tools/background-task/task-status-format.ts +72 -72
  1036. package/src/tools/background-task/time-format.ts +30 -30
  1037. package/src/tools/background-task/tools.ts +11 -11
  1038. package/src/tools/background-task/truncate-text.ts +4 -4
  1039. package/src/tools/background-task/types.ts +72 -72
  1040. package/src/tools/call-omo-agent/AGENTS.md +51 -51
  1041. package/src/tools/call-omo-agent/agent-resolver.ts +64 -64
  1042. package/src/tools/call-omo-agent/background-agent-executor.ts +91 -91
  1043. package/src/tools/call-omo-agent/background-executor.ts +98 -98
  1044. package/src/tools/call-omo-agent/completion-poller.ts +65 -65
  1045. package/src/tools/call-omo-agent/constants.ts +23 -23
  1046. package/src/tools/call-omo-agent/index.ts +3 -3
  1047. package/src/tools/call-omo-agent/message-dir.ts +1 -1
  1048. package/src/tools/call-omo-agent/message-processor.ts +86 -86
  1049. package/src/tools/call-omo-agent/message-storage-directory.ts +1 -1
  1050. package/src/tools/call-omo-agent/session-creator.ts +70 -70
  1051. package/src/tools/call-omo-agent/subagent-session-creator.ts +74 -74
  1052. package/src/tools/call-omo-agent/sync-executor.ts +148 -148
  1053. package/src/tools/call-omo-agent/tool-context-with-metadata.ts +10 -10
  1054. package/src/tools/call-omo-agent/tools.ts +192 -192
  1055. package/src/tools/call-omo-agent/types.ts +34 -34
  1056. package/src/tools/delegate-task/AGENTS.md +58 -58
  1057. package/src/tools/delegate-task/anthropic-categories.ts +62 -62
  1058. package/src/tools/delegate-task/available-models.ts +64 -64
  1059. package/src/tools/delegate-task/background-continuation.ts +68 -68
  1060. package/src/tools/delegate-task/background-task.ts +165 -165
  1061. package/src/tools/delegate-task/builtin-categories.ts +33 -33
  1062. package/src/tools/delegate-task/builtin-category-definition.ts +8 -8
  1063. package/src/tools/delegate-task/cancel-unstable-agent-task.ts +19 -19
  1064. package/src/tools/delegate-task/categories.ts +77 -77
  1065. package/src/tools/delegate-task/category-resolver.ts +310 -310
  1066. package/src/tools/delegate-task/constants.ts +351 -351
  1067. package/src/tools/delegate-task/delegated-model-config.ts +20 -20
  1068. package/src/tools/delegate-task/error-formatting.ts +51 -51
  1069. package/src/tools/delegate-task/executor-types.ts +39 -39
  1070. package/src/tools/delegate-task/executor.ts +16 -16
  1071. package/src/tools/delegate-task/fallback-entry-resolution.ts +27 -27
  1072. package/src/tools/delegate-task/fallback-entry-settings.ts +20 -20
  1073. package/src/tools/delegate-task/google-categories.ts +130 -130
  1074. package/src/tools/delegate-task/index.ts +4 -4
  1075. package/src/tools/delegate-task/kimi-categories.ts +40 -40
  1076. package/src/tools/delegate-task/model-selection.ts +201 -201
  1077. package/src/tools/delegate-task/model-string-parser.ts +63 -63
  1078. package/src/tools/delegate-task/openai-categories.ts +128 -128
  1079. package/src/tools/delegate-task/parent-context-resolver.ts +47 -47
  1080. package/src/tools/delegate-task/prompt-builder.ts +107 -107
  1081. package/src/tools/delegate-task/resolve-call-id.ts +5 -5
  1082. package/src/tools/delegate-task/skill-resolver.ts +22 -22
  1083. package/src/tools/delegate-task/sub-agent.ts +70 -70
  1084. package/src/tools/delegate-task/subagent-discovery.ts +152 -152
  1085. package/src/tools/delegate-task/subagent-resolver.ts +225 -225
  1086. package/src/tools/delegate-task/sync-continuation-deps.ts +9 -9
  1087. package/src/tools/delegate-task/sync-continuation.ts +149 -149
  1088. package/src/tools/delegate-task/sync-prompt-sender.ts +137 -137
  1089. package/src/tools/delegate-task/sync-result-fetcher.ts +60 -60
  1090. package/src/tools/delegate-task/sync-session-creator.ts +29 -29
  1091. package/src/tools/delegate-task/sync-session-poller.ts +188 -188
  1092. package/src/tools/delegate-task/sync-task-deps.ts +13 -13
  1093. package/src/tools/delegate-task/sync-task-fallback.ts +68 -68
  1094. package/src/tools/delegate-task/sync-task.ts +243 -243
  1095. package/src/tools/delegate-task/time-formatter.ts +13 -13
  1096. package/src/tools/delegate-task/timing.ts +46 -46
  1097. package/src/tools/delegate-task/token-limiter.ts +123 -123
  1098. package/src/tools/delegate-task/tools.ts +259 -259
  1099. package/src/tools/delegate-task/types.ts +89 -89
  1100. package/src/tools/delegate-task/unstable-agent-task.ts +243 -243
  1101. package/src/tools/glob/cli.ts +206 -206
  1102. package/src/tools/glob/constants.ts +12 -12
  1103. package/src/tools/glob/index.ts +1 -1
  1104. package/src/tools/glob/result-formatter.ts +26 -26
  1105. package/src/tools/glob/tools.ts +49 -49
  1106. package/src/tools/glob/types.ts +23 -23
  1107. package/src/tools/grep/cli.ts +279 -279
  1108. package/src/tools/grep/constants.ts +141 -141
  1109. package/src/tools/grep/downloader.ts +128 -128
  1110. package/src/tools/grep/index.ts +1 -1
  1111. package/src/tools/grep/result-formatter.ts +60 -60
  1112. package/src/tools/grep/tools.ts +75 -75
  1113. package/src/tools/grep/types.ts +42 -42
  1114. package/src/tools/hashline-edit/AGENTS.md +92 -92
  1115. package/src/tools/hashline-edit/autocorrect-replacement-lines.ts +179 -179
  1116. package/src/tools/hashline-edit/constants.ts +10 -10
  1117. package/src/tools/hashline-edit/diff-utils.ts +53 -53
  1118. package/src/tools/hashline-edit/edit-deduplication.ts +43 -43
  1119. package/src/tools/hashline-edit/edit-operation-primitives.ts +126 -126
  1120. package/src/tools/hashline-edit/edit-operations.ts +103 -103
  1121. package/src/tools/hashline-edit/edit-ordering.ts +56 -56
  1122. package/src/tools/hashline-edit/edit-text-normalization.ts +111 -111
  1123. package/src/tools/hashline-edit/file-text-canonicalization.ts +44 -44
  1124. package/src/tools/hashline-edit/formatter-trigger.ts +132 -132
  1125. package/src/tools/hashline-edit/hash-computation.ts +154 -154
  1126. package/src/tools/hashline-edit/hashline-chunk-formatter.ts +52 -52
  1127. package/src/tools/hashline-edit/hashline-edit-diff.ts +31 -31
  1128. package/src/tools/hashline-edit/hashline-edit-executor.ts +197 -197
  1129. package/src/tools/hashline-edit/index.ts +20 -20
  1130. package/src/tools/hashline-edit/normalize-edits.ts +95 -95
  1131. package/src/tools/hashline-edit/tool-description.ts +95 -95
  1132. package/src/tools/hashline-edit/tools.ts +42 -42
  1133. package/src/tools/hashline-edit/types.ts +20 -20
  1134. package/src/tools/hashline-edit/validation.ts +181 -181
  1135. package/src/tools/index.ts +64 -64
  1136. package/src/tools/interactive-bash/constants.ts +18 -18
  1137. package/src/tools/interactive-bash/index.ts +4 -4
  1138. package/src/tools/interactive-bash/tmux-path-resolver.ts +71 -71
  1139. package/src/tools/interactive-bash/tools.ts +136 -136
  1140. package/src/tools/look-at/assistant-message-extractor.ts +67 -67
  1141. package/src/tools/look-at/constants.ts +3 -3
  1142. package/src/tools/look-at/image-converter.ts +164 -164
  1143. package/src/tools/look-at/index.ts +3 -3
  1144. package/src/tools/look-at/look-at-arguments.ts +34 -34
  1145. package/src/tools/look-at/mime-type-inference.ts +94 -94
  1146. package/src/tools/look-at/multimodal-agent-metadata.ts +166 -166
  1147. package/src/tools/look-at/multimodal-fallback-chain.ts +66 -66
  1148. package/src/tools/look-at/session-poller.ts +42 -42
  1149. package/src/tools/look-at/tools.ts +245 -245
  1150. package/src/tools/look-at/types.ts +5 -5
  1151. package/src/tools/lsp/AGENTS.md +70 -70
  1152. package/src/tools/lsp/client.ts +3 -3
  1153. package/src/tools/lsp/config.ts +3 -3
  1154. package/src/tools/lsp/constants.ts +7 -7
  1155. package/src/tools/lsp/diagnostics-tool.ts +75 -75
  1156. package/src/tools/lsp/directory-diagnostics.ts +163 -163
  1157. package/src/tools/lsp/find-references-tool.ts +43 -43
  1158. package/src/tools/lsp/goto-definition-tool.ts +42 -42
  1159. package/src/tools/lsp/index.ts +9 -9
  1160. package/src/tools/lsp/infer-extension.ts +65 -65
  1161. package/src/tools/lsp/language-config.ts +5 -5
  1162. package/src/tools/lsp/language-mappings.ts +171 -171
  1163. package/src/tools/lsp/lsp-client-connection.ts +66 -66
  1164. package/src/tools/lsp/lsp-client-transport.ts +210 -210
  1165. package/src/tools/lsp/lsp-client-wrapper.ts +116 -116
  1166. package/src/tools/lsp/lsp-client.ts +129 -129
  1167. package/src/tools/lsp/lsp-formatters.ts +193 -193
  1168. package/src/tools/lsp/lsp-manager-process-cleanup.ts +83 -83
  1169. package/src/tools/lsp/lsp-manager-temp-directory-cleanup.ts +29 -29
  1170. package/src/tools/lsp/lsp-process.ts +158 -158
  1171. package/src/tools/lsp/lsp-server.ts +217 -217
  1172. package/src/tools/lsp/rename-tools.ts +53 -53
  1173. package/src/tools/lsp/server-config-loader.ts +116 -116
  1174. package/src/tools/lsp/server-definitions.ts +91 -91
  1175. package/src/tools/lsp/server-installation.ts +58 -58
  1176. package/src/tools/lsp/server-path-bases.ts +16 -16
  1177. package/src/tools/lsp/server-resolution.ts +109 -109
  1178. package/src/tools/lsp/symbols-tool.ts +76 -76
  1179. package/src/tools/lsp/tools.ts +5 -5
  1180. package/src/tools/lsp/types.ts +124 -124
  1181. package/src/tools/lsp/workspace-edit.ts +121 -121
  1182. package/src/tools/session-manager/constants.ts +93 -93
  1183. package/src/tools/session-manager/file-storage.ts +203 -203
  1184. package/src/tools/session-manager/index.ts +3 -3
  1185. package/src/tools/session-manager/sdk-storage.ts +135 -135
  1186. package/src/tools/session-manager/sdk-unavailable.ts +43 -43
  1187. package/src/tools/session-manager/session-formatter.ts +199 -199
  1188. package/src/tools/session-manager/storage.ts +161 -161
  1189. package/src/tools/session-manager/tools.ts +197 -197
  1190. package/src/tools/session-manager/types.ts +99 -99
  1191. package/src/tools/shared/semaphore.ts +32 -32
  1192. package/src/tools/skill/constants.ts +14 -14
  1193. package/src/tools/skill/description-formatter.ts +61 -61
  1194. package/src/tools/skill/index.ts +3 -3
  1195. package/src/tools/skill/mcp-capability-formatter.ts +97 -97
  1196. package/src/tools/skill/native-skills.ts +62 -62
  1197. package/src/tools/skill/scope-priority.ts +17 -17
  1198. package/src/tools/skill/skill-body.ts +26 -26
  1199. package/src/tools/skill/skill-matcher.ts +40 -40
  1200. package/src/tools/skill/tools.ts +196 -196
  1201. package/src/tools/skill/types.ts +48 -48
  1202. package/src/tools/skill-mcp/constants.ts +9 -9
  1203. package/src/tools/skill-mcp/index.ts +3 -3
  1204. package/src/tools/skill-mcp/tools.ts +204 -204
  1205. package/src/tools/skill-mcp/types.ts +8 -8
  1206. package/src/tools/slashcommand/command-discovery.ts +161 -161
  1207. package/src/tools/slashcommand/command-output-formatter.ts +75 -75
  1208. package/src/tools/slashcommand/index.ts +2 -2
  1209. package/src/tools/slashcommand/types.ts +21 -21
  1210. package/src/tools/task/index.ts +7 -7
  1211. package/src/tools/task/task-create.ts +113 -113
  1212. package/src/tools/task/task-get.ts +47 -47
  1213. package/src/tools/task/task-list.ts +79 -79
  1214. package/src/tools/task/task-update.ts +152 -152
  1215. package/src/tools/task/todo-sync.ts +205 -205
  1216. package/src/tools/task/types.ts +77 -77
  1217. package/scripts/check_docs.ts +0 -129
  1218. package/scripts/doctor.ts +0 -522
  1219. package/scripts/measure_prompts.ts +0 -193
  1220. 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
+ }