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