@hiai-gg/hiai-opencode 0.1.0 → 0.1.1

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