@polderlabs/bizar 6.2.4 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/CLAUDE.md +15 -0
- package/.claude/agents/_shared/AGENT_BASELINE.md +168 -0
- package/.claude/agents/_shared/CLAUDE_TOOLS.md +412 -0
- package/.claude/agents/_shared/SKILLS.md +109 -0
- package/.claude/agents/agent-browser.md +80 -0
- package/.claude/agents/baldr.md +49 -0
- package/.claude/agents/forseti.md +51 -0
- package/.claude/agents/frigg.md +42 -0
- package/.claude/agents/heimdall.md +33 -0
- package/.claude/agents/hermod.md +53 -0
- package/.claude/agents/mimir.md +49 -0
- package/.claude/agents/odin.md +287 -0
- package/.claude/agents/quick.md +34 -0
- package/.claude/agents/semble-search.md +50 -0
- package/.claude/agents/thor.md +53 -0
- package/.claude/agents/tyr.md +56 -0
- package/.claude/agents/vidarr.md +54 -0
- package/.claude/agents/vor.md +53 -0
- package/.claude/commands/audit.md +25 -0
- package/.claude/commands/bizar.md +22 -0
- package/.claude/commands/explain.md +17 -0
- package/.claude/commands/init.md +29 -0
- package/.claude/commands/learn.md +44 -0
- package/.claude/commands/plan.md +35 -0
- package/.claude/commands/plow-through.md +50 -0
- package/.claude/commands/pr-review.md +49 -0
- package/.claude/commands/setup-provider.md +96 -0
- package/.claude/commands/tailscale-serve.md +100 -0
- package/.claude/commands/team.md +132 -0
- package/.claude/commands/test.md +62 -0
- package/.claude/commands/validate.md +68 -0
- package/.claude/commands/visual-plan.md +24 -0
- package/.claude/hooks/README.md +92 -0
- package/.claude/hooks/posttooluse-editwrite.mjs +91 -0
- package/.claude/hooks/pretooluse-bash.mjs +81 -0
- package/.claude/hooks/pretooluse-editwrite.mjs +139 -0
- package/.claude/hooks/sessionend-recall.mjs +74 -0
- package/.claude/hooks/sessionstart-prime.mjs +80 -0
- package/.claude/hooks/userpromptsubmit-tag.mjs +80 -0
- package/.claude/settings.json +116 -0
- package/.claude/skills/9router/SKILL.md +80 -0
- package/.claude/skills/9router-chat/SKILL.md +73 -0
- package/.claude/skills/9router-embeddings/SKILL.md +69 -0
- package/.claude/skills/9router-image/SKILL.md +86 -0
- package/.claude/skills/9router-stt/SKILL.md +79 -0
- package/.claude/skills/9router-tts/SKILL.md +80 -0
- package/.claude/skills/9router-web-fetch/SKILL.md +99 -0
- package/.claude/skills/9router-web-search/SKILL.md +91 -0
- package/.claude/skills/bizar/README.md +9 -0
- package/.claude/skills/bizar/SKILL.md +450 -0
- package/.claude/skills/cpp-coding-standards/README.md +28 -0
- package/.claude/skills/cpp-coding-standards/SKILL.md +634 -0
- package/.claude/skills/cpp-coding-standards/references/concurrency.md +320 -0
- package/.claude/skills/cpp-coding-standards/references/error-handling.md +229 -0
- package/.claude/skills/cpp-coding-standards/references/memory-safety.md +216 -0
- package/.claude/skills/cpp-coding-standards/references/modern-idioms.md +282 -0
- package/.claude/skills/cpp-coding-standards/references/review-checklist.md +96 -0
- package/.claude/skills/cpp-testing/README.md +28 -0
- package/.claude/skills/cpp-testing/SKILL.md +304 -0
- package/.claude/skills/cpp-testing/references/coverage.md +370 -0
- package/.claude/skills/cpp-testing/references/framework-compare.md +175 -0
- package/.claude/skills/cpp-testing/references/host-test-for-embedded.md +499 -0
- package/.claude/skills/cpp-testing/references/mocking.md +364 -0
- package/.claude/skills/cpp-testing/references/tdd-workflow.md +308 -0
- package/.claude/skills/cubesandbox/SKILL.md +148 -0
- package/.claude/skills/embedded-esp-idf/README.md +41 -0
- package/.claude/skills/embedded-esp-idf/SKILL.md +439 -0
- package/.claude/skills/embedded-esp-idf/references/freertos-patterns.md +214 -0
- package/.claude/skills/embedded-esp-idf/references/host-tests.md +164 -0
- package/.claude/skills/embedded-esp-idf/references/idf-py-commands.md +157 -0
- package/.claude/skills/embedded-esp-idf/references/kconfig.md +159 -0
- package/.claude/skills/embedded-esp-idf/references/logging-discipline.md +118 -0
- package/.claude/skills/embedded-esp-idf/references/memory-and-iram.md +137 -0
- package/.claude/skills/embedded-esp-idf/references/nvs.md +121 -0
- package/.claude/skills/embedded-esp-idf/references/packed-structs.md +192 -0
- package/.claude/skills/embedded-esp-idf/scripts/idf_env.sh +47 -0
- package/.claude/skills/embedded-esp-idf/scripts/size_check.sh +77 -0
- package/.claude/skills/glyph/SKILL.md +163 -0
- package/.claude/skills/harness-engineering/SKILL.md +143 -0
- package/.claude/skills/lightrag/SKILL.md +81 -0
- package/.claude/skills/memory-protocol/SKILL.md +105 -0
- package/.claude/skills/obsidian/SKILL.md +306 -0
- package/.claude/skills/read-the-damn-docs/SKILL.md +113 -0
- package/.claude/skills/self-improvement/SKILL.md +64 -0
- package/README.md +87 -59
- package/bizar-dash/dist/assets/{EnvVarsSection-DXM8gRm_.js → EnvVarsSection-B58aiJiE.js} +1 -1
- package/bizar-dash/dist/assets/{EnvVarsSection-DXM8gRm_.js.map → EnvVarsSection-B58aiJiE.js.map} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-BnKN_Ks_.js → MobileChat-BJrqwVDd.js} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-BnKN_Ks_.js.map → MobileChat-BJrqwVDd.js.map} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-DjCPxC-Q.js → MobileSettings-CEQNJNLJ.js} +1 -1
- package/bizar-dash/dist/assets/{MobileSettings-DjCPxC-Q.js.map → MobileSettings-CEQNJNLJ.js.map} +1 -1
- package/bizar-dash/dist/assets/{main-DYiZqMrn.js → main-IvfQAOfy.js} +1 -1
- package/bizar-dash/dist/assets/{main-DYiZqMrn.js.map → main-IvfQAOfy.js.map} +1 -1
- package/bizar-dash/dist/assets/{markdown-C6mXtQxD.js → markdown-tOLaD6nm.js} +1 -1
- package/bizar-dash/dist/assets/{markdown-C6mXtQxD.js.map → markdown-tOLaD6nm.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile-CMHqtLV2.js → mobile-DYCHcUpq.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-CMHqtLV2.js.map → mobile-DYCHcUpq.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile-layout-3jIhHX_p.js → mobile-layout-CBHjpwsb.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-layout-3jIhHX_p.js.map → mobile-layout-CBHjpwsb.js.map} +1 -1
- package/bizar-dash/dist/assets/{useSlashCommands-DtITw8Xv.js → useSlashCommands-Bd7_FA6U.js} +2 -2
- package/bizar-dash/dist/assets/{useSlashCommands-DtITw8Xv.js.map → useSlashCommands-Bd7_FA6U.js.map} +1 -1
- package/bizar-dash/dist/assets/{vendor-CeHGtduv.js → vendor-C843201K.js} +12 -12
- package/bizar-dash/dist/assets/vendor-C843201K.js.map +1 -0
- package/bizar-dash/dist/index.html +6 -6
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/api.mjs +4 -4
- package/bizar-dash/src/server/background-store.mjs +15 -16
- package/bizar-dash/src/server/bg-poller.mjs +25 -35
- package/bizar-dash/src/server/bg-retry.mjs +66 -194
- package/bizar-dash/src/server/claude-bg-spawner.mjs +390 -0
- package/bizar-dash/src/server/claude-info.mjs +412 -0
- package/bizar-dash/src/server/{cline-runner.mjs → claude-runner.mjs} +53 -48
- package/bizar-dash/src/server/claude-sdk.mjs +191 -0
- package/bizar-dash/src/server/providers-store.mjs +22 -49
- package/bizar-dash/src/server/routes/background.mjs +24 -20
- package/bizar-dash/src/server/routes/chat.mjs +223 -326
- package/bizar-dash/src/server/routes/claude-session-detail.mjs +329 -0
- package/bizar-dash/src/server/routes/claude-sessions.mjs +259 -0
- package/bizar-dash/src/server/routes/tasks.mjs +20 -28
- package/bizar-dash/src/server/task-delegator.mjs +76 -77
- package/bizar-dash/src/web/components/chat/useChat.ts +23 -27
- package/bizar-dash/src/web/views/Chat.tsx +8 -8
- package/cli/bin.mjs +31 -18
- package/cli/commands/claude-cmd.mjs +348 -0
- package/cli/commands/install.mjs +12 -11
- package/cli/commands/sandbox.mjs +220 -0
- package/cli/commands/validate.mjs +252 -288
- package/cli/dev-link.test.mjs +11 -11
- package/cli/doctor.mjs +131 -213
- package/cli/install.mjs +33 -37
- package/cli/install.test.mjs +9 -9
- package/cli/provision-claude.mjs +893 -0
- package/cli/provision.mjs +330 -226
- package/cli/provision.test.mjs +102 -0
- package/cli/utils.mjs +72 -58
- package/config/AGENTS.md +28 -28
- package/config/agents/_shared/AGENT_BASELINE.md +104 -684
- package/config/skills/bizar/SKILL.md +197 -0
- package/config/skills/cubesandbox/SKILL.md +148 -0
- package/config/skills/harness-engineering/SKILL.md +142 -0
- package/install.sh +66 -44
- package/package.json +19 -11
- package/packages/sdk/ARCHITECTURE.md +40 -36
- package/packages/sdk/package-lock.json +32 -0
- package/packages/sdk/package.json +15 -5
- package/{plugins/bizar → packages/sdk}/src/dangerous-patterns.ts +6 -28
- package/packages/sdk/src/fingerprint.ts +0 -0
- package/packages/sdk/src/index.ts +38 -73
- package/packages/sdk/src/mcp/bin.ts +51 -0
- package/packages/sdk/src/mcp/server.ts +498 -0
- package/{plugins/bizar/src/memory-vault.ts → packages/sdk/src/memory/index.ts} +18 -21
- package/packages/sdk/tests/sdk.test.mjs +148 -0
- package/packages/sdk/vitest.config.ts +1 -1
- package/scripts/bh-full-e2e.mjs +166 -363
- package/scripts/check-deps.mjs +28 -46
- package/scripts/mirror-claude-md.sh +78 -0
- package/scripts/test-in-container.sh +157 -0
- package/templates/clean-state-checklist.md +3 -3
- package/templates/sprint-contract.md +2 -2
- package/bizar-dash/.bizar/activity.log +0 -3
- package/bizar-dash/.omx/logs/omx-2026-07-07.jsonl +0 -1
- package/bizar-dash/.omx/state/session.json +0 -10
- package/bizar-dash/bizar-design/EXPLANATION.md +0 -307
- package/bizar-dash/bizar-design/INSTRUCTIONS.md +0 -149
- package/bizar-dash/bizar-design/canvas.html +0 -281
- package/bizar-dash/bizar-design/canvas.html.artifact.json +0 -19
- package/bizar-dash/bizar-design/components.css +0 -1665
- package/bizar-dash/bizar-design/components.html +0 -557
- package/bizar-dash/bizar-design/components.html.artifact.json +0 -19
- package/bizar-dash/bizar-design/desktop/agents.html +0 -74
- package/bizar-dash/bizar-design/desktop/agents.html.artifact.json +0 -19
- package/bizar-dash/bizar-design/desktop/memory.html +0 -75
- package/bizar-dash/bizar-design/desktop/memory.html.artifact.json +0 -19
- package/bizar-dash/bizar-design/desktop/metrics.html +0 -63
- package/bizar-dash/bizar-design/desktop/metrics.html.artifact.json +0 -19
- package/bizar-dash/bizar-design/desktop/overview.html +0 -95
- package/bizar-dash/bizar-design/desktop/overview.html.artifact.json +0 -19
- package/bizar-dash/bizar-design/desktop/settings.html +0 -70
- package/bizar-dash/bizar-design/desktop/settings.html.artifact.json +0 -19
- package/bizar-dash/bizar-design/icons.svg +0 -44
- package/bizar-dash/bizar-design/index.html +0 -192
- package/bizar-dash/bizar-design/index.html.artifact.json +0 -19
- package/bizar-dash/bizar-design/mobile/agents.html +0 -29
- package/bizar-dash/bizar-design/mobile/agents.html.artifact.json +0 -19
- package/bizar-dash/bizar-design/mobile/memory.html +0 -28
- package/bizar-dash/bizar-design/mobile/memory.html.artifact.json +0 -19
- package/bizar-dash/bizar-design/mobile/metrics.html +0 -29
- package/bizar-dash/bizar-design/mobile/metrics.html.artifact.json +0 -19
- package/bizar-dash/bizar-design/mobile/overview.html +0 -33
- package/bizar-dash/bizar-design/mobile/overview.html.artifact.json +0 -19
- package/bizar-dash/bizar-design/mobile/settings.html +0 -28
- package/bizar-dash/bizar-design/mobile/settings.html.artifact.json +0 -19
- package/bizar-dash/bizar-design/prototype.js +0 -114
- package/bizar-dash/bizar-design/tokens.css +0 -106
- package/bizar-dash/dist/assets/vendor-CeHGtduv.js.map +0 -1
- package/bizar-dash/node_modules/.package-lock.json +0 -6
- package/bizar-dash/package-lock.json +0 -6
- package/bizar-dash/src/server/cline-sdk.mjs +0 -132
- package/bizar-dash/src/server/routes/cline-session-detail.mjs +0 -559
- package/bizar-dash/src/server/routes/cline-sessions.mjs +0 -291
- package/cli/commands/cline-cmd.mjs +0 -289
- package/cli/commands/validate.test.mjs +0 -344
- package/cli/doctor.test.mjs +0 -350
- package/config/cline.json.template +0 -342
- package/packages/sdk/src/client.ts +0 -188
- package/packages/sdk/src/cline-events.ts +0 -134
- package/packages/sdk/src/cline-types.ts +0 -66
- package/packages/sdk/src/cline.ts +0 -339
- package/packages/sdk/src/errors.ts +0 -129
- package/packages/sdk/src/events.ts +0 -153
- package/packages/sdk/src/types.ts +0 -176
- package/packages/sdk/tests/client.test.ts +0 -217
- package/packages/sdk/tests/errors.test.ts +0 -108
- package/packages/sdk/tests/events.test.ts +0 -139
- package/packages/sdk/tests/fixtures/fetch-mock.ts +0 -152
- package/packages/sdk/tests/fixtures/sse-mock.ts +0 -30
- package/plugins/bizar/ARCHITECTURE.md +0 -142
- package/plugins/bizar/CONSTRAINTS.md +0 -67
- package/plugins/bizar/LICENSE +0 -21
- package/plugins/bizar/README.md +0 -448
- package/plugins/bizar/index.ts +0 -872
- package/plugins/bizar/package.json +0 -39
- package/plugins/bizar/scripts/check-forbidden-imports.sh +0 -33
- package/plugins/bizar/src/background-state.ts +0 -641
- package/plugins/bizar/src/background.ts +0 -1806
- package/plugins/bizar/src/cline-runner.ts +0 -203
- package/plugins/bizar/src/clineruntime.ts +0 -227
- package/plugins/bizar/src/commands-impl.ts +0 -151
- package/plugins/bizar/src/commands.ts +0 -1799
- package/plugins/bizar/src/compaction.d.mts +0 -48
- package/plugins/bizar/src/compaction.mjs +0 -192
- package/plugins/bizar/src/dashboard-client.ts +0 -233
- package/plugins/bizar/src/event-stream.ts +0 -606
- package/plugins/bizar/src/fingerprint.ts +0 -120
- package/plugins/bizar/src/handoff.ts +0 -79
- package/plugins/bizar/src/hooks/memory-flush-on-compact.ts +0 -123
- package/plugins/bizar/src/hooks/memory-inject.ts +0 -247
- package/plugins/bizar/src/hooks/memory-write-on-end.ts +0 -188
- package/plugins/bizar/src/hooks/skill-curator.ts +0 -180
- package/plugins/bizar/src/http-client.ts +0 -467
- package/plugins/bizar/src/key-rotation.ts +0 -218
- package/plugins/bizar/src/logger.ts +0 -144
- package/plugins/bizar/src/loop-engineering.ts +0 -241
- package/plugins/bizar/src/loop.ts +0 -176
- package/plugins/bizar/src/mistake-recovery.ts +0 -98
- package/plugins/bizar/src/odin.ts +0 -227
- package/plugins/bizar/src/options.ts +0 -470
- package/plugins/bizar/src/plan-fs.ts +0 -323
- package/plugins/bizar/src/reasoning-clean.ts +0 -454
- package/plugins/bizar/src/report.ts +0 -178
- package/plugins/bizar/src/research-prompt.ts +0 -35
- package/plugins/bizar/src/serve-info.ts +0 -228
- package/plugins/bizar/src/serve.ts +0 -496
- package/plugins/bizar/src/settings.ts +0 -349
- package/plugins/bizar/src/state.ts +0 -298
- package/plugins/bizar/src/tool-discipline.ts +0 -105
- package/plugins/bizar/src/tools/agent-browser.ts +0 -315
- package/plugins/bizar/src/tools/bg-collect.ts +0 -131
- package/plugins/bizar/src/tools/bg-get-comments.ts +0 -266
- package/plugins/bizar/src/tools/bg-kill.ts +0 -116
- package/plugins/bizar/src/tools/bg-pause.ts +0 -99
- package/plugins/bizar/src/tools/bg-report-progress.ts +0 -115
- package/plugins/bizar/src/tools/bg-resume.ts +0 -94
- package/plugins/bizar/src/tools/bg-send-message.ts +0 -223
- package/plugins/bizar/src/tools/bg-spawn.ts +0 -502
- package/plugins/bizar/src/tools/bg-status.ts +0 -130
- package/plugins/bizar/src/tools/graph-query.ts +0 -278
- package/plugins/bizar/src/tools/loop-engineering.ts +0 -193
- package/plugins/bizar/src/tools/memory-list.ts +0 -43
- package/plugins/bizar/src/tools/memory-read.ts +0 -69
- package/plugins/bizar/src/tools/memory-search.ts +0 -47
- package/plugins/bizar/src/tools/memory-write.ts +0 -54
- package/plugins/bizar/src/tools/open-kb.ts +0 -198
- package/plugins/bizar/src/tools/plan-action.ts +0 -785
- package/plugins/bizar/src/tools/read-glyph-feedback.ts +0 -191
- package/plugins/bizar/src/tools/team-spawn.ts +0 -73
- package/plugins/bizar/src/tools/team-status.ts +0 -76
- package/plugins/bizar/src/tools/wait-for-feedback.ts +0 -415
- package/plugins/bizar/src/trajectory.ts +0 -104
- package/plugins/bizar/tests/README.md +0 -99
- package/plugins/bizar/tests/attach-handler-bug.test.ts +0 -169
- package/plugins/bizar/tests/background-state.test.ts +0 -277
- package/plugins/bizar/tests/background.test.ts +0 -402
- package/plugins/bizar/tests/block.test.ts +0 -195
- package/plugins/bizar/tests/canonical-key-order.test.ts +0 -75
- package/plugins/bizar/tests/clineruntime-config.test.ts +0 -283
- package/plugins/bizar/tests/commands-impl.test.ts +0 -316
- package/plugins/bizar/tests/commands.test.ts +0 -584
- package/plugins/bizar/tests/compaction.test.ts +0 -264
- package/plugins/bizar/tests/config.test.ts +0 -128
- package/plugins/bizar/tests/dashboard-client.test.ts +0 -159
- package/plugins/bizar/tests/dispose.test.ts +0 -336
- package/plugins/bizar/tests/event-stream.test.ts +0 -409
- package/plugins/bizar/tests/event.test.ts +0 -262
- package/plugins/bizar/tests/fingerprint.test.ts +0 -162
- package/plugins/bizar/tests/http-client.test.ts +0 -404
- package/plugins/bizar/tests/init-helpers.test.ts +0 -203
- package/plugins/bizar/tests/integration/slash-command.test.ts +0 -349
- package/plugins/bizar/tests/integration/tool-routing.test.ts +0 -98
- package/plugins/bizar/tests/key-rotation.test.ts +0 -396
- package/plugins/bizar/tests/loop-engineering.test.ts +0 -168
- package/plugins/bizar/tests/loop.test.ts +0 -397
- package/plugins/bizar/tests/memory-write-on-end.test.ts +0 -92
- package/plugins/bizar/tests/mistake-recovery.test.ts +0 -116
- package/plugins/bizar/tests/odin.test.ts +0 -125
- package/plugins/bizar/tests/options.test.ts +0 -329
- package/plugins/bizar/tests/reasoning-clean.test.ts +0 -422
- package/plugins/bizar/tests/safety.test.ts +0 -256
- package/plugins/bizar/tests/serve.test.ts +0 -339
- package/plugins/bizar/tests/settings.test.ts +0 -351
- package/plugins/bizar/tests/stall-think.test.ts +0 -750
- package/plugins/bizar/tests/state.test.ts +0 -276
- package/plugins/bizar/tests/tool-discipline.test.ts +0 -77
- package/plugins/bizar/tests/tools/agent-browser.test.ts +0 -98
- package/plugins/bizar/tests/tools/bg-collect.test.ts +0 -337
- package/plugins/bizar/tests/tools/bg-get-comments.test.ts +0 -485
- package/plugins/bizar/tests/tools/bg-kill.test.ts +0 -235
- package/plugins/bizar/tests/tools/bg-pause.test.ts +0 -61
- package/plugins/bizar/tests/tools/bg-report-progress.test.ts +0 -79
- package/plugins/bizar/tests/tools/bg-resume.test.ts +0 -40
- package/plugins/bizar/tests/tools/bg-send-message.test.ts +0 -116
- package/plugins/bizar/tests/tools/bg-spawn-delegation.test.ts +0 -147
- package/plugins/bizar/tests/tools/bg-spawn-http.test.ts +0 -233
- package/plugins/bizar/tests/tools/bg-spawn.test.ts +0 -311
- package/plugins/bizar/tests/tools/bg-status.test.ts +0 -217
- package/plugins/bizar/tests/tools/cline-runner.test.ts +0 -115
- package/plugins/bizar/tests/tools/plan-action.test.ts +0 -599
- package/plugins/bizar/tests/tools/read-glyph-feedback.test.ts +0 -253
- package/plugins/bizar/tests/tools/wait-for-feedback.test.ts +0 -390
- package/plugins/bizar/tests/update-deadlock.test.ts +0 -151
- package/plugins/bizar/tsconfig.json +0 -29
|
@@ -1,1806 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* background.ts
|
|
3
|
-
*
|
|
4
|
-
* InstanceManager — owns the in-memory map of background instances and
|
|
5
|
-
* orchestrates the per-instance event handlers (v0.4.2 spec §2.2, §4, §5.4, §6.2).
|
|
6
|
-
*
|
|
7
|
-
* Responsibilities:
|
|
8
|
-
* - `add()` is the single entry point for inserting a new instance. The
|
|
9
|
-
* cap check and the map insertion happen inside one async mutex, so
|
|
10
|
-
* concurrent `add()` calls can never exceed the cap (HIGH-10 / HIGH-12 /
|
|
11
|
-
* HIGH-21 / HIGH-38).
|
|
12
|
-
* - `update()` patches the in-memory state and persists to disk. The
|
|
13
|
-
* per-instance mutex from {@link BackgroundStateStore} serializes
|
|
14
|
-
* concurrent updates to the same instance.
|
|
15
|
-
* - `kill()` and `collect()` operate on the in-memory state; the HTTP
|
|
16
|
-
* calls go through {@link HttpClient}.
|
|
17
|
-
* - `rebuildInMemoryMap()` is called on init (spec §5.4). Any in-flight
|
|
18
|
-
* `running` or `pending` instance is marked `failed` because the
|
|
19
|
-
* serve child is new and the cline sessions are gone.
|
|
20
|
-
* - `shutdownAll()` is called on `dispose` / SIGTERM. Marks all in-memory
|
|
21
|
-
* instances `failed` with `error: "plugin shutting down"`, aborts
|
|
22
|
-
* each via `POST /session/{id}/abort` (best-effort, 5s timeout per
|
|
23
|
-
* call), then waits for the serve child to exit.
|
|
24
|
-
*
|
|
25
|
-
* Per-instance event handler (spec §4.1, §4.3, §6.2):
|
|
26
|
-
* - For every `EventMessagePartUpdated` of `type: "tool"`, increment
|
|
27
|
-
* `toolCallCount`. If the count reaches the per-instance cap, abort
|
|
28
|
-
* the session and mark the instance `failed` with
|
|
29
|
-
* `error: "Tool-call cap reached (N). Aborted to prevent cost runaway."`.
|
|
30
|
-
* - If the tool part's error matches the loop-guard regex
|
|
31
|
-
* `Loop protection: 12 identical calls to (\S+)`, capture the tool
|
|
32
|
-
* name into `loopGuardTool`, set `error` to the canonical string,
|
|
33
|
-
* and mark the instance `failed`.
|
|
34
|
-
* - For every `EventMessagePartUpdated` of `type: "text"` on an
|
|
35
|
-
* assistant message, refresh `resultPreview` (last 200 chars).
|
|
36
|
-
* - On `EventSessionIdle`, mark the instance `done`.
|
|
37
|
-
* - On `EventSessionError`, mark the instance `failed` with the error.
|
|
38
|
-
*
|
|
39
|
-
* v0.3.0 — stall and thinking-loop protection:
|
|
40
|
-
* - Every event handler updates `lastEventAt` (the "heartbeat"). The
|
|
41
|
-
* stall checker fires every `STALL_CHECK_INTERVAL_MS`; if a non-terminal
|
|
42
|
-
* instance has `now - lastEventAt > backgroundStallTimeoutMs`, the
|
|
43
|
-
* session is aborted and the instance marked `failed`.
|
|
44
|
-
* - `tool` and `text` parts advance `lastToolOrTextAt`. `thinking`
|
|
45
|
-
* parts do NOT advance it; that is the loop indicator.
|
|
46
|
-
* - The thinking-loop checker fires every `STALL_CHECK_INTERVAL_MS`. For
|
|
47
|
-
* a `running` instance with `now - lastToolOrTextAt >
|
|
48
|
-
* backgroundThinkingLoopTimeoutMs`:
|
|
49
|
-
* - If `interventionCount < backgroundMaxInterventions`: send a
|
|
50
|
-
* research-intervention prompt (fire-and-forget) and increment the
|
|
51
|
-
* counter.
|
|
52
|
-
* - Otherwise: abort the session and mark `failed`.
|
|
53
|
-
* - When a `tool` or `text` part arrives after one or more interventions,
|
|
54
|
-
* the counter is reset to 0 (sign of progress). The intervention
|
|
55
|
-
* metadata is cleared so a later status check does not show stale
|
|
56
|
-
* intervention info.
|
|
57
|
-
*
|
|
58
|
-
* "Track BEFORE HTTP" invariant (spec §2.2 / HIGH-21):
|
|
59
|
-
* - The instance is added to the map (status `pending`) BEFORE any HTTP
|
|
60
|
-
* call. If the HTTP call fails, the instance is marked `failed`. The
|
|
61
|
-
* map is never left in a half-state.
|
|
62
|
-
*/
|
|
63
|
-
|
|
64
|
-
import type {
|
|
65
|
-
BackgroundState,
|
|
66
|
-
BackgroundStateStore,
|
|
67
|
-
Logger,
|
|
68
|
-
ToolCallEntry,
|
|
69
|
-
} from "./background-state.js";
|
|
70
|
-
import {
|
|
71
|
-
MAX_TOOL_CALL_HISTORY,
|
|
72
|
-
MAX_TOOL_ARG_CHARS,
|
|
73
|
-
TERMINAL_STATUSES,
|
|
74
|
-
} from "./background-state.js";
|
|
75
|
-
import type { HttpClient } from "./http-client.js";
|
|
76
|
-
import type { EventStream, StreamEvent, SessionEventHandler } from "./event-stream.js";
|
|
77
|
-
import type { ServeLifecycle } from "./serve.js";
|
|
78
|
-
import { researchInterventionPrompt } from "./research-prompt.js";
|
|
79
|
-
|
|
80
|
-
import * as clineRunner from "./cline-runner.js";
|
|
81
|
-
|
|
82
|
-
// --- v5.5.1 — dashboard HTTP helpers -------------------------------------
|
|
83
|
-
//
|
|
84
|
-
// Background agents run as cline serve SDK sessions managed by the
|
|
85
|
-
// dashboard. The plugin's `pause`/`resume` (and the tool layer's
|
|
86
|
-
// `kill`/`send-message`) therefore talk to the dashboard over HTTP
|
|
87
|
-
// instead of touching the OS or the cline SDK directly.
|
|
88
|
-
//
|
|
89
|
-
// These helpers are intentionally tiny — they only need to do POSTs
|
|
90
|
-
// for `pause`/`resume`. The token + URL resolution matches what
|
|
91
|
-
// `bg-spawn.ts` and `bg-send-message.ts` do.
|
|
92
|
-
|
|
93
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
94
|
-
import { join } from "node:path";
|
|
95
|
-
import { homedir } from "node:os";
|
|
96
|
-
|
|
97
|
-
function resolveDashboardUrl(): string {
|
|
98
|
-
const fromEnv = process.env.BIZAR_DASHBOARD_URL;
|
|
99
|
-
if (fromEnv && fromEnv.trim()) return fromEnv.trim().replace(/\/+$/, "");
|
|
100
|
-
const port = process.env.BIZAR_DASHBOARD_PORT;
|
|
101
|
-
if (port && /^\d+$/.test(port)) return `http://127.0.0.1:${port}`;
|
|
102
|
-
return "http://127.0.0.1:4098";
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const DEFAULT_DASHBOARD_AUTH_PATHS = [
|
|
106
|
-
join(homedir(), ".config", "bizar", "dashboard-secret"),
|
|
107
|
-
join(homedir(), ".cache", "bizarharness", "dash-auth.json"),
|
|
108
|
-
join(homedir(), ".cache", "bizar", "dash-auth.json"),
|
|
109
|
-
];
|
|
110
|
-
|
|
111
|
-
function readDashboardToken(): string {
|
|
112
|
-
for (const candidate of DEFAULT_DASHBOARD_AUTH_PATHS) {
|
|
113
|
-
try {
|
|
114
|
-
if (!existsSync(candidate)) continue;
|
|
115
|
-
const text = readFileSync(candidate, "utf-8").trim();
|
|
116
|
-
if (text && text.length >= 16) return text;
|
|
117
|
-
} catch {
|
|
118
|
-
/* ignore */
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
for (const candidate of DEFAULT_DASHBOARD_AUTH_PATHS) {
|
|
122
|
-
try {
|
|
123
|
-
if (!existsSync(candidate)) continue;
|
|
124
|
-
const parsed = JSON.parse(readFileSync(candidate, "utf-8")) as { password?: unknown };
|
|
125
|
-
if (typeof parsed.password === "string" && parsed.password.length >= 16) {
|
|
126
|
-
return parsed.password;
|
|
127
|
-
}
|
|
128
|
-
} catch {
|
|
129
|
-
/* ignore */
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return "";
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* POST to the dashboard with a JSON `{}` body. Used for pause/resume —
|
|
137
|
-
* no payload, just the URL. Returns `{ ok: true }` on 2xx, `{ ok: false,
|
|
138
|
-
* error }` on transport failure or non-2xx (so the caller can surface
|
|
139
|
-
* a structured error without catching exceptions).
|
|
140
|
-
*/
|
|
141
|
-
async function postDashboardControl(
|
|
142
|
-
path: string,
|
|
143
|
-
logger: Logger,
|
|
144
|
-
): Promise<{ ok: boolean; error?: string }> {
|
|
145
|
-
const url = `${resolveDashboardUrl()}${path}`;
|
|
146
|
-
const token = readDashboardToken();
|
|
147
|
-
const headers: Record<string, string> = {
|
|
148
|
-
"content-type": "application/json",
|
|
149
|
-
accept: "application/json",
|
|
150
|
-
};
|
|
151
|
-
if (token) headers.authorization = `Bearer ${token}`;
|
|
152
|
-
const ac = new AbortController();
|
|
153
|
-
const timer = setTimeout(() => ac.abort(), 5_000);
|
|
154
|
-
try {
|
|
155
|
-
const res = await fetch(url, {
|
|
156
|
-
method: "POST",
|
|
157
|
-
headers,
|
|
158
|
-
body: JSON.stringify({}),
|
|
159
|
-
signal: ac.signal,
|
|
160
|
-
});
|
|
161
|
-
if (!res.ok) {
|
|
162
|
-
const text = await res.text().catch(() => "");
|
|
163
|
-
logger.warn(`bizar: dashboard ${path} returned ${res.status}: ${text.slice(0, 200)}`);
|
|
164
|
-
return { ok: false, error: `dashboard_http_${res.status}` };
|
|
165
|
-
}
|
|
166
|
-
return { ok: true };
|
|
167
|
-
} catch (err) {
|
|
168
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
169
|
-
logger.warn(`bizar: dashboard ${path} failed: ${msg}`);
|
|
170
|
-
return { ok: false, error: msg };
|
|
171
|
-
} finally {
|
|
172
|
-
clearTimeout(timer);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// --- Public surface -------------------------------------------------------
|
|
177
|
-
|
|
178
|
-
/** A snapshot of an instance for the `bizar_status` tool. */
|
|
179
|
-
export interface InstanceView {
|
|
180
|
-
instanceId: string;
|
|
181
|
-
agent: string;
|
|
182
|
-
status: BackgroundState["status"];
|
|
183
|
-
startedAt: number;
|
|
184
|
-
completedAt?: number;
|
|
185
|
-
toolCallCount: number;
|
|
186
|
-
promptPreview: string;
|
|
187
|
-
resultPreview?: string;
|
|
188
|
-
error?: string;
|
|
189
|
-
parentAgent: string;
|
|
190
|
-
parentInstanceId?: string;
|
|
191
|
-
sessionId: string;
|
|
192
|
-
// v0.3.0 — stall and thinking-loop protection
|
|
193
|
-
lastEventAt?: number;
|
|
194
|
-
interventionCount?: number;
|
|
195
|
-
interventionAt?: number;
|
|
196
|
-
interventionReason?: string;
|
|
197
|
-
// v5.x — extended surface for dashboard integration
|
|
198
|
-
/** Tool call history (cap 100). Each entry has name, args, status, timing. */
|
|
199
|
-
toolCalls?: ToolCallEntry[];
|
|
200
|
-
/** Progress percentage (0..100; -1 = indeterminate). */
|
|
201
|
-
progress?: number;
|
|
202
|
-
/** Progress message (free-form). */
|
|
203
|
-
progressMessage?: string;
|
|
204
|
-
/** PID of the cline run subprocess, if known. */
|
|
205
|
-
processId?: number;
|
|
206
|
-
/** Current runner state (one of "starting"|"running"|"done"|"failed"|"killed"). */
|
|
207
|
-
runnerState?: string;
|
|
208
|
-
/** Tags from spawn (free-form). */
|
|
209
|
-
tags?: string[];
|
|
210
|
-
/** When the instance was paused (epoch ms), if paused. */
|
|
211
|
-
pausedAt?: number;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/** The return shape of `bizar_collect`. */
|
|
215
|
-
export interface CollectResult {
|
|
216
|
-
status: BackgroundState["status"];
|
|
217
|
-
result: string;
|
|
218
|
-
toolCallCount: number;
|
|
219
|
-
durationMs: number;
|
|
220
|
-
error?: string;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Filter shape for `list()`. */
|
|
224
|
-
export interface InstanceListFilter {
|
|
225
|
-
agent?: string;
|
|
226
|
-
status?: BackgroundState["status"];
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** Shape passed to `add()`. The status is forced to `pending` and the
|
|
230
|
-
* startedAt is stamped by the manager. */
|
|
231
|
-
export type AddDraft = Omit<BackgroundState, "status" | "startedAt">;
|
|
232
|
-
|
|
233
|
-
/** Return type of `add()`. `"cap_reached"` is a sentinel for the
|
|
234
|
-
* overshoot path; the populated state is the success path. */
|
|
235
|
-
export type AddResult = BackgroundState | "cap_reached";
|
|
236
|
-
|
|
237
|
-
// --- Constants ------------------------------------------------------------
|
|
238
|
-
|
|
239
|
-
/** Maximum length of `resultPreview` per spec §3.2. */
|
|
240
|
-
const RESULT_PREVIEW_MAX = 200;
|
|
241
|
-
/** Maximum length of `promptPreview` stored in the JSON. */
|
|
242
|
-
const PROMPT_PREVIEW_MAX = 200;
|
|
243
|
-
/** Tool-call cap regex (spec §4.1, NEW-H8 pin). */
|
|
244
|
-
const LOOP_GUARD_RE = /Loop protection: 12 identical calls to (\S+)/;
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* How often the stall + thinking-loop checker fires. 15 seconds is short
|
|
248
|
-
* enough to detect stalls within one tick of the default 3-min stall
|
|
249
|
-
* timeout, and long enough that the per-instance mutex is not constantly
|
|
250
|
-
* contested. Spec §v0.3.0.
|
|
251
|
-
*/
|
|
252
|
-
const STALL_CHECK_INTERVAL_MS = 15_000;
|
|
253
|
-
|
|
254
|
-
// --- Class ---------------------------------------------------------------
|
|
255
|
-
|
|
256
|
-
/**
|
|
257
|
-
* Manages the in-memory map of background instances. Created once at
|
|
258
|
-
* plugin init; lives for the life of the plugin process.
|
|
259
|
-
*/
|
|
260
|
-
export class InstanceManager {
|
|
261
|
-
private instances = new Map<string, BackgroundState>();
|
|
262
|
-
private eventUnsubscribers = new Map<string, () => void>();
|
|
263
|
-
private addLock: Promise<unknown> = Promise.resolve();
|
|
264
|
-
private stateStore: BackgroundStateStore;
|
|
265
|
-
private maxConcurrent: number;
|
|
266
|
-
private toolCallCap: number;
|
|
267
|
-
private logger: Logger;
|
|
268
|
-
// v0.8.0 — `serve`, `http`, `stream` are nullable to support the
|
|
269
|
-
// bg-only mode used when the cline serve child is unavailable
|
|
270
|
-
// (BIZAR_SERVE_DISABLE=1, startup failure) or when this process IS a
|
|
271
|
-
// bg-spawned `cline run` subprocess. In bg-only mode, every method
|
|
272
|
-
// that would otherwise call `this.http.X` or `this.stream.X` is a
|
|
273
|
-
// no-op; state transitions still happen via the runner's `onExit`
|
|
274
|
-
// callback (see src/tools/bg-spawn.ts).
|
|
275
|
-
private serve: ServeLifecycle | null;
|
|
276
|
-
private http: HttpClient | null;
|
|
277
|
-
private stream: EventStream | null;
|
|
278
|
-
private worktree: string;
|
|
279
|
-
// v0.3.0 — stall and thinking-loop protection
|
|
280
|
-
private stallTimeoutMs: number;
|
|
281
|
-
private thinkingLoopTimeoutMs: number;
|
|
282
|
-
private maxInterventions: number;
|
|
283
|
-
/** Interval handle for the periodic stall + thinking-loop checker. */
|
|
284
|
-
private stallCheckerTimer: ReturnType<typeof setInterval> | null = null;
|
|
285
|
-
/** Guard so tests can disable the interval without monkey-patching. */
|
|
286
|
-
private stallCheckerDisabled = false;
|
|
287
|
-
|
|
288
|
-
constructor(opts: {
|
|
289
|
-
stateStore: BackgroundStateStore;
|
|
290
|
-
maxConcurrent: number;
|
|
291
|
-
toolCallCap: number;
|
|
292
|
-
logger: Logger;
|
|
293
|
-
// v0.8.0 — `worktree` is now an explicit param (was previously
|
|
294
|
-
// derived from `opts.serve.worktree`, which is impossible when
|
|
295
|
-
// `serve` is null in bg-only mode).
|
|
296
|
-
worktree: string;
|
|
297
|
-
serve: ServeLifecycle | null;
|
|
298
|
-
http: HttpClient | null;
|
|
299
|
-
stream: EventStream | null;
|
|
300
|
-
// v0.3.0
|
|
301
|
-
stallTimeoutMs?: number;
|
|
302
|
-
thinkingLoopTimeoutMs?: number;
|
|
303
|
-
maxInterventions?: number;
|
|
304
|
-
}) {
|
|
305
|
-
this.stateStore = opts.stateStore;
|
|
306
|
-
this.maxConcurrent = Math.max(1, Math.floor(opts.maxConcurrent));
|
|
307
|
-
this.toolCallCap = Math.max(1, Math.floor(opts.toolCallCap));
|
|
308
|
-
this.logger = opts.logger;
|
|
309
|
-
this.serve = opts.serve;
|
|
310
|
-
this.http = opts.http;
|
|
311
|
-
this.stream = opts.stream;
|
|
312
|
-
this.worktree = opts.worktree;
|
|
313
|
-
this.stallTimeoutMs = Math.max(
|
|
314
|
-
1_000,
|
|
315
|
-
Math.floor(opts.stallTimeoutMs ?? 180_000),
|
|
316
|
-
);
|
|
317
|
-
this.thinkingLoopTimeoutMs = Math.max(
|
|
318
|
-
1_000,
|
|
319
|
-
Math.floor(opts.thinkingLoopTimeoutMs ?? 300_000),
|
|
320
|
-
);
|
|
321
|
-
this.maxInterventions = Math.max(1, Math.floor(opts.maxInterventions ?? 1));
|
|
322
|
-
// Schedule the periodic stall + thinking-loop checker ONLY when we
|
|
323
|
-
// have a working HTTP client. In bg-only mode the runner owns the
|
|
324
|
-
// subprocess lifecycle, so the checker has nothing to do.
|
|
325
|
-
if (this.http !== null) {
|
|
326
|
-
this.stallCheckerTimer = setInterval(
|
|
327
|
-
() => void this.runStallAndLoopChecks(),
|
|
328
|
-
STALL_CHECK_INTERVAL_MS,
|
|
329
|
-
);
|
|
330
|
-
this.stallCheckerTimer.unref?.();
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/** True iff the manager was constructed without an HTTP client (no
|
|
335
|
-
* cline serve child reachable). HTTP-dependent operations are
|
|
336
|
-
* no-ops in this mode. */
|
|
337
|
-
get isBgOnly(): boolean {
|
|
338
|
-
return this.http === null;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// --- Getters ------------------------------------------------------------
|
|
342
|
-
|
|
343
|
-
get size(): number {
|
|
344
|
-
return this.instances.size;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/** Current stall timeout (ms). Exposed for tests. */
|
|
348
|
-
get stallTimeoutMsValue(): number {
|
|
349
|
-
return this.stallTimeoutMs;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/** Current thinking-loop timeout (ms). Exposed for tests. */
|
|
353
|
-
get thinkingLoopTimeoutMsValue(): number {
|
|
354
|
-
return this.thinkingLoopTimeoutMs;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** Current max interventions. Exposed for tests. */
|
|
358
|
-
get maxInterventionsValue(): number {
|
|
359
|
-
return this.maxInterventions;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Disable the periodic stall + thinking-loop checker. Used by tests
|
|
364
|
-
* that want to call `runStallAndLoopChecks()` directly without racing
|
|
365
|
-
* the interval. Idempotent.
|
|
366
|
-
*/
|
|
367
|
-
disablePeriodicChecks(): void {
|
|
368
|
-
this.stallCheckerDisabled = true;
|
|
369
|
-
if (this.stallCheckerTimer !== null) {
|
|
370
|
-
clearInterval(this.stallCheckerTimer);
|
|
371
|
-
this.stallCheckerTimer = null;
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* Run one iteration of the stall + thinking-loop checker. Public so
|
|
377
|
-
* tests can invoke it deterministically. Production code drives this
|
|
378
|
-
* via the `setInterval` registered in the constructor.
|
|
379
|
-
*/
|
|
380
|
-
async runStallAndLoopChecks(): Promise<void> {
|
|
381
|
-
if (this.stallCheckerDisabled) return;
|
|
382
|
-
// v0.8.0 — bg-only mode has no HTTP client, so stall and
|
|
383
|
-
// intervention are no-ops. The constructor also skips registering
|
|
384
|
-
// the interval in this mode, but a stray call from a test or future
|
|
385
|
-
// caller must still be safe.
|
|
386
|
-
if (this.http === null) return;
|
|
387
|
-
// Snapshot the instance ids so we do not iterate while the map mutates.
|
|
388
|
-
const ids: string[] = [];
|
|
389
|
-
for (const inst of this.instances.values()) {
|
|
390
|
-
if (TERMINAL_STATUSES.has(inst.status)) continue;
|
|
391
|
-
ids.push(inst.instanceId);
|
|
392
|
-
}
|
|
393
|
-
for (const id of ids) {
|
|
394
|
-
const inst = this.instances.get(id);
|
|
395
|
-
if (!inst || TERMINAL_STATUSES.has(inst.status)) continue;
|
|
396
|
-
const now = Date.now();
|
|
397
|
-
// `lastEventAt` / `lastToolOrTextAt` are seeded by `add()` and
|
|
398
|
-
// backfilled in `readState`, so they are guaranteed to be set on
|
|
399
|
-
// any instance that ever reached this method. We coalesce with
|
|
400
|
-
// `?? 0` because TS strict mode treats the schema field as
|
|
401
|
-
// optional — the value is informational in the rare case where
|
|
402
|
-
// it is missing (an old or corrupt state file).
|
|
403
|
-
const lastEventAt = inst.lastEventAt ?? 0;
|
|
404
|
-
const lastToolOrTextAt = inst.lastToolOrTextAt ?? 0;
|
|
405
|
-
// Stall check fires first; it is the more severe failure.
|
|
406
|
-
if (now - lastEventAt > this.stallTimeoutMs) {
|
|
407
|
-
await this._abortAsStalled(inst);
|
|
408
|
-
continue;
|
|
409
|
-
}
|
|
410
|
-
// Thinking-loop check applies to `running` instances only. A
|
|
411
|
-
// `pending` instance has not yet started generating, so it is not
|
|
412
|
-
// a candidate for the loop detector.
|
|
413
|
-
if (inst.status === "running") {
|
|
414
|
-
const since = now - lastToolOrTextAt;
|
|
415
|
-
if (since > this.thinkingLoopTimeoutMs) {
|
|
416
|
-
const currentCount = inst.interventionCount ?? 0;
|
|
417
|
-
if (currentCount < this.maxInterventions) {
|
|
418
|
-
await this._sendIntervention(inst, since);
|
|
419
|
-
} else {
|
|
420
|
-
await this._abortAsThinkingLoop(inst, since);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
// --- Internal dispatch (shared by add() and restart()) ------------------
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* Insert a fully-constructed BackgroundState into the in-memory map
|
|
431
|
-
* and persist to disk asynchronously. Does NOT check the concurrency
|
|
432
|
-
* cap — the caller handles that.
|
|
433
|
-
*
|
|
434
|
-
* This is extracted from `add()` so `restart()` can reuse the same
|
|
435
|
-
* insert-and-persist path without duplicating the logic.
|
|
436
|
-
*/
|
|
437
|
-
private dispatchInternal(full: BackgroundState): BackgroundState {
|
|
438
|
-
this.instances.set(full.instanceId, full);
|
|
439
|
-
// Persist asynchronously; failure is logged but does not roll back
|
|
440
|
-
// the in-memory insert (the instance is "tracked" either way).
|
|
441
|
-
this.stateStore.save(full).catch((err: unknown) => {
|
|
442
|
-
this.logger.warn(
|
|
443
|
-
`bizar: failed to persist new instance ${full.instanceId}: ${
|
|
444
|
-
err instanceof Error ? err.message : String(err)
|
|
445
|
-
}`,
|
|
446
|
-
);
|
|
447
|
-
});
|
|
448
|
-
return full;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
// --- Atomic add (spec §2.2) ---------------------------------------------
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* Add a new instance. The cap check and the map insertion are inside
|
|
455
|
-
* one async mutex — no half-state on overshoot. Returns `"cap_reached"`
|
|
456
|
-
* on overshoot; the full `BackgroundState` on success.
|
|
457
|
-
*/
|
|
458
|
-
async add(draft: AddDraft): Promise<AddResult> {
|
|
459
|
-
return (await (this.addLock = this.addLock.then(async () => {
|
|
460
|
-
// Count "live" instances: anything not yet terminal.
|
|
461
|
-
let live = 0;
|
|
462
|
-
for (const inst of this.instances.values()) {
|
|
463
|
-
if (!TERMINAL_STATUSES.has(inst.status)) live += 1;
|
|
464
|
-
}
|
|
465
|
-
if (live >= this.maxConcurrent) {
|
|
466
|
-
this.logger.warn(
|
|
467
|
-
`bizar: max concurrent instances reached (${this.maxConcurrent}); rejecting add`,
|
|
468
|
-
);
|
|
469
|
-
return "cap_reached" as const;
|
|
470
|
-
}
|
|
471
|
-
const now = Date.now();
|
|
472
|
-
const full: BackgroundState = {
|
|
473
|
-
...draft,
|
|
474
|
-
status: "pending",
|
|
475
|
-
startedAt: now,
|
|
476
|
-
toolCallCount: draft.toolCallCount ?? 0,
|
|
477
|
-
// Trim the prompt preview so the JSON stays small.
|
|
478
|
-
promptPreview: (draft.promptPreview ?? "").slice(0, PROMPT_PREVIEW_MAX),
|
|
479
|
-
// v0.3.0 — seed the liveness timestamps so the stall and
|
|
480
|
-
// thinking-loop checkers have a baseline. We seed BOTH from
|
|
481
|
-
// `startedAt` so a freshly-spawned instance is not immediately
|
|
482
|
-
// flagged as stalled while the session is still being created
|
|
483
|
-
// (the first event typically arrives within seconds).
|
|
484
|
-
lastEventAt: now,
|
|
485
|
-
lastToolOrTextAt: now,
|
|
486
|
-
interventionCount: 0,
|
|
487
|
-
};
|
|
488
|
-
this.dispatchInternal(full);
|
|
489
|
-
// BUGFIX (v0.5.1): Do NOT call attachEventHandler() here. The
|
|
490
|
-
// instance was just added with sessionId="" (filled in later by
|
|
491
|
-
// POST /session). EventStream.onSessionEvent rejects empty strings,
|
|
492
|
-
// so attaching here threw and the spawn failed before the HTTP
|
|
493
|
-
// call could run. Callers must call attachEventHandler() explicitly
|
|
494
|
-
// after the real sessionId is known. See test in
|
|
495
|
-
// tests/background.test.ts "add() does not attach event handler
|
|
496
|
-
// (empty sessionId)".
|
|
497
|
-
return full;
|
|
498
|
-
}))) as AddResult;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// --- Read access --------------------------------------------------------
|
|
502
|
-
|
|
503
|
-
/**
|
|
504
|
-
* Look up an instance by id. Returns null if not found.
|
|
505
|
-
*/
|
|
506
|
-
async get(instanceId: string): Promise<BackgroundState | null> {
|
|
507
|
-
return this.instances.get(instanceId) ?? null;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Snapshot of in-memory instances, filtered. Used by `bizar_status`.
|
|
512
|
-
*/
|
|
513
|
-
async list(filter?: InstanceListFilter): Promise<InstanceView[]> {
|
|
514
|
-
const out: InstanceView[] = [];
|
|
515
|
-
for (const inst of this.instances.values()) {
|
|
516
|
-
if (filter?.agent && inst.agent !== filter.agent) continue;
|
|
517
|
-
if (filter?.status && inst.status !== filter.status) continue;
|
|
518
|
-
out.push(toView(inst));
|
|
519
|
-
}
|
|
520
|
-
// Sort by startedAt ascending so callers see the oldest first.
|
|
521
|
-
out.sort((a, b) => a.startedAt - b.startedAt);
|
|
522
|
-
return out;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// --- Update -------------------------------------------------------------
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* Patch an instance in-memory and persist. Returns silently if the
|
|
529
|
-
* instance is not found. Mutations that would set a terminal state
|
|
530
|
-
* stamp `completedAt` automatically.
|
|
531
|
-
*/
|
|
532
|
-
async update(instanceId: string, patch: Partial<BackgroundState>): Promise<void> {
|
|
533
|
-
const inst = this.instances.get(instanceId);
|
|
534
|
-
if (!inst) return;
|
|
535
|
-
await this.stateStore.withLock(instanceId, async () => {
|
|
536
|
-
const current = this.instances.get(instanceId);
|
|
537
|
-
if (!current) return;
|
|
538
|
-
Object.assign(current, patch);
|
|
539
|
-
if (TERMINAL_STATUSES.has(patch.status ?? current.status) && !current.completedAt) {
|
|
540
|
-
current.completedAt = Date.now();
|
|
541
|
-
}
|
|
542
|
-
if (TERMINAL_STATUSES.has(current.status)) {
|
|
543
|
-
this.detachEventHandler(instanceId);
|
|
544
|
-
}
|
|
545
|
-
try {
|
|
546
|
-
await this.stateStore.saveUnlocked(current);
|
|
547
|
-
} catch (err: unknown) {
|
|
548
|
-
this.logger.warn(
|
|
549
|
-
`bizar: failed to persist update for ${instanceId}: ${
|
|
550
|
-
err instanceof Error ? err.message : String(err)
|
|
551
|
-
}`,
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// --- Kill ---------------------------------------------------------------
|
|
558
|
-
|
|
559
|
-
/**
|
|
560
|
-
* Abort the cline session and mark the instance `killed`. If the
|
|
561
|
-
* instance is already in a terminal state, this is a no-op (spec §1.5,
|
|
562
|
-
* MEDIUM-40).
|
|
563
|
-
*
|
|
564
|
-
* v5.5.1 — SDK-backed instances are killed via `DELETE /api/background/:id`
|
|
565
|
-
* on the dashboard (which calls `sdk.sessions.abort`). Pre-v5.5.1
|
|
566
|
-
* fallback: bg-only mode marks the instance killed in-memory and lets
|
|
567
|
-
* the runner notice; full mode (with HTTP client) calls
|
|
568
|
-
* `http.abortSession` directly. The two paths are now functionally
|
|
569
|
-
* equivalent — the dashboard IS the SDK client in v5.5.1.
|
|
570
|
-
*/
|
|
571
|
-
async kill(instanceId: string): Promise<void> {
|
|
572
|
-
const inst = this.instances.get(instanceId);
|
|
573
|
-
if (!inst) return;
|
|
574
|
-
if (TERMINAL_STATUSES.has(inst.status)) {
|
|
575
|
-
this.logger.debug(
|
|
576
|
-
`bizar: kill(${instanceId}) is a no-op (status=${inst.status})`,
|
|
577
|
-
);
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
// v5.5.1 — SDK-backed: ask the dashboard to abort.
|
|
581
|
-
if (inst.liveSession) {
|
|
582
|
-
const url = `${resolveDashboardUrl()}/api/background/${encodeURIComponent(instanceId)}`;
|
|
583
|
-
const token = readDashboardToken();
|
|
584
|
-
const headers: Record<string, string> = { accept: "application/json" };
|
|
585
|
-
if (token) headers.authorization = `Bearer ${token}`;
|
|
586
|
-
const ac = new AbortController();
|
|
587
|
-
const timer = setTimeout(() => ac.abort(), 5_000);
|
|
588
|
-
try {
|
|
589
|
-
await fetch(url, { method: "DELETE", headers, signal: ac.signal });
|
|
590
|
-
} catch (err) {
|
|
591
|
-
this.logger.warn(
|
|
592
|
-
`bizar: kill(${instanceId}) dashboard DELETE failed: ${
|
|
593
|
-
err instanceof Error ? err.message : String(err)
|
|
594
|
-
}`,
|
|
595
|
-
);
|
|
596
|
-
} finally {
|
|
597
|
-
clearTimeout(timer);
|
|
598
|
-
}
|
|
599
|
-
await this.update(instanceId, {
|
|
600
|
-
status: "killed",
|
|
601
|
-
completedAt: Date.now(),
|
|
602
|
-
});
|
|
603
|
-
this.logger.info(
|
|
604
|
-
`bizar: killed background instance ${instanceId} (sdk-backed, dashboard DELETE)`,
|
|
605
|
-
);
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
// v0.8.0 — bg-only mode has no HTTP client. The subprocess is
|
|
609
|
-
// already owned by cline-runner.ts (see src/tools/bg-spawn.ts);
|
|
610
|
-
// mark the instance killed in-memory and let the runner notice the
|
|
611
|
-
// status change when the process eventually exits. We do NOT try
|
|
612
|
-
// to kill the OS process from here — that's the runner's job and
|
|
613
|
-
// we don't have a clean processId reference in bg-only mode
|
|
614
|
-
// (the runner does, but it lives in a separate module).
|
|
615
|
-
if (this.http === null) {
|
|
616
|
-
this.logger.warn(
|
|
617
|
-
`bizar: kill(${instanceId}) in bg-only mode: marking killed; subprocess will be reaped by cline-runner.ts on exit`,
|
|
618
|
-
);
|
|
619
|
-
await this.update(instanceId, {
|
|
620
|
-
status: "killed",
|
|
621
|
-
completedAt: Date.now(),
|
|
622
|
-
});
|
|
623
|
-
return;
|
|
624
|
-
}
|
|
625
|
-
// Pre-v5.5.1 fallback — direct abort on the legacy serve child.
|
|
626
|
-
const abort = await this.http.abortSession(inst.sessionId, this.worktree);
|
|
627
|
-
if (!abort.ok) {
|
|
628
|
-
this.logger.warn(
|
|
629
|
-
`bizar: kill(${instanceId}): abort failed: ${abort.error}`,
|
|
630
|
-
);
|
|
631
|
-
}
|
|
632
|
-
await this.update(instanceId, {
|
|
633
|
-
status: "killed",
|
|
634
|
-
completedAt: Date.now(),
|
|
635
|
-
});
|
|
636
|
-
this.logger.info(`bizar: killed background instance ${instanceId}`);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// --- Restart (v0.5.5 — persistent auto-restart) ------------------------
|
|
640
|
-
|
|
641
|
-
/**
|
|
642
|
-
* Re-spawn a failed persistent instance with the same prompt, agent,
|
|
643
|
-
* and model config. The new instance gets a fresh `instanceId` and is
|
|
644
|
-
* linked to the original via `parentInstanceId`.
|
|
645
|
-
*
|
|
646
|
-
* Returns `{ ok: true, newInstanceId }` on success, or
|
|
647
|
-
* `{ ok: false, error: "..." }` on failure. Does NOT check the
|
|
648
|
-
* concurrency cap — the original instance is terminal, so its slot
|
|
649
|
-
* is already freed.
|
|
650
|
-
*/
|
|
651
|
-
async restart(instanceId: string): Promise<{
|
|
652
|
-
ok: boolean;
|
|
653
|
-
newInstanceId?: string;
|
|
654
|
-
error?: string;
|
|
655
|
-
}> {
|
|
656
|
-
const existing = this.instances.get(instanceId);
|
|
657
|
-
if (!existing) return { ok: false, error: "instance_not_found" };
|
|
658
|
-
const totalRestarts = this.getTotalRestartCount(instanceId);
|
|
659
|
-
if (totalRestarts >= (existing.maxRestarts ?? 3)) {
|
|
660
|
-
return { ok: false, error: "max_restarts_reached" };
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
const now = Date.now();
|
|
664
|
-
const newInstanceId = generateInstanceId();
|
|
665
|
-
const full: BackgroundState = {
|
|
666
|
-
instanceId: newInstanceId,
|
|
667
|
-
sessionId: "",
|
|
668
|
-
agent: existing.agent,
|
|
669
|
-
model: existing.model,
|
|
670
|
-
promptPreview: (existing.prompt ?? existing.promptPreview ?? "").slice(0, PROMPT_PREVIEW_MAX),
|
|
671
|
-
prompt: existing.prompt,
|
|
672
|
-
parentAgent: existing.parentAgent,
|
|
673
|
-
parentInstanceId: instanceId,
|
|
674
|
-
logPath: `${this.worktree}/.cline/log/${newInstanceId}.log`,
|
|
675
|
-
timeoutMs: existing.timeoutMs,
|
|
676
|
-
toolCallCount: 0,
|
|
677
|
-
// v0.5.5 — persist the auto-restart fields
|
|
678
|
-
persistent: existing.persistent,
|
|
679
|
-
maxRestarts: existing.maxRestarts,
|
|
680
|
-
restartCount: (existing.restartCount ?? 0) + 1,
|
|
681
|
-
status: "pending",
|
|
682
|
-
startedAt: now,
|
|
683
|
-
lastEventAt: now,
|
|
684
|
-
lastToolOrTextAt: now,
|
|
685
|
-
interventionCount: 0,
|
|
686
|
-
};
|
|
687
|
-
|
|
688
|
-
this.dispatchInternal(full);
|
|
689
|
-
this.logger.info(
|
|
690
|
-
`bizar: restarted instance ${instanceId} as ${newInstanceId} (restart #${full.restartCount})`,
|
|
691
|
-
);
|
|
692
|
-
return { ok: true, newInstanceId };
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
/**
|
|
696
|
-
* Walk the parent chain to compute the true restart count for this
|
|
697
|
-
* instance (Forseti C4). `restartCount` alone only reflects the child's
|
|
698
|
-
* own counter, so a chain of restarts can exceed `maxRestarts`. This
|
|
699
|
-
* helper sums the counters across the entire chain (parent + all
|
|
700
|
-
* descendants) and returns the total.
|
|
701
|
-
*/
|
|
702
|
-
private getTotalRestartCount(instanceId: string): number {
|
|
703
|
-
let current = this.instances.get(instanceId);
|
|
704
|
-
if (!current) return 0;
|
|
705
|
-
let count = current.restartCount ?? 0;
|
|
706
|
-
let parentId = current.parentInstanceId;
|
|
707
|
-
const visited = new Set<string>([instanceId]);
|
|
708
|
-
while (parentId && !visited.has(parentId)) {
|
|
709
|
-
visited.add(parentId);
|
|
710
|
-
const parent = this.instances.get(parentId);
|
|
711
|
-
if (!parent) break;
|
|
712
|
-
count += parent.restartCount ?? 0;
|
|
713
|
-
parentId = parent.parentInstanceId;
|
|
714
|
-
}
|
|
715
|
-
return count;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
// --- Collect ------------------------------------------------------------
|
|
719
|
-
|
|
720
|
-
/**
|
|
721
|
-
* Wait for an instance to reach a terminal status, or until `deadline`
|
|
722
|
-
* (ms epoch) is reached. Returns `true` on terminal, `false` on
|
|
723
|
-
* timeout.
|
|
724
|
-
*
|
|
725
|
-
* Two implementations:
|
|
726
|
-
* - **With HTTP+stream** (full mode): subscribe to the SSE session
|
|
727
|
-
* event stream AND poll the in-memory map. SSE gives sub-second
|
|
728
|
-
* resolution; the in-memory check covers terminal states we set
|
|
729
|
-
* ourselves (tool-call cap, loop guard, intervention abort).
|
|
730
|
-
* - **Bg-only mode** (no HTTP, no SSE): poll the in-memory map
|
|
731
|
-
* every 500 ms. Terminal transitions come from the runner's
|
|
732
|
-
* `onExit` callback (see src/tools/bg-spawn.ts) which updates
|
|
733
|
-
* the instance state directly.
|
|
734
|
-
*/
|
|
735
|
-
private async waitForTerminal(
|
|
736
|
-
instanceId: string,
|
|
737
|
-
deadline: number,
|
|
738
|
-
): Promise<boolean> {
|
|
739
|
-
// Already terminal?
|
|
740
|
-
const initial = this.instances.get(instanceId);
|
|
741
|
-
if (initial && TERMINAL_STATUSES.has(initial.status)) return true;
|
|
742
|
-
|
|
743
|
-
if (this.stream === null || initial === undefined) {
|
|
744
|
-
// Bg-only path: poll the in-memory map.
|
|
745
|
-
const POLL_MS = 500;
|
|
746
|
-
while (Date.now() < deadline) {
|
|
747
|
-
await new Promise<void>((resolve) => setTimeout(resolve, POLL_MS));
|
|
748
|
-
const cur = this.instances.get(instanceId);
|
|
749
|
-
if (!cur) return false;
|
|
750
|
-
if (TERMINAL_STATUSES.has(cur.status)) return true;
|
|
751
|
-
}
|
|
752
|
-
return false;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
// Full path: subscribe to the session event stream AND observe our
|
|
756
|
-
// own in-memory state changes for terminal transitions we set
|
|
757
|
-
// ourselves (tool-cap, loop guard, intervention abort).
|
|
758
|
-
return await new Promise<boolean>((resolve) => {
|
|
759
|
-
const remaining = Math.max(0, deadline - Date.now());
|
|
760
|
-
if (remaining === 0) {
|
|
761
|
-
resolve(false);
|
|
762
|
-
return;
|
|
763
|
-
}
|
|
764
|
-
const timer = setTimeout(() => {
|
|
765
|
-
unsubscribe();
|
|
766
|
-
resolve(false);
|
|
767
|
-
}, remaining);
|
|
768
|
-
const unsubscribe = this.stream!.onSessionEvent(
|
|
769
|
-
initial.sessionId,
|
|
770
|
-
(ev) => {
|
|
771
|
-
if (ev.type === "session.idle" || ev.type === "session.error") {
|
|
772
|
-
clearTimeout(timer);
|
|
773
|
-
unsubscribe();
|
|
774
|
-
resolve(true);
|
|
775
|
-
return;
|
|
776
|
-
}
|
|
777
|
-
const cur = this.instances.get(instanceId);
|
|
778
|
-
if (cur && TERMINAL_STATUSES.has(cur.status)) {
|
|
779
|
-
clearTimeout(timer);
|
|
780
|
-
unsubscribe();
|
|
781
|
-
resolve(true);
|
|
782
|
-
}
|
|
783
|
-
},
|
|
784
|
-
);
|
|
785
|
-
const cur = this.instances.get(instanceId);
|
|
786
|
-
if (cur && TERMINAL_STATUSES.has(cur.status)) {
|
|
787
|
-
clearTimeout(timer);
|
|
788
|
-
unsubscribe();
|
|
789
|
-
resolve(true);
|
|
790
|
-
}
|
|
791
|
-
});
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
/**
|
|
795
|
-
* Wait for the instance to reach a terminal state (or until
|
|
796
|
-
* `timeoutMs` elapses), then build the result string per spec §4.4.
|
|
797
|
-
*
|
|
798
|
-
* If the instance is already terminal on entry, we skip the wait and
|
|
799
|
-
* go straight to result construction.
|
|
800
|
-
*/
|
|
801
|
-
async collect(instanceId: string, timeoutMs: number): Promise<CollectResult> {
|
|
802
|
-
const inst = this.instances.get(instanceId);
|
|
803
|
-
if (!inst) {
|
|
804
|
-
throw new Error(`collect: instance ${instanceId} not found`);
|
|
805
|
-
}
|
|
806
|
-
const startedAt = inst.startedAt;
|
|
807
|
-
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
808
|
-
|
|
809
|
-
// 1. Wait for terminal state.
|
|
810
|
-
if (!TERMINAL_STATUSES.has(inst.status)) {
|
|
811
|
-
const reachedTerminal = await this.waitForTerminal(instanceId, deadline);
|
|
812
|
-
if (!reachedTerminal) {
|
|
813
|
-
// Timed out. Return what we have.
|
|
814
|
-
const final = this.instances.get(instanceId);
|
|
815
|
-
if (final && !TERMINAL_STATUSES.has(final.status)) {
|
|
816
|
-
await this.update(instanceId, {
|
|
817
|
-
status: "timed_out",
|
|
818
|
-
completedAt: Date.now(),
|
|
819
|
-
});
|
|
820
|
-
}
|
|
821
|
-
const dur = Date.now() - startedAt;
|
|
822
|
-
const final2 = this.instances.get(instanceId);
|
|
823
|
-
const out: CollectResult = {
|
|
824
|
-
status: final2?.status ?? "timed_out",
|
|
825
|
-
result: final2?.resultPreview ?? "",
|
|
826
|
-
toolCallCount: final2?.toolCallCount ?? 0,
|
|
827
|
-
durationMs: dur,
|
|
828
|
-
error: `collect timed out after ${timeoutMs}ms`,
|
|
829
|
-
};
|
|
830
|
-
return out;
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
// 2. Build the result. Fetch messages from the cline server and
|
|
835
|
-
// concatenate the assistant text parts. In bg-only mode there is
|
|
836
|
-
// no HTTP client to ask, so we fall back to whatever
|
|
837
|
-
// `resultPreview` was captured during the run (often empty
|
|
838
|
-
// because there is no SSE stream in bg-only mode — the runner
|
|
839
|
-
// writes the raw output to the log file instead).
|
|
840
|
-
const final = this.instances.get(instanceId);
|
|
841
|
-
if (!final) {
|
|
842
|
-
throw new Error(`collect: instance ${instanceId} disappeared`);
|
|
843
|
-
}
|
|
844
|
-
const resultText = await this.buildResultText(final);
|
|
845
|
-
const dur = (final.completedAt ?? Date.now()) - startedAt;
|
|
846
|
-
const out: CollectResult = {
|
|
847
|
-
status: final.status,
|
|
848
|
-
result: resultText,
|
|
849
|
-
toolCallCount: final.toolCallCount,
|
|
850
|
-
durationMs: dur,
|
|
851
|
-
};
|
|
852
|
-
if (final.error !== undefined) out.error = final.error;
|
|
853
|
-
return out;
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
// --- Rebuild on init (spec §5.4) ----------------------------------------
|
|
857
|
-
|
|
858
|
-
/**
|
|
859
|
-
* Scan the bg directory, load every instance, and rebuild the in-memory
|
|
860
|
-
* map. Any `running` or `pending` instance is checked for liveness:
|
|
861
|
-
*
|
|
862
|
-
* - v5.5.1: bg instances no longer have OS subprocesses — they live
|
|
863
|
-
* as cline serve SDK sessions on the dashboard. `clineRunner.
|
|
864
|
-
* isAlive(pid)` always returns `false`, so the adoption branch is
|
|
865
|
-
* effectively dead code; we keep it for the rare case a state file
|
|
866
|
-
* from v5.5.0 (with a real pid) survives an upgrade. We mark the
|
|
867
|
-
* instance `failed` with reason `"v5.5.1 session reconciliation
|
|
868
|
-
* pending"` so the operator knows to look at the dashboard; the
|
|
869
|
-
* dashboard's own SSE bridge will reconcile.
|
|
870
|
-
* - v5.5.0 and earlier: a still-alive PID re-adopts as before.
|
|
871
|
-
*
|
|
872
|
-
* Historical terminal records (done, failed, killed, timed_out, steered)
|
|
873
|
-
* are preserved as-is.
|
|
874
|
-
*/
|
|
875
|
-
async rebuildInMemoryMap(): Promise<void> {
|
|
876
|
-
let all: BackgroundState[];
|
|
877
|
-
try {
|
|
878
|
-
all = await this.stateStore.list();
|
|
879
|
-
} catch (err: unknown) {
|
|
880
|
-
this.logger.warn(
|
|
881
|
-
`bizar: rebuildInMemoryMap: list() failed: ${
|
|
882
|
-
err instanceof Error ? err.message : String(err)
|
|
883
|
-
}`,
|
|
884
|
-
);
|
|
885
|
-
return;
|
|
886
|
-
}
|
|
887
|
-
let rebuilt = 0;
|
|
888
|
-
let failed = 0;
|
|
889
|
-
let adopted = 0;
|
|
890
|
-
for (const inst of all) {
|
|
891
|
-
this.instances.set(inst.instanceId, inst);
|
|
892
|
-
rebuilt += 1;
|
|
893
|
-
if (inst.status === "running" || inst.status === "pending") {
|
|
894
|
-
// v5.5.1 — prefer the liveSession flag (the SDK-session marker)
|
|
895
|
-
// when present. If the dashboard still tracks the session, we
|
|
896
|
-
// re-adopt; otherwise we mark failed with a reconcilable message.
|
|
897
|
-
if (inst.liveSession) {
|
|
898
|
-
// SDK-backed: we trust the state file. The dashboard's SSE
|
|
899
|
-
// bridge will deliver the next event. Mark `running` and
|
|
900
|
-
// `runnerState: "adopted"` so the operator sees the plugin
|
|
901
|
-
// has caught up.
|
|
902
|
-
await this.update(inst.instanceId, {
|
|
903
|
-
status: "running",
|
|
904
|
-
completedAt: undefined,
|
|
905
|
-
runnerState: "adopted",
|
|
906
|
-
});
|
|
907
|
-
adopted += 1;
|
|
908
|
-
this.logger.info(
|
|
909
|
-
`bizar: re-adopted SDK-backed bg instance ${inst.instanceId}`,
|
|
910
|
-
);
|
|
911
|
-
continue;
|
|
912
|
-
}
|
|
913
|
-
// Pre-v5.5.1 subprocess fallback (rare; the runner is stubbed).
|
|
914
|
-
const pid = inst.processId;
|
|
915
|
-
const alive = typeof pid === "number" && clineRunner.isAlive(pid);
|
|
916
|
-
if (alive) {
|
|
917
|
-
await this.update(inst.instanceId, {
|
|
918
|
-
status: "running",
|
|
919
|
-
completedAt: undefined,
|
|
920
|
-
runnerState: "adopted",
|
|
921
|
-
});
|
|
922
|
-
adopted += 1;
|
|
923
|
-
this.logger.info(
|
|
924
|
-
`bizar: adopted still-alive subprocess for instance ${inst.instanceId} (pid=${pid})`,
|
|
925
|
-
);
|
|
926
|
-
continue;
|
|
927
|
-
}
|
|
928
|
-
const message = "subprocess died during restart";
|
|
929
|
-
await this.update(inst.instanceId, {
|
|
930
|
-
status: "failed",
|
|
931
|
-
error: message,
|
|
932
|
-
completedAt: Date.now(),
|
|
933
|
-
});
|
|
934
|
-
failed += 1;
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
if (rebuilt > 0) {
|
|
938
|
-
this.logger.info(
|
|
939
|
-
`bizar: rebuilt in-memory map (${rebuilt} instances, ${adopted} adopted, ${failed} marked failed)`,
|
|
940
|
-
);
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
// --- Shutdown (spec §5.3) ----------------------------------------------
|
|
945
|
-
|
|
946
|
-
/**
|
|
947
|
-
* Mark all in-memory instances as failed with `error: "plugin shutting down"`,
|
|
948
|
-
* abort all running sessions best-effort (5s timeout per call, in
|
|
949
|
-
* parallel), then return. The serve child termination is the
|
|
950
|
-
* caller's responsibility.
|
|
951
|
-
*
|
|
952
|
-
* Also clears the v0.3.0 stall-checker interval. After `shutdownAll`,
|
|
953
|
-
* the manager is effectively inert — no more periodic checks will
|
|
954
|
-
* fire even though the InstanceManager object itself is still alive.
|
|
955
|
-
*/
|
|
956
|
-
async shutdownAll(): Promise<void> {
|
|
957
|
-
// v0.3.0 — clear the periodic checker first so it does not race
|
|
958
|
-
// the in-flight updates below.
|
|
959
|
-
if (this.stallCheckerTimer !== null) {
|
|
960
|
-
clearInterval(this.stallCheckerTimer);
|
|
961
|
-
this.stallCheckerTimer = null;
|
|
962
|
-
}
|
|
963
|
-
this.stallCheckerDisabled = true;
|
|
964
|
-
const live: BackgroundState[] = [];
|
|
965
|
-
for (const inst of this.instances.values()) {
|
|
966
|
-
if (!TERMINAL_STATUSES.has(inst.status)) {
|
|
967
|
-
live.push(inst);
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
// Phase 1: mark failed first (spec §5.3 step 1).
|
|
971
|
-
for (const inst of live) {
|
|
972
|
-
await this.update(inst.instanceId, {
|
|
973
|
-
status: "failed",
|
|
974
|
-
error: "plugin shutting down",
|
|
975
|
-
completedAt: Date.now(),
|
|
976
|
-
});
|
|
977
|
-
}
|
|
978
|
-
// Phase 2: best-effort aborts in parallel, 5s per call. In bg-only
|
|
979
|
-
// mode there is no HTTP client to ask — the runner owns the
|
|
980
|
-
// subprocess lifecycle, so the in-memory status flip is the only
|
|
981
|
-
// signal we can emit. Skipped cleanly when http is null.
|
|
982
|
-
if (this.http !== null) {
|
|
983
|
-
const abortPromises = live.map((inst) =>
|
|
984
|
-
withTimeout(
|
|
985
|
-
this.http!.abortSession(inst.sessionId, this.worktree),
|
|
986
|
-
5_000,
|
|
987
|
-
).catch(() => undefined),
|
|
988
|
-
);
|
|
989
|
-
await Promise.allSettled(abortPromises);
|
|
990
|
-
}
|
|
991
|
-
this.logger.info(`bizar: shutdownAll complete (${live.length} instances aborted)`);
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
// --- v0.3.0 stall and thinking-loop helpers ----------------------------
|
|
995
|
-
|
|
996
|
-
/**
|
|
997
|
-
* Mark an instance `failed` with the canonical stall message and
|
|
998
|
-
* fire-and-forget the cline abort call. The stall timeout is
|
|
999
|
-
* intentionally short enough that an abort that fails gracefully
|
|
1000
|
-
* (the serve child is dead, etc.) does not leave the user waiting.
|
|
1001
|
-
*/
|
|
1002
|
-
private async _abortAsStalled(inst: BackgroundState): Promise<void> {
|
|
1003
|
-
const lastEventAt = inst.lastEventAt ?? 0;
|
|
1004
|
-
const sinceMs = Date.now() - lastEventAt;
|
|
1005
|
-
this.logger.warn(
|
|
1006
|
-
`bizar: instance ${inst.instanceId} stalled (no event for ${sinceMs}ms); aborting`,
|
|
1007
|
-
);
|
|
1008
|
-
// Fire-and-forget. If the serve child is dead, this returns a
|
|
1009
|
-
// failure result but we still mark the instance failed in-memory.
|
|
1010
|
-
// v0.8.0 — bg-only mode has no HTTP client; the abort is a no-op
|
|
1011
|
-
// there (the runner owns the subprocess).
|
|
1012
|
-
if (this.http !== null) {
|
|
1013
|
-
this.http
|
|
1014
|
-
.abortSession(inst.sessionId, this.worktree)
|
|
1015
|
-
.catch(() => undefined);
|
|
1016
|
-
}
|
|
1017
|
-
await this.update(inst.instanceId, {
|
|
1018
|
-
status: "failed",
|
|
1019
|
-
error: `No activity for ${this.stallTimeoutMs}ms — LLM appears stalled`,
|
|
1020
|
-
completedAt: Date.now(),
|
|
1021
|
-
});
|
|
1022
|
-
// v0.5.5 — persistent auto-restart on stall
|
|
1023
|
-
await this._maybeAutoRestart(inst.instanceId);
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
/**
|
|
1027
|
-
* Send the research-intervention prompt to the running session. The
|
|
1028
|
-
* message interrupts the current generation and starts a new turn
|
|
1029
|
-
* with the prompt as the next user message. This is fire-and-forget:
|
|
1030
|
-
* we do not wait for the prompt to complete, only for the HTTP call
|
|
1031
|
-
* to return.
|
|
1032
|
-
*/
|
|
1033
|
-
private async _sendIntervention(
|
|
1034
|
-
inst: BackgroundState,
|
|
1035
|
-
sinceMs: number,
|
|
1036
|
-
): Promise<void> {
|
|
1037
|
-
const messageID = generateMessageId();
|
|
1038
|
-
const prompt = researchInterventionPrompt(sinceMs);
|
|
1039
|
-
const currentCount = inst.interventionCount ?? 0;
|
|
1040
|
-
this.logger.warn(
|
|
1041
|
-
`bizar: instance ${inst.instanceId} thinking loop (${sinceMs}ms without tool/text); sending intervention #${currentCount + 1}/${this.maxInterventions}`,
|
|
1042
|
-
);
|
|
1043
|
-
try {
|
|
1044
|
-
// v0.8.0 — bg-only mode has no HTTP client; interventions are a
|
|
1045
|
-
// no-op there (the runner doesn't expose a "send a user message
|
|
1046
|
-
// mid-run" hook). Logged as a debug so the operator can see why
|
|
1047
|
-
// no intervention went out.
|
|
1048
|
-
if (this.http === null) {
|
|
1049
|
-
this.logger.debug(
|
|
1050
|
-
`bizar: skipping intervention for ${inst.sessionId} (bg-only mode; no HTTP client)`,
|
|
1051
|
-
);
|
|
1052
|
-
} else {
|
|
1053
|
-
await this.http.sendPrompt(
|
|
1054
|
-
{
|
|
1055
|
-
sessionId: inst.sessionId,
|
|
1056
|
-
messageID,
|
|
1057
|
-
agent: inst.agent,
|
|
1058
|
-
parts: [{ type: "text", text: prompt }],
|
|
1059
|
-
},
|
|
1060
|
-
this.worktree,
|
|
1061
|
-
);
|
|
1062
|
-
}
|
|
1063
|
-
} catch (err: unknown) {
|
|
1064
|
-
// We swallow the error: the periodic checker will try again next
|
|
1065
|
-
// tick. The intervention counter is still incremented below so
|
|
1066
|
-
// we eventually escalate to an abort if the prompt keeps failing.
|
|
1067
|
-
this.logger.warn(
|
|
1068
|
-
`bizar: intervention prompt send failed for ${inst.instanceId}: ${
|
|
1069
|
-
err instanceof Error ? err.message : String(err)
|
|
1070
|
-
}`,
|
|
1071
|
-
);
|
|
1072
|
-
}
|
|
1073
|
-
const reason = `thinking loop (${formatDuration(sinceMs)} without tool/text)`;
|
|
1074
|
-
await this.update(inst.instanceId, {
|
|
1075
|
-
interventionCount: currentCount + 1,
|
|
1076
|
-
interventionAt: Date.now(),
|
|
1077
|
-
interventionReason: reason,
|
|
1078
|
-
// Bumping lastEventAt here is intentional: the intervention call
|
|
1079
|
-
// counted as an HTTP-driven activity, so the stall checker does
|
|
1080
|
-
// not fire immediately after.
|
|
1081
|
-
lastEventAt: Date.now(),
|
|
1082
|
-
});
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
/**
|
|
1086
|
-
* Mark an instance `failed` with the canonical thinking-loop message
|
|
1087
|
-
* and fire-and-forget the abort call.
|
|
1088
|
-
*/
|
|
1089
|
-
private async _abortAsThinkingLoop(
|
|
1090
|
-
inst: BackgroundState,
|
|
1091
|
-
sinceMs: number,
|
|
1092
|
-
): Promise<void> {
|
|
1093
|
-
this.logger.warn(
|
|
1094
|
-
`bizar: instance ${inst.instanceId} thinking loop exhausted ${this.maxInterventions} intervention(s) over ${sinceMs}ms; aborting`,
|
|
1095
|
-
);
|
|
1096
|
-
// v0.8.0 — bg-only mode has no HTTP client; the abort is a no-op
|
|
1097
|
-
// there (the runner owns the subprocess).
|
|
1098
|
-
if (this.http !== null) {
|
|
1099
|
-
this.http
|
|
1100
|
-
.abortSession(inst.sessionId, this.worktree)
|
|
1101
|
-
.catch(() => undefined);
|
|
1102
|
-
}
|
|
1103
|
-
await this.update(inst.instanceId, {
|
|
1104
|
-
status: "failed",
|
|
1105
|
-
error: `Thinking loop detected: ${formatDuration(sinceMs)} of thinking without tool calls or output. Spawn a Mimir agent for research.`,
|
|
1106
|
-
completedAt: Date.now(),
|
|
1107
|
-
});
|
|
1108
|
-
// v0.5.5 — persistent auto-restart on thinking-loop exhaustion
|
|
1109
|
-
await this._maybeAutoRestart(inst.instanceId);
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
// --- Internal: per-session event handler -------------------------------
|
|
1113
|
-
|
|
1114
|
-
public attachEventHandler(inst: BackgroundState): () => void {
|
|
1115
|
-
this.detachEventHandler(inst.instanceId);
|
|
1116
|
-
// v0.8.0 — bg-only mode has no EventStream; return a no-op unsubscriber.
|
|
1117
|
-
if (!this.stream) return () => {};
|
|
1118
|
-
const handler: SessionEventHandler = (ev: StreamEvent) => {
|
|
1119
|
-
void this.handleInstanceEvent(inst.instanceId, ev);
|
|
1120
|
-
};
|
|
1121
|
-
const unsubscribe = this.stream.onSessionEvent(inst.sessionId, handler);
|
|
1122
|
-
this.eventUnsubscribers.set(inst.instanceId, unsubscribe);
|
|
1123
|
-
return unsubscribe;
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
private detachEventHandler(instanceId: string): void {
|
|
1127
|
-
const unsubscribe = this.eventUnsubscribers.get(instanceId);
|
|
1128
|
-
if (!unsubscribe) return;
|
|
1129
|
-
try {
|
|
1130
|
-
unsubscribe();
|
|
1131
|
-
} catch {
|
|
1132
|
-
// ignore
|
|
1133
|
-
}
|
|
1134
|
-
this.eventUnsubscribers.delete(instanceId);
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
private async handleInstanceEvent(
|
|
1138
|
-
instanceId: string,
|
|
1139
|
-
ev: StreamEvent,
|
|
1140
|
-
): Promise<void> {
|
|
1141
|
-
const inst = this.instances.get(instanceId);
|
|
1142
|
-
if (!inst) return;
|
|
1143
|
-
// Already terminal — ignore further events (e.g., after kill, an
|
|
1144
|
-
// EventSessionError may still arrive).
|
|
1145
|
-
if (TERMINAL_STATUSES.has(inst.status)) return;
|
|
1146
|
-
|
|
1147
|
-
// v0.3.0 — every event advances the heartbeat. We do this BEFORE
|
|
1148
|
-
// any further work (or inside the per-instance mutex in update())
|
|
1149
|
-
// so the stall checker sees the freshest timestamp regardless of
|
|
1150
|
-
// how the rest of the handler proceeds.
|
|
1151
|
-
inst.lastEventAt = Date.now();
|
|
1152
|
-
|
|
1153
|
-
if (ev.type === "message.part.updated") {
|
|
1154
|
-
await this.onPartUpdated(instanceId, ev);
|
|
1155
|
-
} else if (ev.type === "session.idle") {
|
|
1156
|
-
await this.update(instanceId, {
|
|
1157
|
-
status: "done",
|
|
1158
|
-
completedAt: Date.now(),
|
|
1159
|
-
});
|
|
1160
|
-
} else if (ev.type === "session.error") {
|
|
1161
|
-
const errMsg = ev.error ?? "session error";
|
|
1162
|
-
await this.update(instanceId, {
|
|
1163
|
-
status: "failed",
|
|
1164
|
-
error: errMsg,
|
|
1165
|
-
completedAt: Date.now(),
|
|
1166
|
-
});
|
|
1167
|
-
// v0.5.5 — persistent auto-restart. If the instance is persistent
|
|
1168
|
-
// and was not explicitly killed, try to restart.
|
|
1169
|
-
if (inst.persistent && inst.status !== "killed") {
|
|
1170
|
-
await this._maybeAutoRestart(instanceId);
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
/**
|
|
1176
|
-
* v0.8.0 — Public version of `_maybeAutoRestart`. The cline-runner
|
|
1177
|
-
* (see src/cline-runner.ts) calls this from its onExit callback
|
|
1178
|
-
* when a `bizar_spawn_background` subprocess exits. Without this,
|
|
1179
|
-
* persistent instances would never auto-restart under the new
|
|
1180
|
-
* subprocess-based path (the SSE event handler that previously
|
|
1181
|
-
* triggered auto-restart is no longer wired up because we don't
|
|
1182
|
-
* subscribe to per-session events anymore).
|
|
1183
|
-
*/
|
|
1184
|
-
async maybeAutoRestart(instanceId: string): Promise<void> {
|
|
1185
|
-
await this._maybeAutoRestart(instanceId);
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
// --- v5.x — pause / resume / progress / tool-call instrumentation -------
|
|
1189
|
-
|
|
1190
|
-
/**
|
|
1191
|
-
* Pause a running instance. In v5.5.1 (SDK-backed) this delegates to
|
|
1192
|
-
* the dashboard's `POST /api/background/:id/pause`, which performs an
|
|
1193
|
-
* OUTPUT pause (stops forwarding events to the WS bus; the underlying
|
|
1194
|
-
* cline serve session keeps running). For pre-v5.5.1 instances
|
|
1195
|
-
* that still carry a `processId`, fall back to the old SIGSTOP path
|
|
1196
|
-
* via the (now stubbed) runner — best-effort, returns ok:false if the
|
|
1197
|
-
* runner reports the OS no longer supports it.
|
|
1198
|
-
*
|
|
1199
|
-
* On success, the in-memory status flips to `"paused"` and `pausedAt`
|
|
1200
|
-
* is stamped.
|
|
1201
|
-
*
|
|
1202
|
-
* Already-terminal instances return `{ ok: false, error: "already_terminal" }`.
|
|
1203
|
-
* Already-paused instances return `{ ok: true }` (no-op).
|
|
1204
|
-
*/
|
|
1205
|
-
async pause(instanceId: string): Promise<{ ok: boolean; error?: string }> {
|
|
1206
|
-
const inst = this.instances.get(instanceId);
|
|
1207
|
-
if (!inst) return { ok: false, error: "instance_not_found" };
|
|
1208
|
-
if (TERMINAL_STATUSES.has(inst.status)) {
|
|
1209
|
-
return { ok: false, error: "already_terminal" };
|
|
1210
|
-
}
|
|
1211
|
-
if (inst.status === "paused") return { ok: true };
|
|
1212
|
-
|
|
1213
|
-
// v5.5.1 — SDK-backed: delegate to the dashboard.
|
|
1214
|
-
if (inst.liveSession) {
|
|
1215
|
-
const res = await postDashboardControl(`/api/background/${encodeURIComponent(instanceId)}/pause`, this.logger);
|
|
1216
|
-
if (!res.ok) {
|
|
1217
|
-
return { ok: false, error: res.error || "pause_failed" };
|
|
1218
|
-
}
|
|
1219
|
-
await this.update(instanceId, {
|
|
1220
|
-
status: "paused",
|
|
1221
|
-
pausedAt: Date.now(),
|
|
1222
|
-
});
|
|
1223
|
-
this.logger.info(`bizar: paused background instance ${instanceId} (sdk-backed)`);
|
|
1224
|
-
return { ok: true };
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
// Pre-v5.5.1 fallback: subprocess SIGSTOP. The runner is a stub now,
|
|
1228
|
-
// but if a state file from v5.5.0 is still around we try the old
|
|
1229
|
-
// path for compatibility.
|
|
1230
|
-
if (inst.processId === undefined) {
|
|
1231
|
-
return { ok: false, error: "no_subprocess" };
|
|
1232
|
-
}
|
|
1233
|
-
const pid = inst.processId;
|
|
1234
|
-
const res = clineRunner.pauseAgent(pid);
|
|
1235
|
-
if (!res.ok) return { ok: false, error: res.error || "pause_failed" };
|
|
1236
|
-
await this.update(instanceId, {
|
|
1237
|
-
status: "paused",
|
|
1238
|
-
pausedAt: Date.now(),
|
|
1239
|
-
});
|
|
1240
|
-
this.logger.info(
|
|
1241
|
-
`bizar: paused background instance ${instanceId} (pid=${pid}, legacy)`,
|
|
1242
|
-
);
|
|
1243
|
-
return { ok: true };
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
/**
|
|
1247
|
-
* Resume a paused instance. In v5.5.1 (SDK-backed) delegates to the
|
|
1248
|
-
* dashboard's `POST /api/background/:id/resume`, which drains the
|
|
1249
|
-
* buffered events back to the WS bus. For pre-v5.5.1 instances with
|
|
1250
|
-
* a `processId`, falls back to the old SIGCONT path via the runner.
|
|
1251
|
-
*
|
|
1252
|
-
* Idempotent: resuming a non-paused instance is a no-op that returns
|
|
1253
|
-
* `{ ok: true }`.
|
|
1254
|
-
*/
|
|
1255
|
-
async resume(instanceId: string): Promise<{ ok: boolean; error?: string }> {
|
|
1256
|
-
const inst = this.instances.get(instanceId);
|
|
1257
|
-
if (!inst) return { ok: false, error: "instance_not_found" };
|
|
1258
|
-
if (inst.status !== "paused") return { ok: true };
|
|
1259
|
-
|
|
1260
|
-
// v5.5.1 — SDK-backed: delegate to the dashboard.
|
|
1261
|
-
if (inst.liveSession) {
|
|
1262
|
-
const res = await postDashboardControl(`/api/background/${encodeURIComponent(instanceId)}/resume`, this.logger);
|
|
1263
|
-
if (!res.ok) {
|
|
1264
|
-
return { ok: false, error: res.error || "resume_failed" };
|
|
1265
|
-
}
|
|
1266
|
-
await this.update(instanceId, {
|
|
1267
|
-
status: "running",
|
|
1268
|
-
pausedAt: undefined,
|
|
1269
|
-
});
|
|
1270
|
-
this.logger.info(`bizar: resumed background instance ${instanceId} (sdk-backed)`);
|
|
1271
|
-
return { ok: true };
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
// Pre-v5.5.1 fallback: subprocess SIGCONT.
|
|
1275
|
-
if (inst.processId === undefined) {
|
|
1276
|
-
return { ok: false, error: "no_subprocess" };
|
|
1277
|
-
}
|
|
1278
|
-
const res = clineRunner.resumeAgent(inst.processId);
|
|
1279
|
-
if (!res.ok) return { ok: false, error: res.error || "resume_failed" };
|
|
1280
|
-
await this.update(instanceId, {
|
|
1281
|
-
status: "running",
|
|
1282
|
-
pausedAt: undefined,
|
|
1283
|
-
});
|
|
1284
|
-
this.logger.info(
|
|
1285
|
-
`bizar: resumed background instance ${instanceId} (pid=${inst.processId}, legacy)`,
|
|
1286
|
-
);
|
|
1287
|
-
return { ok: true };
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
/**
|
|
1291
|
-
* Update progress (and optional message) for an instance. Called by the
|
|
1292
|
-
* `bizar_report_progress` tool. Pinned to [0, 100] unless `indeterminate`
|
|
1293
|
-
* is true, in which case we set progress to -1.
|
|
1294
|
-
*/
|
|
1295
|
-
async updateProgress(
|
|
1296
|
-
instanceId: string,
|
|
1297
|
-
step: number,
|
|
1298
|
-
total: number,
|
|
1299
|
-
message?: string,
|
|
1300
|
-
indeterminate = false,
|
|
1301
|
-
): Promise<{ ok: boolean; progress?: number; error?: string }> {
|
|
1302
|
-
const inst = this.instances.get(instanceId);
|
|
1303
|
-
if (!inst) return { ok: false, error: "instance_not_found" };
|
|
1304
|
-
if (TERMINAL_STATUSES.has(inst.status)) {
|
|
1305
|
-
return { ok: false, error: "already_terminal" };
|
|
1306
|
-
}
|
|
1307
|
-
let pct: number;
|
|
1308
|
-
if (indeterminate) {
|
|
1309
|
-
pct = -1;
|
|
1310
|
-
} else {
|
|
1311
|
-
const safeTotal = Math.max(1, Math.floor(total));
|
|
1312
|
-
const safeStep = Math.max(0, Math.floor(step));
|
|
1313
|
-
pct = Math.max(0, Math.min(100, Math.round((safeStep / safeTotal) * 100)));
|
|
1314
|
-
}
|
|
1315
|
-
const patch: Partial<BackgroundState> = {
|
|
1316
|
-
progress: pct,
|
|
1317
|
-
progressAt: Date.now(),
|
|
1318
|
-
};
|
|
1319
|
-
if (message !== undefined) patch.progressMessage = message.slice(0, 500);
|
|
1320
|
-
await this.update(instanceId, patch);
|
|
1321
|
-
return { ok: true, progress: pct };
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
/**
|
|
1325
|
-
* Record a tool-call start. Returns the generated id so callers can
|
|
1326
|
-
* pass it to `completeToolCall()` later. Caps the history at
|
|
1327
|
-
* `MAX_TOOL_CALL_HISTORY` entries.
|
|
1328
|
-
*/
|
|
1329
|
-
async recordToolCallStart(
|
|
1330
|
-
instanceId: string,
|
|
1331
|
-
name: string,
|
|
1332
|
-
args: unknown,
|
|
1333
|
-
): Promise<string> {
|
|
1334
|
-
const id = `tc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
1335
|
-
const inst = this.instances.get(instanceId);
|
|
1336
|
-
if (!inst) return id;
|
|
1337
|
-
const entry: ToolCallEntry = {
|
|
1338
|
-
id,
|
|
1339
|
-
name: String(name ?? "").slice(0, 200),
|
|
1340
|
-
args: truncateForToolHistory(args),
|
|
1341
|
-
status: "running",
|
|
1342
|
-
startedAt: Date.now(),
|
|
1343
|
-
};
|
|
1344
|
-
const existing = (inst.toolCalls ?? []).slice(-MAX_TOOL_CALL_HISTORY + 1);
|
|
1345
|
-
const next = [...existing, entry].slice(-MAX_TOOL_CALL_HISTORY);
|
|
1346
|
-
await this.update(instanceId, { toolCalls: next });
|
|
1347
|
-
return id;
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
/**
|
|
1351
|
-
* Mark a tool-call complete. `result` and `error` are both truncated.
|
|
1352
|
-
* A no-op if no matching entry is found (the cap may have rolled it off).
|
|
1353
|
-
*/
|
|
1354
|
-
async completeToolCall(
|
|
1355
|
-
instanceId: string,
|
|
1356
|
-
id: string,
|
|
1357
|
-
result?: string,
|
|
1358
|
-
error?: string,
|
|
1359
|
-
): Promise<void> {
|
|
1360
|
-
const inst = this.instances.get(instanceId);
|
|
1361
|
-
if (!inst || !Array.isArray(inst.toolCalls)) return;
|
|
1362
|
-
const idx = inst.toolCalls.findIndex((t) => t.id === id);
|
|
1363
|
-
if (idx < 0) return;
|
|
1364
|
-
const cur = inst.toolCalls[idx];
|
|
1365
|
-
if (!cur) return;
|
|
1366
|
-
const next = inst.toolCalls.slice();
|
|
1367
|
-
const updated: ToolCallEntry = {
|
|
1368
|
-
...cur,
|
|
1369
|
-
status: error ? "error" : "ok",
|
|
1370
|
-
endedAt: Date.now(),
|
|
1371
|
-
};
|
|
1372
|
-
if (error !== undefined) {
|
|
1373
|
-
updated.error = String(error).slice(0, MAX_TOOL_ARG_CHARS);
|
|
1374
|
-
} else if (result !== undefined) {
|
|
1375
|
-
updated.result = String(result).slice(0, MAX_TOOL_ARG_CHARS);
|
|
1376
|
-
}
|
|
1377
|
-
next[idx] = updated;
|
|
1378
|
-
await this.update(instanceId, { toolCalls: next });
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
/**
|
|
1382
|
-
* Public accessor used by the cline-runner when it wants to surface
|
|
1383
|
-
* the recorded tool-call list (read-only). Returns a copy.
|
|
1384
|
-
*/
|
|
1385
|
-
listToolCalls(instanceId: string): ToolCallEntry[] {
|
|
1386
|
-
const inst = this.instances.get(instanceId);
|
|
1387
|
-
if (!inst || !Array.isArray(inst.toolCalls)) return [];
|
|
1388
|
-
return inst.toolCalls.slice();
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
/**
|
|
1392
|
-
* Public: mark an instance as "adopted" — used by `rebuildInMemoryMap`
|
|
1393
|
-
* when a previously running instance's subprocess is still alive. The
|
|
1394
|
-
* status flips back to "running" and the runner is resumed.
|
|
1395
|
-
*/
|
|
1396
|
-
async adoptInstance(instanceId: string): Promise<void> {
|
|
1397
|
-
const inst = this.instances.get(instanceId);
|
|
1398
|
-
if (!inst) return;
|
|
1399
|
-
if (!TERMINAL_STATUSES.has(inst.status)) {
|
|
1400
|
-
// Already alive in our map; nothing to adopt.
|
|
1401
|
-
return;
|
|
1402
|
-
}
|
|
1403
|
-
await this.update(instanceId, {
|
|
1404
|
-
status: "running",
|
|
1405
|
-
completedAt: undefined,
|
|
1406
|
-
runnerState: "adopted",
|
|
1407
|
-
});
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
/** v0.5.5 — Attempt an auto-restart for a persistent failed instance. */
|
|
1411
|
-
private async _maybeAutoRestart(instanceId: string): Promise<void> {
|
|
1412
|
-
const inst = this.instances.get(instanceId);
|
|
1413
|
-
if (!inst || !inst.persistent) return;
|
|
1414
|
-
if (inst.status === "killed") return; // user killed it — do not restart
|
|
1415
|
-
this.logger.info(
|
|
1416
|
-
`bizar: persistent instance ${instanceId} failed; auto-restarting`,
|
|
1417
|
-
);
|
|
1418
|
-
const result = await this.restart(instanceId);
|
|
1419
|
-
if (result.ok) {
|
|
1420
|
-
// Clear any previous restartError on the parent so the operator
|
|
1421
|
-
// sees a clean state.
|
|
1422
|
-
await this.update(instanceId, { restartError: undefined });
|
|
1423
|
-
this.logger.info(
|
|
1424
|
-
`bizar: auto-restart complete: ${instanceId} -> ${result.newInstanceId}`,
|
|
1425
|
-
);
|
|
1426
|
-
} else {
|
|
1427
|
-
// Persist a human-readable error so the operator can see why the
|
|
1428
|
-
// restart was rejected (e.g. max_restarts_reached).
|
|
1429
|
-
await this.update(instanceId, { restartError: result.error || "unknown" });
|
|
1430
|
-
this.logger.warn(
|
|
1431
|
-
`bizar: auto-restart failed for ${instanceId}: ${result.error}`,
|
|
1432
|
-
);
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
private async onPartUpdated(
|
|
1437
|
-
instanceId: string,
|
|
1438
|
-
ev: Extract<StreamEvent, { type: "message.part.updated" }>,
|
|
1439
|
-
): Promise<void> {
|
|
1440
|
-
const inst = this.instances.get(instanceId);
|
|
1441
|
-
if (!inst) return;
|
|
1442
|
-
const part = ev.part;
|
|
1443
|
-
|
|
1444
|
-
// v0.3.0 — tool and text parts advance the "progress" timestamp.
|
|
1445
|
-
// `thinking` parts do NOT, because that is the loop indicator.
|
|
1446
|
-
if (part.type === "tool" || part.type === "text") {
|
|
1447
|
-
inst.lastToolOrTextAt = Date.now();
|
|
1448
|
-
// The agent has shown concrete progress after one or more
|
|
1449
|
-
// interventions — reset the intervention counter so the next
|
|
1450
|
-
// thinking loop has a fresh budget. Clear the intervention
|
|
1451
|
-
// metadata so a later status check does not show stale info.
|
|
1452
|
-
if ((inst.interventionCount ?? 0) > 0) {
|
|
1453
|
-
inst.interventionCount = 0;
|
|
1454
|
-
delete inst.interventionAt;
|
|
1455
|
-
delete inst.interventionReason;
|
|
1456
|
-
}
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
// --- Tool-call cap (spec §6.2) ---
|
|
1460
|
-
if (part.type === "tool") {
|
|
1461
|
-
const nextCount = inst.toolCallCount + 1;
|
|
1462
|
-
const patch: Partial<BackgroundState> = { toolCallCount: nextCount };
|
|
1463
|
-
if (nextCount >= this.toolCallCap) {
|
|
1464
|
-
// Abort and mark failed. Use a fire-and-forget abort because we
|
|
1465
|
-
// do not want to block the handler on a network call.
|
|
1466
|
-
// v0.8.0 — bg-only mode has no HTTP client; the abort is a
|
|
1467
|
-
// no-op there (the runner owns the subprocess).
|
|
1468
|
-
if (this.http !== null) {
|
|
1469
|
-
this.http
|
|
1470
|
-
.abortSession(inst.sessionId, this.worktree)
|
|
1471
|
-
.catch(() => undefined);
|
|
1472
|
-
}
|
|
1473
|
-
patch.status = "failed";
|
|
1474
|
-
patch.error = `Tool-call cap reached (${nextCount}). Aborted to prevent cost runaway.`;
|
|
1475
|
-
patch.completedAt = Date.now();
|
|
1476
|
-
}
|
|
1477
|
-
await this.update(instanceId, patch);
|
|
1478
|
-
// v0.5.5 — persistent auto-restart on tool-call cap
|
|
1479
|
-
if (patch.status === "failed") {
|
|
1480
|
-
await this._maybeAutoRestart(instanceId);
|
|
1481
|
-
return;
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
|
|
1485
|
-
// --- v5.x — Tool call history recording -----------------------------
|
|
1486
|
-
// The tool part re-fires on every state transition (pending ->
|
|
1487
|
-
// running -> completed). We key the history entry by `partID` so
|
|
1488
|
-
// the same call's "running" and "completed" messages converge to
|
|
1489
|
-
// one row. When a part has no entry yet, we record a fresh "running"
|
|
1490
|
-
// entry; when it already exists, we update status/result/error.
|
|
1491
|
-
if (part.type === "tool") {
|
|
1492
|
-
const toolName = readToolName(part);
|
|
1493
|
-
const existing = Array.isArray(inst.toolCalls)
|
|
1494
|
-
? inst.toolCalls.find((t) => t.id === ev.partID)
|
|
1495
|
-
: undefined;
|
|
1496
|
-
if (existing === undefined) {
|
|
1497
|
-
// Fresh tool call — record start.
|
|
1498
|
-
const tcId = ev.partID;
|
|
1499
|
-
const entry: ToolCallEntry = {
|
|
1500
|
-
id: tcId,
|
|
1501
|
-
name: toolName,
|
|
1502
|
-
args: readToolArgs(part),
|
|
1503
|
-
status: readToolStatus(part),
|
|
1504
|
-
startedAt: Date.now(),
|
|
1505
|
-
};
|
|
1506
|
-
const merged = [...(inst.toolCalls ?? []), entry].slice(
|
|
1507
|
-
-MAX_TOOL_CALL_HISTORY,
|
|
1508
|
-
);
|
|
1509
|
-
await this.update(instanceId, { toolCalls: merged });
|
|
1510
|
-
} else {
|
|
1511
|
-
// Existing entry — update status/result/error.
|
|
1512
|
-
const next = (inst.toolCalls ?? []).slice();
|
|
1513
|
-
const idx = next.findIndex((t) => t.id === ev.partID);
|
|
1514
|
-
if (idx >= 0) {
|
|
1515
|
-
const cur = next[idx];
|
|
1516
|
-
if (cur) {
|
|
1517
|
-
const status = readToolStatus(part);
|
|
1518
|
-
const updated: ToolCallEntry = {
|
|
1519
|
-
...cur,
|
|
1520
|
-
status,
|
|
1521
|
-
endedAt: status === "ok" || status === "error" ? Date.now() : cur.endedAt,
|
|
1522
|
-
};
|
|
1523
|
-
const errText = readToolError(part);
|
|
1524
|
-
if (errText) updated.error = errText.slice(0, MAX_TOOL_ARG_CHARS);
|
|
1525
|
-
// If the part carries a result string in `state.output` or
|
|
1526
|
-
// a result-shaped field, surface it. The cline schema
|
|
1527
|
-
// is loose here — we accept either `state.output` or raw
|
|
1528
|
-
// `output`.
|
|
1529
|
-
const output = readToolOutput(part);
|
|
1530
|
-
if (output) updated.result = output.slice(0, MAX_TOOL_ARG_CHARS);
|
|
1531
|
-
next[idx] = updated;
|
|
1532
|
-
await this.update(instanceId, { toolCalls: next });
|
|
1533
|
-
}
|
|
1534
|
-
}
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
// --- Loop-guard threshold-12 detection (spec §4.1) ---
|
|
1539
|
-
if (part.type === "tool" && !inst.loopGuardTool) {
|
|
1540
|
-
const errorText = readToolError(part);
|
|
1541
|
-
if (errorText) {
|
|
1542
|
-
const m = errorText.match(LOOP_GUARD_RE);
|
|
1543
|
-
if (m && m[1]) {
|
|
1544
|
-
const tool = m[1];
|
|
1545
|
-
await this.update(instanceId, {
|
|
1546
|
-
status: "failed",
|
|
1547
|
-
error: `Loop protection: 12 identical calls to ${tool}`,
|
|
1548
|
-
loopGuardTool: tool,
|
|
1549
|
-
completedAt: Date.now(),
|
|
1550
|
-
});
|
|
1551
|
-
// v0.5.5 — persistent auto-restart on loop-guard
|
|
1552
|
-
await this._maybeAutoRestart(instanceId);
|
|
1553
|
-
return;
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
|
-
// --- Text-part result preview refresh (spec §3.2) ---
|
|
1559
|
-
if (part.type === "text" && typeof part.text === "string") {
|
|
1560
|
-
const preview = part.text.slice(-RESULT_PREVIEW_MAX);
|
|
1561
|
-
const newIds = [...(inst.resultMessageIds ?? []), ev.messageID];
|
|
1562
|
-
// Deduplicate messageIDs.
|
|
1563
|
-
const seen = new Set<string>();
|
|
1564
|
-
const uniq = newIds.filter((id) => {
|
|
1565
|
-
if (seen.has(id)) return false;
|
|
1566
|
-
seen.add(id);
|
|
1567
|
-
return true;
|
|
1568
|
-
});
|
|
1569
|
-
await this.update(instanceId, {
|
|
1570
|
-
resultPreview: preview,
|
|
1571
|
-
resultMessageIds: uniq,
|
|
1572
|
-
});
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1576
|
-
/**
|
|
1577
|
-
* Build the result text for `collect` per spec §4.4:
|
|
1578
|
-
* - Fetch assistant messages via `GET /session/{id}/message`.
|
|
1579
|
-
* - Concatenate `TextPart.text` in order; skip everything else.
|
|
1580
|
-
* - If `loopGuardTool` is set, prepend the marker.
|
|
1581
|
-
*/
|
|
1582
|
-
private async buildResultText(inst: BackgroundState): Promise<string> {
|
|
1583
|
-
// v0.8.0 — bg-only mode has no HTTP client; fall back to whatever
|
|
1584
|
-
// text-part preview we've already accumulated.
|
|
1585
|
-
if (!this.http) return inst.resultPreview ?? "";
|
|
1586
|
-
const res = await this.http.listMessages(inst.sessionId, this.worktree);
|
|
1587
|
-
if (!res.ok) {
|
|
1588
|
-
this.logger.warn(`bizar: collect: listMessages failed: ${res.error}`);
|
|
1589
|
-
return inst.resultPreview ?? "";
|
|
1590
|
-
}
|
|
1591
|
-
const textParts: string[] = [];
|
|
1592
|
-
for (const msg of res.value) {
|
|
1593
|
-
if (msg.role !== "assistant") continue;
|
|
1594
|
-
for (const p of msg.parts) {
|
|
1595
|
-
if (p.type !== "text") continue;
|
|
1596
|
-
if (typeof p.text === "string" && p.text.length > 0) {
|
|
1597
|
-
textParts.push(p.text);
|
|
1598
|
-
}
|
|
1599
|
-
}
|
|
1600
|
-
}
|
|
1601
|
-
const body = textParts.join("");
|
|
1602
|
-
if (inst.loopGuardTool) {
|
|
1603
|
-
return `[loop guard: 12 identical calls to ${inst.loopGuardTool}]\n${body}`;
|
|
1604
|
-
}
|
|
1605
|
-
return body;
|
|
1606
|
-
}
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
// --- Helpers --------------------------------------------------------------
|
|
1610
|
-
|
|
1611
|
-
/**
|
|
1612
|
-
* Format a millisecond duration as `Xm Ys` (or just `Ys` if under a minute).
|
|
1613
|
-
* Used in stall and thinking-loop error messages.
|
|
1614
|
-
*/
|
|
1615
|
-
function formatDuration(ms: number): string {
|
|
1616
|
-
const safeMs = Math.max(0, Math.floor(ms));
|
|
1617
|
-
const minutes = Math.floor(safeMs / 60_000);
|
|
1618
|
-
const seconds = Math.floor((safeMs % 60_000) / 1000);
|
|
1619
|
-
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
/**
|
|
1623
|
-
* Generate a unique instance id: `bgr_<22-char base32>` (ULID-like).
|
|
1624
|
-
* We use 16 random bytes encoded as 22 base32 characters. The prefix
|
|
1625
|
-
* `bgr_` makes the file naming scheme obvious.
|
|
1626
|
-
*/
|
|
1627
|
-
export function generateInstanceId(): string {
|
|
1628
|
-
const bytes = new Uint8Array(16);
|
|
1629
|
-
globalThis.crypto.getRandomValues(bytes);
|
|
1630
|
-
return `bgr_${base32(bytes)}`;
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
|
-
/**
|
|
1634
|
-
* Crockford base32 (no I, L, O, U) encoder for 16 bytes → 26 chars.
|
|
1635
|
-
* We use 16 bytes (128 bits) to give plenty of entropy; only the first
|
|
1636
|
-
* 22 chars are used for the actual id and the last 4 are dropped.
|
|
1637
|
-
*/
|
|
1638
|
-
function base32(bytes: Uint8Array): string {
|
|
1639
|
-
const ALPH = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
1640
|
-
// Encode 5 bytes → 8 chars; pad the last group with zeros.
|
|
1641
|
-
let bits = 0;
|
|
1642
|
-
let value = 0;
|
|
1643
|
-
let out = "";
|
|
1644
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
1645
|
-
value = (value << 8) | (bytes[i] ?? 0);
|
|
1646
|
-
bits += 8;
|
|
1647
|
-
while (bits >= 5) {
|
|
1648
|
-
out += ALPH[(value >>> (bits - 5)) & 0x1f];
|
|
1649
|
-
bits -= 5;
|
|
1650
|
-
}
|
|
1651
|
-
}
|
|
1652
|
-
if (bits > 0) out += ALPH[(value << (5 - bits)) & 0x1f];
|
|
1653
|
-
return out.slice(0, 22);
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
/**
|
|
1657
|
-
* Generate a unique message id: `msg_<22-char base32>`. Same encoding
|
|
1658
|
-
* as `generateInstanceId`. Used for `POST /session/{id}/prompt_async`.
|
|
1659
|
-
*/
|
|
1660
|
-
export function generateMessageId(): string {
|
|
1661
|
-
const bytes = new Uint8Array(16);
|
|
1662
|
-
globalThis.crypto.getRandomValues(bytes);
|
|
1663
|
-
return `msg_${base32(bytes)}`;
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
|
-
function toView(inst: BackgroundState): InstanceView {
|
|
1667
|
-
const v: InstanceView = {
|
|
1668
|
-
instanceId: inst.instanceId,
|
|
1669
|
-
agent: inst.agent,
|
|
1670
|
-
status: inst.status,
|
|
1671
|
-
startedAt: inst.startedAt,
|
|
1672
|
-
toolCallCount: inst.toolCallCount,
|
|
1673
|
-
promptPreview: inst.promptPreview,
|
|
1674
|
-
parentAgent: inst.parentAgent,
|
|
1675
|
-
sessionId: inst.sessionId,
|
|
1676
|
-
// v0.3.0 — stall and thinking-loop protection. Always include
|
|
1677
|
-
// lastEventAt so a status caller can see how fresh the activity is.
|
|
1678
|
-
lastEventAt: inst.lastEventAt,
|
|
1679
|
-
};
|
|
1680
|
-
if (inst.completedAt !== undefined) v.completedAt = inst.completedAt;
|
|
1681
|
-
if (inst.resultPreview !== undefined) v.resultPreview = inst.resultPreview;
|
|
1682
|
-
if (inst.error !== undefined) v.error = inst.error;
|
|
1683
|
-
if (inst.parentInstanceId !== undefined) v.parentInstanceId = inst.parentInstanceId;
|
|
1684
|
-
// Only surface intervention metadata when we have actually intervened.
|
|
1685
|
-
// `interventionCount > 0` is the canonical signal; absent fields mean
|
|
1686
|
-
// "no intervention has been sent yet", which is the common case.
|
|
1687
|
-
const interventionCount = inst.interventionCount ?? 0;
|
|
1688
|
-
if (interventionCount > 0) {
|
|
1689
|
-
v.interventionCount = interventionCount;
|
|
1690
|
-
if (inst.interventionAt !== undefined) v.interventionAt = inst.interventionAt;
|
|
1691
|
-
if (inst.interventionReason !== undefined) v.interventionReason = inst.interventionReason;
|
|
1692
|
-
}
|
|
1693
|
-
// v5.x — extended dashboard surface.
|
|
1694
|
-
if (Array.isArray(inst.toolCalls) && inst.toolCalls.length > 0) {
|
|
1695
|
-
v.toolCalls = inst.toolCalls.slice();
|
|
1696
|
-
}
|
|
1697
|
-
if (typeof inst.progress === "number") v.progress = inst.progress;
|
|
1698
|
-
if (inst.progressMessage !== undefined) v.progressMessage = inst.progressMessage;
|
|
1699
|
-
if (inst.processId !== undefined) v.processId = inst.processId;
|
|
1700
|
-
if (inst.runnerState !== undefined) v.runnerState = inst.runnerState;
|
|
1701
|
-
if (Array.isArray(inst.tags) && inst.tags.length > 0) v.tags = inst.tags.slice();
|
|
1702
|
-
if (inst.pausedAt !== undefined) v.pausedAt = inst.pausedAt;
|
|
1703
|
-
return v;
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
/**
|
|
1707
|
-
* Extract the canonical loop-guard error string from a tool part. The
|
|
1708
|
-
* part may carry the error either on `part.error` or on
|
|
1709
|
-
* `part.state.error` (per spec §4.1).
|
|
1710
|
-
*/
|
|
1711
|
-
function readToolError(part: { error?: string; state?: { error?: string } }): string | null {
|
|
1712
|
-
if (typeof part.error === "string" && part.error.length > 0) return part.error;
|
|
1713
|
-
if (part.state && typeof part.state.error === "string" && part.state.error.length > 0) {
|
|
1714
|
-
return part.state.error;
|
|
1715
|
-
}
|
|
1716
|
-
return null;
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
/**
|
|
1720
|
-
* v5.x — Read the tool name from a tool part. cline's wire format
|
|
1721
|
-
* is loose: the tool name may live on `part.tool`, `part.name`, or as
|
|
1722
|
-
* the value of the first entries. Returns "unknown" when nothing
|
|
1723
|
-
* matches.
|
|
1724
|
-
*/
|
|
1725
|
-
function readToolName(part: { tool?: unknown; name?: unknown; [k: string]: unknown }): string {
|
|
1726
|
-
const candidates = [part.tool, part.name];
|
|
1727
|
-
for (const c of candidates) {
|
|
1728
|
-
if (typeof c === "string" && c.length > 0) return c.slice(0, 200);
|
|
1729
|
-
}
|
|
1730
|
-
return "unknown";
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
/**
|
|
1734
|
-
* v5.x — Read the tool args from a tool part. cline may carry
|
|
1735
|
-
* `args`, `input`, or a JSON-stringified `arguments`. Returns "" when
|
|
1736
|
-
* nothing useful is found.
|
|
1737
|
-
*/
|
|
1738
|
-
function readToolArgs(part: { args?: unknown; input?: unknown; arguments?: unknown; [k: string]: unknown }): string {
|
|
1739
|
-
const candidates = [part.args, part.input, part.arguments];
|
|
1740
|
-
for (const c of candidates) {
|
|
1741
|
-
if (c === undefined || c === null) continue;
|
|
1742
|
-
if (typeof c === "string") return c.slice(0, MAX_TOOL_ARG_CHARS);
|
|
1743
|
-
try {
|
|
1744
|
-
return JSON.stringify(c).slice(0, MAX_TOOL_ARG_CHARS);
|
|
1745
|
-
} catch {
|
|
1746
|
-
// fall through
|
|
1747
|
-
}
|
|
1748
|
-
}
|
|
1749
|
-
return "";
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1752
|
-
/**
|
|
1753
|
-
* v5.x — Map the part's state.status (or top-level status) to our
|
|
1754
|
-
* ToolCallEntry status enum.
|
|
1755
|
-
*/
|
|
1756
|
-
function readToolStatus(part: { state?: { status?: unknown }; status?: unknown }): "running" | "ok" | "error" {
|
|
1757
|
-
const s = part.state?.status ?? part.status;
|
|
1758
|
-
if (typeof s !== "string") return "running";
|
|
1759
|
-
const lower = s.toLowerCase();
|
|
1760
|
-
if (lower === "ok" || lower === "success" || lower === "completed" || lower === "done") return "ok";
|
|
1761
|
-
if (lower === "error" || lower === "failed") return "error";
|
|
1762
|
-
return "running";
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
function readToolOutput(part: { state?: { output?: unknown; result?: unknown; status?: unknown; error?: unknown }; output?: unknown; result?: unknown }): string {
|
|
1766
|
-
const candidates = [part.state?.output, part.state?.result, part.output, part.result];
|
|
1767
|
-
for (const c of candidates) {
|
|
1768
|
-
if (c === undefined || c === null) continue;
|
|
1769
|
-
if (typeof c === "string") return c;
|
|
1770
|
-
try {
|
|
1771
|
-
return JSON.stringify(c);
|
|
1772
|
-
} catch {
|
|
1773
|
-
// fall through
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
return "";
|
|
1777
|
-
}
|
|
1778
|
-
|
|
1779
|
-
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
1780
|
-
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
1781
|
-
const timeout = new Promise<never>((_, reject) => {
|
|
1782
|
-
timer = setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms);
|
|
1783
|
-
});
|
|
1784
|
-
try {
|
|
1785
|
-
return await Promise.race([promise, timeout]);
|
|
1786
|
-
} finally {
|
|
1787
|
-
if (timer !== null) clearTimeout(timer);
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
1790
|
-
|
|
1791
|
-
/**
|
|
1792
|
-
* Serialize an unknown tool-args value into a string suitable for the
|
|
1793
|
-
* `toolCalls` history. JSON-serializes objects, truncates to
|
|
1794
|
-
* {@link MAX_TOOL_ARG_CHARS}. Returns `""` for empty/undefined.
|
|
1795
|
-
*/
|
|
1796
|
-
function truncateForToolHistory(value: unknown): string {
|
|
1797
|
-
if (value === undefined || value === null) return "";
|
|
1798
|
-
let s: string;
|
|
1799
|
-
try {
|
|
1800
|
-
s = typeof value === "string" ? value : JSON.stringify(value);
|
|
1801
|
-
} catch {
|
|
1802
|
-
s = String(value);
|
|
1803
|
-
}
|
|
1804
|
-
if (s.length > MAX_TOOL_ARG_CHARS) s = s.slice(0, MAX_TOOL_ARG_CHARS) + "…";
|
|
1805
|
-
return s;
|
|
1806
|
-
}
|