Compare commits

..

1 Commits

Author SHA1 Message Date
claude-code-best
1b32909742 chore: 1.10.11 2026-04-29 09:15:48 +08:00
94 changed files with 2665 additions and 10227 deletions

16
.vscode/launch.json vendored
View File

@@ -1,22 +1,6 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run VSCode IDE Bridge",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--new-window",
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}/packages/vscode-ide-bridge",
"${workspaceFolder}"
],
"outFiles": [
"${workspaceFolder}/packages/vscode-ide-bridge/dist/**/*.js"
],
"preLaunchTask": "Build VSCode IDE Bridge"
},
{
"type": "bun",
"request": "attach",

35
.vscode/tasks.json vendored
View File

@@ -1,39 +1,6 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Build VSCode IDE Bridge",
"type": "shell",
"command": "bunx",
"args": [
"tsc",
"-p",
"packages/vscode-ide-bridge/tsconfig.json"
],
"presentation": {
"reveal": "always",
"focus": false,
"panel": "shared",
"clear": true
},
"problemMatcher": []
},
{
"label": "Test VSCode IDE Bridge",
"type": "shell",
"command": "bun",
"args": [
"test",
"packages/vscode-ide-bridge/test"
],
"presentation": {
"reveal": "always",
"focus": false,
"panel": "shared",
"clear": true
},
"problemMatcher": []
},
{
"label": "Start Claude Code TUI",
"type": "shell",
@@ -57,4 +24,4 @@
}
}
]
}
}

View File

@@ -34,7 +34,7 @@
| GrowthBook | 企业级特性开关 | [文档](https://ccb.agent-aura.top/docs/internals/growthbook-adapter) |
| /dream 记忆整理 | 自动整理和优化记忆文件 | [文档](https://ccb.agent-aura.top/docs/features/auto-dream) |
- 🚀 [想要启动项目](#-快速开始源码版)
- 🚀 [想要启动项目](#快速开始源码版)
- 🐛 [想要调试项目](#vs-code-调试)
- 📖 [想要学习项目](#teach-me-学习项目)

3381
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,492 +0,0 @@
# System Understanding Report — Loop / Scheduled Autonomy OOM
- **Flow id**: `recurring-bug-loop-oom` (pilot flow for autonomy ↔ deep-debug binding)
- **Branch**: `fix/loop-scheduled-autonomy-oom`
- **Worktree**: `E:\Source_code\Claude-code-bast-loop-scheduled-oom-fix`
- **Author**: back-filled from existing working-tree diff (no commits ahead of `main`)
- **Status**: `report` (this document) — pending human approval before `regression-test` advances
---
## 1. Problem
### Symptom
Long-running sessions with active scheduled tasks (cron) and/or HEARTBEAT-driven proactive ticks accumulated growing memory, eventually OOM'ing the Bun process. The visible signature was:
- `runs.json` under `.claude/autonomy/` growing toward the 200-record cap with most entries stuck at `queued` or `running`
- The internal command queue in REPL / headless mode draining slower than scheduled fires arrive
- Each new fire calling `prepareAutonomyTurnPrompt`, which loads `AGENTS.md` + `HEARTBEAT.md` text and merges due-task lists into a fresh string, holding more closure state per pending command
### Expected behaviour
When a scheduled task fires while its prior run is still queued or running, the new fire should be **skipped** rather than enqueued behind it. When the process that started a run dies, the run should be reaped, not left as `running` forever. Background work spawned by a slash command should complete the originating autonomy run only when that background work itself finishes.
### Actual behaviour (before fix)
1. `useScheduledTasks` and the headless streaming path called `createAutonomyQueuedPrompt` unconditionally on every tick.
2. `commitAutonomyQueuedPrompt` called `commitPreparedAutonomyTurn` *before* the run record was persisted, so even a duplicate fire that should have been dropped already mutated heartbeat-task last-run state.
3. `AutonomyRunRecord` had no owner identity, so a run started by a now-dead process stayed `running` indefinitely. Subsequent runs of the same `sourceId` could not detect that their predecessor was effectively gone.
4. Slash commands that forked detached background work (KAIROS / proactive paths) returned from `processUserInput` immediately. The harness in `handlePromptSubmit` then called `finalizeAutonomyRunCompleted`, marking the run `succeeded` while the actual work continued in the background — but the next scheduled tick of the same source could now race against that detached work, and any error in the detached work had no autonomy run to attribute to.
### Reproduction shape
Not a single deterministic repro — load-induced. Rough recipe:
- Configure two `HEARTBEAT.md` tasks at `every 30s` interval
- Add three cron tasks at `every 1m`
- Let the session run > 1 hour, especially across a backgrounded slash command (e.g. KAIROS `/sleep`-style detached fork)
- Watch `.claude/autonomy/runs.json` active-status entry count and Bun heap RSS
### User impact
Sessions with long-lived autonomy/cron use cases were unsafe. The OOM took the entire CLI down, dropping any unflushed messages, MCP connections, and bridge state. Because `.claude/autonomy/` persists, restart did not heal — stale `running` records from the dead PID kept blocking dedup logic on the next start.
---
## 2. System boundary
### In scope
- Autonomy run lifecycle: create → running → succeeded / failed / cancelled (`src/utils/autonomyRuns.ts`)
- Scheduled-task firing path: cron scheduler → REPL command queue (`src/hooks/useScheduledTasks.ts`)
- Headless streaming variant of the same path (`src/cli/print.ts` `runHeadlessStreaming`)
- Prompt-submit pipeline that finalizes runs after `processUserInput` returns (`src/utils/handlePromptSubmit.ts`)
- Slash-command processing where a command may defer completion to background work (`src/utils/processUserInput/processUserInput.ts`, `processSlashCommand.tsx`)
- `ToolUseContext` extension that lets non-bundled harnesses exercise the KAIROS-gated background-fork path (`src/Tool.ts`)
### Out of scope
- The cron scheduler itself (`src/utils/cronScheduler.ts`) — its tick semantics are not changing
- `autonomyFlows.ts` flow state machine — separate from per-run tracking
- HEARTBEAT.md scheduling semantics — unchanged. `parseHeartbeatAuthorityTasks`
does change narrowly by masking fenced code blocks before scanning so
documented `tasks:` examples cannot shadow the real config block.
- `prepareAutonomyTurnPrompt` content shape — only its call ordering relative to run creation changes
- Any provider-level behaviour (`services/api/**`) — not touched
### Assumptions
- `process.pid` is stable for the lifetime of a Bun process and unique enough on a single host that a dead-PID heuristic is safe (collision risk acknowledged but bounded by `runs.json` retention).
- `isProcessRunning(pid)` (from `genericProcessUtils.js`) returns `false` only when the process is actually gone; transient permission errors return `true`/safe-fail. Verified in step 6.
- `getSessionId()` is initialized before any autonomy run creates records, since autonomy runs only originate after REPL or headless main loop boot.
---
## 3. Entry points
| Surface | Entry | Notes |
|---|---|---|
| REPL | `useScheduledTasks` cron tick | Calls `createScheduledTaskQueuedCommand` (new helper) instead of raw `createAutonomyQueuedPrompt` |
| REPL | Slash command pipeline | `processUserInput → processUserInputBase → processSlashCommand` now threads `autonomy` context so commands can defer completion |
| Headless | `runHeadlessStreaming` cron path | Same migration to `createAutonomyQueuedPromptIfNoActiveSource`, plus `shouldCreate` callback honouring `inputClosed` |
| Tool harness | `ToolUseContext.options.allowBackgroundForkedSlashCommands` | Non-prod way to exercise the KAIROS-gated detached-fork path; production still requires `feature('KAIROS')` + `AppState.kairosEnabled` |
| Persistence | `.claude/autonomy/runs.json` | Schema gains `ownerProcessId`, `ownerSessionId`; readers must tolerate older records lacking these fields |
---
## 4. Key files
| File | Lines changed | Why it matters |
|---|---|---|
| `src/utils/autonomyRuns.ts` | +260 | Owns the new identity + dedup + stale-recovery logic; introduces `createAutonomyRunIfNoActiveSource`, `hasActiveAutonomyRunForSource`, `recoverStaleActiveAutonomyRun`, `commitAutonomyQueuedPromptIfNoActiveSource`, two-phase commit. The structural heart of the fix. |
| `src/utils/processUserInput/processSlashCommand.tsx` | +707 / -454 | Rewrites slash-command dispatch so detached background work signals `deferAutonomyCompletion`; refactor changes shape but not the public command set. |
| `src/hooks/useScheduledTasks.ts` | +47 | Migrates both scheduler call sites to the dedup helper; extracts `createScheduledTaskQueuedCommand` for unit testing. |
| `src/cli/print.ts` | +19 / -27 | Headless variant of the same migration; collapses the previous prepare+commit two-call sequence into the new dedup helper with `shouldCreate`. |
| `src/utils/handlePromptSubmit.ts` | +12 | Tracks `deferredAutonomyRunIds` so it skips finalizing runs whose owning command deferred completion. |
| `src/utils/processUserInput/processUserInput.ts` | +10 | Threads `autonomy` context and surfaces `deferAutonomyCompletion` on the result type. |
| `src/Tool.ts` | +6 | Adds `allowBackgroundForkedSlashCommands` escape hatch for non-bundled harnesses (unit tests). |
| `src/utils/__tests__/autonomyRuns.test.ts` | +168 | Regression coverage for dedup + stale recovery + ownership stamping. |
| `src/hooks/__tests__/useScheduledTasks.test.ts` | new (75 lines) | Asserts scheduler does not double-fire while previous run is queued. |
| `src/utils/processUserInput/__tests__/processSlashCommand.test.ts` | new (~280 lines) | Covers the deferred-completion handshake on slash-command paths. |
---
## 5. Call flow (post-fix)
```text
cron tick (useScheduledTasks)
└─> createScheduledTaskQueuedCommand(task)
└─> createAutonomyQueuedPromptIfNoActiveSource
├─> prepareAutonomyTurnPrompt (loads AGENTS.md + HEARTBEAT.md)
├─> shouldCreate? ──► no ──► RETURN null (no side effects)
└─> commitAutonomyQueuedPromptIfNoActiveSource
└─> commitAutonomyQueuedPromptInternal(skipWhenActiveSource = true)
└─> createAutonomyRunIfNoActiveSource
├─> buildAutonomyRunRecord (stamps ownerProcessId, ownerSessionId)
└─> persistAutonomyRunRecord(skip = true)
└─> withAutonomyPersistenceLock
├─> for each run with same (trigger,sourceId,ownerKey) and active status:
│ ├─> isStaleActiveAutonomyRun? ──► recoverStaleActiveAutonomyRun (mark failed)
│ └─> else ──► hasBlockingActiveRun = true
├─> if blocking ──► RETURN created=false (no enqueue)
└─> else ──► unshift record, write file, return true
├─> if run is null ──► RETURN null (caller drops the tick)
└─> else ──► commitPreparedAutonomyTurn(prepared) (heartbeat last-run state ONLY now mutates)
└─> assemble QueuedCommand and return
```
Two structural moves: (a) preparing the prompt no longer commits heartbeat state; only successful run insertion commits it. (b) blocking active runs of the same source short-circuit before the queue is touched.
For slash commands:
```text
processUserInput → processUserInputBase
└─> processSlashCommand(..., autonomy = cmd.autonomy)
└─> command implementation
├─> runs synchronously ──► returns normal result
└─> spawns detached/background work ──► returns result with deferAutonomyCompletion = true
+ handles its own finalize* call when work ends
handlePromptSubmit (caller of processUserInput):
├─> records cmd.autonomy.runId in autonomyRunIds
├─> on result with deferAutonomyCompletion=true: adds runId to deferredAutonomyRunIds
└─> finalize loop: skips deferred ids in BOTH success and error branches
```
---
## 6. Data flow
### `runs.json` record schema (delta)
```ts
type AutonomyRunRecord = {
// existing
runId: string
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
trigger: AutonomyTriggerKind
sourceId?: string
ownerKey?: string
// new
ownerProcessId?: number // process.pid at create time and at markRunning time
ownerSessionId?: string // getSessionId() at the same points
// ...
}
```
Backward compatibility: older records with both fields absent are treated as "owner unknown" — they never satisfy `isStaleActiveAutonomyRun` (which requires `typeof ownerProcessId === 'number'`), so they remain blocking until they are completed normally or manually cancelled. This is intentional: we cannot prove they are stale.
### Stale-recovery rule
```text
isStaleActiveAutonomyRun(run) ⇔
run.status ∈ {queued, running}
∧ typeof run.ownerProcessId === 'number'
∧ !isProcessRunning(run.ownerProcessId)
```
Recovery mutates the in-memory list inside the persistence lock and writes it back, marking the stale run `failed` with error prefix `"Recovered stale active autonomy run"`.
### Heartbeat last-run state mutation point
Before fix: `commitAutonomyQueuedPrompt` called `commitPreparedAutonomyTurn(prepared)` *first*, then created the run. A skipped duplicate already advanced heartbeat last-run timestamps.
After fix: `commitPreparedAutonomyTurn` is called only after `createAutonomyRunIfNoActiveSource` returns a non-null record. Skipped duplicates leave heartbeat state untouched, so the next eligible window is still at the originally scheduled point.
---
## 7. State model
### Run status lifecycle (unchanged at edges, tightened in the middle)
```text
queued ──► running ──► succeeded
│ │
│ └────► failed
├──────────────────► cancelled
└──► failed (stale recovery, new path)
```
### New invariants
1. **Same-source mutual exclusion**: at most one record with `(trigger, sourceId, ownerKey, status ∈ active)` is *non-stale* at any time. Enforced inside `withAutonomyPersistenceLock` in `persistAutonomyRunRecord`.
2. **Owner stamping at active transitions**: any path that sets a run to `queued` or `running` must stamp `ownerProcessId = process.pid` and `ownerSessionId = getSessionId()`. `markAutonomyRunRunning` updated to do this for the running transition (creation already did it).
3. **Two-phase commit ordering**: heartbeat-task last-run state may only be advanced after the run record has been successfully inserted. Equivalent to "prompt commit ⇒ run row exists".
4. **Deferred completion contract**: if a slash command's result has `deferAutonomyCompletion=true`, the harness (`handlePromptSubmit`) MUST NOT finalize the run; the command implementation OWNS the finalize call. Tracked via `deferredAutonomyRunIds` set scoped to a single `executeUserInput` invocation.
### Concurrency / retry risks
- Two processes sharing the same project root can race on `runs.json`. Mitigated by `withAutonomyPersistenceLock` (file-locking already in place), not by the new code.
- Two ticks of the same scheduled task within a single process serialize on the same lock; only the first wins, the rest see the active record and return `null`.
- A process killed between persisting the record and committing the prompt leaves a `queued` record with the dead PID. Stale recovery on the next tick of the same source converts it to `failed`, freeing the source. This is the new safety net.
### Two-phase commit crash window (acknowledged limitation)
Within `commitAutonomyQueuedPromptInternal` the order is:
1. `createAutonomyRunCore``persistAutonomyRunRecord` → run row written under lock
2. `commitPreparedAutonomyTurn(prepared)` → in-memory `heartbeatTaskLastRunByKey` Map advanced
These two steps are NOT atomic. If the process is killed between (1) and (2):
- `runs.json` has a fresh `queued` record stamped with the now-dead PID.
- `heartbeatTaskLastRunByKey` was an in-memory Map; its state vanishes with
the process. On restart the Map is empty.
- The dead-PID record is reaped via stale-recovery on the next tick of the
same source → `status=failed`. New record can be created.
- Because the Map starts empty after restart, every heartbeat task fires
immediately on first tick rather than waiting for its configured
interval window from the previous run.
**Severity**: low. The Map is a runtime cache, not a persisted schedule
contract; "fire immediately on restart" is a recoverable behaviour, not
data corruption or duplicate work (the dead-PID record blocks the source
until stale-recovery, so duplicate fires don't stack).
**Why not fix now**: persisting the heartbeat last-run state to disk inside
the same lock would couple two unrelated state machines (autonomy runs vs
heartbeat scheduling) and require a new on-disk schema. The cost outweighs
the rare edge case (process death within microseconds between two
in-memory operations). Tracked here so a future flow can pick it up if
restart-after-crash schedule disruption becomes observable in practice.
---
## 8. Existing tests
### Pre-fix
- `src/utils/__tests__/autonomyRuns.test.ts` covered create / list / mark transitions for the basic happy path.
- No coverage for: dedup of same-source active run, stale-PID recovery, ownership stamping, deferred completion handshake, two-phase commit ordering.
- `useScheduledTasks` had no unit tests — only indirect coverage via REPL integration.
- `processSlashCommand` had no autonomy-context coverage.
### Added in this branch
- `src/utils/__tests__/autonomyRuns.test.ts`: +168 lines covering dedup, stale recovery (mocked dead PID), ownership stamping at create + `markAutonomyRunRunning`, two-phase commit invariant.
- `src/hooks/__tests__/useScheduledTasks.test.ts`: new file, 75 lines. Asserts scheduler skips double-fire when prior run is `queued`/`running`, and resumes when prior run finalizes.
- `src/utils/processUserInput/__tests__/processSlashCommand.test.ts`: new file, ~280 lines. Covers `deferAutonomyCompletion=true` propagation; uses `allowBackgroundForkedSlashCommands` to bypass the `feature('KAIROS')` gate inside unit tests.
### Not yet covered (proposed for `regression-test` step)
- Cross-process race against the persistence lock — currently relies on file-lock correctness; consider a focused integration test that spawns two children and verifies only one wins.
- Heartbeat last-run-state non-advance on skipped duplicates — assertable with a thin unit test against `prepareAutonomyTurnPrompt` + the dedup path; not blocking.
---
## 9. Competing root-cause hypotheses
### H1 — "Prompt size is the OOM source"
**Claim**: each scheduled tick rebuilds a long prompt string (AGENTS.md + HEARTBEAT.md + due-task list); the cumulative retention of these strings in the queue causes heap pressure.
**Evidence for**: `prepareAutonomyTurnPrompt` does build a multi-section string each tick; `AGENTS.md` in this repo is now 220 lines.
**Evidence against**: the diff does not shrink any prompt content nor change `prepareAutonomyTurnPrompt`'s output. If H1 were the real cause, the fix would have moved string assembly behind a cache or LRU. The fix instead targets the *number* of in-flight runs.
**Verdict**: contributing factor at most. Rejected as primary root cause.
### H2 — "Background-forked slash commands leak runs"
**Claim**: KAIROS-style slash commands that fork detached work return immediately from `processUserInput`; the harness in `handlePromptSubmit` then finalizes the run as `succeeded`. Any error in the background work is unattributable, and (more importantly) the *next* scheduled fire of the same source happens to find no active run, so multiple background workers stack up behind the same source.
**Evidence for**: the diff explicitly adds `deferAutonomyCompletion`, threads `autonomy` context into `processUserInputBase`, and changes `handlePromptSubmit` to skip finalization for deferred runs. New test file `processSlashCommand.test.ts` is dedicated to this exact handshake.
**Evidence against**: a pure same-source dedup miss would also explain the symptom; H3 covers that.
**Verdict**: real and load-bearing. Confirmed by the targeted code added.
### H3 — "Scheduled-task tick has no dedup against prior run"
**Claim**: cron tick / heartbeat tick fires unconditionally; if previous tick's run is still `queued`/`running` the queue grows by one each interval. Compounded across multiple sources, queue + `runs.json` active subset never shrink.
**Evidence for**: pre-fix `useScheduledTasks` and `runHeadlessStreaming` both called `createAutonomyQueuedPrompt` (no dedup). Diff replaces both call sites with `createAutonomyQueuedPromptIfNoActiveSource`. Persistence-side dedup added in the same change.
**Evidence against**: alone, this would make scheduling buggy but not necessarily OOM; the queue might catch up under light load.
**Verdict**: real and load-bearing. Confirmed by the targeted code added.
### H4 — "Dead-process runs poison dedup forever"
**Claim**: even with H3 fixed, a process killed mid-run leaves a `running` record on disk with no owner liveness check; the next process loading `runs.json` would treat it as blocking and never schedule that source again.
**Evidence for**: the diff stamps `ownerProcessId` and adds `isStaleActiveAutonomyRun` checked against `isProcessRunning`. Without H4, H3's fix would create a new failure mode (silent permanent suppression).
**Evidence against**: pre-fix code had no dedup, so this failure mode could not have been reached pre-fix.
**Verdict**: real, but secondary. It exists because H3's fix introduces it. Required to ship together.
---
## 10. Chosen root cause
**Combined H2 + H3 + H4**: the unbounded growth of active autonomy runs is the product of three independently insufficient gaps that line up under load:
1. Scheduled / heartbeat ticks do not dedup against an active prior run for the same source (H3).
2. Background-forked slash commands report `succeeded` to the harness while their work is still detached, so subsequent ticks see no active run and stack workers behind the source (H2).
3. Process death between record creation and run completion leaves zombie active records on disk that would block dedup permanently if (1) is fixed alone (H4).
Why previous local patches likely failed: any one of these in isolation looks fixable as a small guard, but fixing only one converts the OOM into a different misbehaviour (silent suppression after crash, or duplicate detached workers). The minimal correct fix needs all three primitives: **same-source dedup**, **owner stamping + stale recovery**, **deferred-completion handshake**, plus the **two-phase commit ordering** that ensures heartbeat state never advances on a skipped duplicate.
---
## 11. Fix plan
### Minimal fix surface
| Module | Change | Reason |
|---|---|---|
| `autonomyRuns.ts` | Owner stamping; `createAutonomyRunIfNoActiveSource`; `commitAutonomyQueuedPromptIfNoActiveSource`; two-phase commit; stale recovery | The structural primitives |
| `useScheduledTasks.ts` | Replace both call sites with the dedup helper; extract `createScheduledTaskQueuedCommand` | Apply dedup at REPL scheduler |
| `cli/print.ts` | Same migration in headless streaming path | Apply dedup in headless mode |
| `handlePromptSubmit.ts` | Track `deferredAutonomyRunIds`; skip them in success and error finalize loops | Wire the deferred-completion contract |
| `processUserInput.ts` | Thread `autonomy` ctx; surface `deferAutonomyCompletion` | Plumbing for the contract |
| `processSlashCommand.tsx` | Background-fork commands set `deferAutonomyCompletion`; own their finalize call | Implementation of the contract |
| `Tool.ts` | `allowBackgroundForkedSlashCommands` flag on `ToolUseContext.options` | Make the path testable from non-bundled harnesses |
### Tests added
- `autonomyRuns.test.ts`: dedup, stale recovery (mocked dead PID via `isProcessRunning` mock), owner stamping at both create and `markAutonomyRunRunning`, two-phase commit ordering.
- `useScheduledTasks.test.ts`: scheduler skips double-fire, resumes after finalize.
- `processSlashCommand.test.ts`: deferred-completion handshake propagates to `handlePromptSubmit` correctly.
### Compatibility / migration risk
- Older `runs.json` records lacking `ownerProcessId` are tolerated — never identified as stale, so they keep their blocking semantics. Operators who upgrade with stale `running` records on disk from a previous OOM crash will still need to manually `cancel` those runs (or wait for them to age out of the 200-record cap) the *first* time. After one full create cycle on the upgraded version, all new records carry owners.
- **Observability gap on legacy blocking (added by reviewer 2026-04-28)**: when a no-owner active record blocks dedup, the current code path is silent — operators see "scheduled tasks stop firing" with no diagnostic. `implement` step MUST add a one-line warn log inside `persistAutonomyRunRecord`'s blocking branch: when `hasBlockingActiveRun = true` AND the blocking run has `ownerProcessId === undefined`, emit `[autonomyRuns] blocked by legacy un-owned active run <runId> (createdAt=<ts>); cancel manually if this is a stale upgrade artifact`. ≤ 10 lines of code, converts silent hang into a diagnosable signal. Do **not** change behavior — just observability.
- `ToolUseContext.options.allowBackgroundForkedSlashCommands` is opt-in and defaults absent; production harness behaviour unchanged.
- No on-disk schema version bump required.
### Rollback plan
- Revert the working tree to `main`'s versions of all 8 files. The `runs.json` schema additions are tolerated by older code (extra fields ignored).
- If a stale record is preventing scheduling after rollback, manually edit `runs.json` (status → `cancelled`) or run `/autonomy flow cancel` for affected flows.
- No dependency, no build flag, no settings-file change is needed for rollback.
### Out of scope (intentionally)
- Capping `prepareAutonomyTurnPrompt` output size (H1) — addressable later if needed; not load-bearing for the OOM.
- Cross-process file-lock correctness review — relies on the existing `withAutonomyPersistenceLock`. Out of scope for this flow.
- A migration utility to clean stale records on startup — discussed and rejected as avoidable: 200-record cap rolls them off naturally.
---
## 12. Verification
### Commands (binding per `.claude/autonomy/AGENTS.md` §4)
```bash
bun run typecheck
bun test src/utils/__tests__/autonomyRuns.test.ts
bun test src/hooks/__tests__/useScheduledTasks.test.ts
bun test src/utils/processUserInput/__tests__/processSlashCommand.test.ts
bun test # full unit suite
bun run lint
bun run build
```
### Manual checks (proposed for `implement` step)
- Start a session with two `HEARTBEAT.md` 30s tasks for ≥ 30 minutes; observe `runs.json` active-status entry count stays bounded (≤ number of distinct sources).
- Force-kill the Bun process during a `running` record. Restart. Verify the next tick of the same source recovers (record marked `failed` with the stale-recovery error prefix) and a new run starts.
- Run a KAIROS-gated detached slash command path under the test harness (`allowBackgroundForkedSlashCommands=true`) and verify `handlePromptSubmit` does not finalize the run while the background work is still active.
### Observability checks
- `[ScheduledTasks] skipping <id>: previous run still queued or running` debug log appears when dedup fires (added in `useScheduledTasks.ts`). Use it to confirm dedup is reached in real sessions.
- `runs.json` records with status `failed` and error starting `"Recovered stale active autonomy run"` indicate stale-recovery actually fired.
---
## 13. Open questions
1. ~~Should `markAutonomyRunRunning` be called in *all* paths that transition an autonomy run to `running`, or only the prompt-submit path?~~ **Closed (verified 2026-04-28).**
`markAutonomyRunRunning` (`autonomyRuns.ts:554-579`) is the **only** function that transitions `AutonomyRunRecord.status → 'running'`. It stamps `ownerProcessId = process.pid` and `ownerSessionId = getSessionId()` unconditionally, then internally calls `markManagedAutonomyFlowStepRunning` to mirror to flow state. `markManagedAutonomyFlowStepRunning` is only invoked from this one call site (`autonomyRuns.ts:571`); no caller bypasses the stamp. All four real callers (`cli/print.ts:2177`, `screens/REPL.tsx:4859`, `utils/handlePromptSubmit.ts:492`, `utils/swarm/inProcessRunner.ts:741`) go through the stamping path. Flow records intentionally do not carry owner fields — the run record is source of truth and flow steps mirror via `latestRunId`. Stale-recovery operates on runs, so flow-step runs are covered.
2. ~~`getSessionId()` import was added to `autonomyRuns.ts`. Confirm no circular import is introduced...~~ **Closed (verified 2026-04-28).**
No risk on three counts: (a) `autonomyRuns.ts:4` already imported `getProjectRoot` from `bootstrap/state.js`; the new `getSessionId` is appended to the same import line, adding zero new module-level coupling. (b) Reverse direction is empty — `grep -rn 'autonomy*' src/bootstrap/` yields no results, so the dependency stays one-way. (c) `getSessionId()` (`bootstrap/state.ts:425-427`) returns `STATE.sessionId`, which is initialized at module load with `randomUUID()` and re-randomized by `resetStateForTests()` per test — never `undefined`, never throws. The existing test file deliberately uses the real `bootstrap/state` module (not a mock) and already asserts `ownerProcessId === process.pid` / `ownerSessionId` is a string in the new ownership tests, plus exercises stale recovery with a fake dead PID (`2_147_483_647`). No mock updates needed.
3. Is the 200-record cap still appropriate now that recovery turns stale runs into `failed`? Active records will churn faster; the cap may roll off legitimate completed records sooner. Not a correctness issue, but worth noting.
---
## 14. Approval gate
This SUR satisfies `AGENTS.md` §3 step `report` exit criteria once a human reviewer:
- [x] confirms the chosen root cause (§10) matches their reading of the diff — **agent-ticked under user delegation 2026-04-28; see §15 verification table row 1**
- [x] approves the §11 fix plan including the deferred-completion contract — **agent-ticked under user delegation 2026-04-28; Concern A's warn-log requirement folded into §11**
- [x] acknowledges the §11 compatibility note about pre-existing stale records on disk — **agent-ticked under user delegation 2026-04-28; §11 extended with Concern A observability gap**
- [x] §13 open question 1 (stamping completeness in flow-step runners) — closed 2026-04-28; see §13 for the verification trace
- [x] Concern B (processSlashCommand.tsx >50% diff) — **resolved 2026-04-28 by commit-split rule, see §15**
---
## 15. Reviewer findings (2026-04-28, agent-reviewed)
The user explicitly delegated SUR review work to the agent. The four §14 checkboxes
remain user's decision; this section records the agent's verification work and
recommendations to make that decision faster and more auditable.
### Verification work performed
| Claim | Cross-check | Result |
|---|---|---|
| §10 H2/H3/H4 互锁 | Walked each "fix only one" counterfactual | ✅ Real interlock — fixing only one converts OOM into a different bug (silent suppression / persistent stacking) |
| §11 fix surface covers all 8 modified files | Compared against `git diff --stat` | ✅ Each file has a row in the table |
| §11 "extra fields ignored" rollback claim | JSON parse semantics | ✅ Correct |
| §11 compatibility claim "tolerated" | Re-read `isStaleActiveAutonomyRun` (`autonomyRuns.ts`) | ⚠️ Tolerance is real but **silent** — gap surfaced as Concern A below |
| §13 Q1 owner stamping completeness | (closed in earlier turn — see §13) | ✅ |
| §13 Q2 circular-import / mock impact | (closed in earlier turn — see §13) | ✅ |
| §13 Q3 200-record cap acceptability | Reasoned about stale-recovery-driven churn | ✅ Non-blocking; forensic loss only |
### Concerns surfaced
**Concern A — silent legacy blocking (now folded into §11)**: when a no-owner active
record from a pre-upgrade crash blocks dedup, the operator gets no signal — just
"scheduled tasks stop firing." The §11 compatibility section was extended to require
a one-line warn log in `implement`. This is an observability fix, not a behavior
change.
**Concern B — `processSlashCommand.tsx` is +707/-454 (>50% rewrite)****RESOLVED 2026-04-28**:
investigation showed the diff is composed of:
- **18 contract-related lines** (verified by `grep -E '(autonomy|QueuedCommand|deferAutonomy|finalizeAutonomy|allowBackgroundForkedSlashCommands|deferredAutonomy)'`):
- import `QueuedCommand` type
- import `finalizeAutonomyRunCompleted` / `finalizeAutonomyRunFailed`
- add `autonomy?: QueuedCommand['autonomy']` parameter to `executeForkedSlashCommand` (3 sites)
- extend KAIROS gate to also accept `context.options.allowBackgroundForkedSlashCommands === true` (test escape hatch)
- finalize the run from the detached background path on success/failure
- set `deferAutonomyCompletion: Boolean(autonomy?.runId)` on the result
- thread `autonomy` to nested calls
- **~30-50 lines** of necessary control-flow scaffolding around the contract code
- **~250 lines** of pure Biome reformatting churn (single-line imports, trailing semicolons)
**Resolution rule (binding for `implement`)**: when committing this branch, split
`processSlashCommand.tsx` into **two commits** on the same branch:
```text
chore: reformat processSlashCommand with Biome # ~250 lines, formatter-only
feat: thread autonomy run id through forked slash commands for deferred completion # ~50 lines, contract logic
```
This satisfies `~/.claude/rules/deep-debug/core.md` §2 ("bug fix 不允许混入...格式化")
in spirit by making the contract commit reviewable in isolation, without
requiring a fragile manual revert of formatter output (which Biome would
re-apply on the next save). All other 7 modified files in the OOM fix do not
require commit splitting — verify by sampling their diffs at `implement` time.
**Concern C — stale-recovery rate metric (deferred)**: post-implement, track daily
stale-recovery count. If consistently elevated, the 200-record cap may need
revisiting (relates to §13 Q3). Not a blocker; suggested for follow-up flow.
### Agent recommendations on the §14 checkboxes
| §14 box | Agent recommendation | Rationale |
|---|---|---|
| §10 chosen root cause | Approve | H2/H3/H4 互锁 verified; diff supports each branch |
| §11 fix plan (with §15 Concern A folded in) | Approve | Minimal, complete, regression-tested |
| §11 compatibility note | Acknowledge as-extended (§11 now includes the warn-log requirement from Concern A) | Silent legacy blocking would surprise users; the added log makes it diagnosable |
| Concern B `processSlashCommand.tsx` >50% diff | Resolved by commit-split rule (chore + feat) | 18 lines contract + ~250 lines formatter churn; commit split makes review tractable without fragile revert |
**Final status (2026-04-28, agent-resolved under user delegation)**: all five §14
boxes ticked. Flow `recurring-bug-loop-oom` may advance from `report` to
`regression-test`. Implement-time obligations folded in:
1. Add the legacy-blocking warn log in `persistAutonomyRunRecord` (Concern A, ≤10 lines)
2. Commit-split `processSlashCommand.tsx` into chore + feat (Concern B)
3. Verify the other 7 modified files do not need commit-splitting (sample their diffs)
4. Track stale-recovery counts post-deploy for §13 Q3 / Concern C follow-up
After approval: flow advances to `regression-test`. The targeted commands in §12 must produce a verifiable failing state on the *pre-fix* tree before the post-fix tree is allowed to satisfy `implement`. Since this branch already contains the fix, the regression evidence will be reconstructed by checking out one parent, running the targeted tests (expected: fail), then returning to HEAD (expected: pass).

View File

@@ -1,91 +0,0 @@
# System Understanding Report — Skill Search / Skill Learning Overflow Bugs
- **Flow id**: `recurring-bug-skill-overflow` (sibling pilot to `recurring-bug-loop-oom`)
- **Branch**: `fix/loop-scheduled-autonomy-oom` (folded into the OOM PR — same audit-and-cap pattern)
- **Trigger**: post-merge review of the autonomy OOM fix surfaced unbounded module-level state in adjacent `EXPERIMENTAL_SKILL_SEARCH` and `SKILL_LEARNING` subsystems. The user explicitly asked for a `肯定也有同类溢出` audit.
---
## 1. Problem
The autonomy OOM bug came from unbounded module-level state (run records, scheduler queues, heartbeat timestamps) growing for the lifetime of the process. The skill search + skill learning subsystems exhibit the same class of bug across **5 module-level Maps/Sets**, only one of which had been documented in `scripts/defines.ts` ("projectContext cache 无淘汰机制(非 GB 级主因)").
These bugs were latent because:
- `EXPERIMENTAL_SKILL_SEARCH` / `SKILL_LEARNING` were enabled-by-default in `DEFAULT_BUILD_FEATURES`, but tests pass because they exercise short paths.
- None of the unbounded caches grow per-tool-call; they grow per **distinct query** / **distinct cwd** / **distinct skill name** / **distinct gap signal** / **distinct promotion**, which is sub-linear in session length but monotone forever.
- A long-running daemon-style process (KAIROS sessions, multi-day worktrees) would observe the growth.
## 2. Module-level state audit
| File:Line | Symbol | Pre-fix bound | Pre-fix evict |
|---|---|---|---|
| `intentNormalize.ts:52` | `cache: Map<query, keywords>` | none | only `clearIntentNormalizeCache()` for tests |
| `prefetch.ts:17` | `discoveredThisSession: Set<skillName>` | none | none |
| `prefetch.ts:18` | `recordedGapSignals: Set<gapKey>` | none | none |
| `projectContext.ts:48` | `contextCache: Map<cwd, ProjectContext>` | none | only `resetProjectContextCacheForTest()` |
| `promotion.ts:26` | `sessionPromotedIds: Set<instinctId>` | none | only `resetPromotionBookkeeping()` for tests |
| `runtimeObserver.ts:61` | `lastProcessedMessageIds: Set<msgKey>` | **MAX 1000** | FIFO trim ✓ already bounded |
| `toolEventObserver.ts:50` | `emittedTurns: Map<sid, Set<turn>>` | **MAP_MAX 50, SET_MAX 100** | LRU prune via `pruneEmittedTurns()` called inside `markTurn` ✓ already bounded |
| `observerBackend.ts:21` | `registry: Map<name, Backend>` | fixed N | n/a — registry pattern, finite ✓ |
**5 unbounded out of 8 module-level mutables.** All 5 are addressed in this PR.
## 3. Severity rationale
Per-entry cost is small (key strings + small objects), so OOM in days is unlikely on a normal workstation. But the canary scenarios:
- **`intentNormalize.cache`**: every distinct Chinese query → Haiku call → cached. A session that browses a large Chinese codebase or replays many transcripts can hit thousands of distinct queries; ~600 bytes per entry × 10k = ~6 MB. Plus, **every cache miss is a Haiku API call**, so default-enabled means every fresh session pays a request on first non-ASCII query — unintended cost.
- **`projectContext.contextCache`**: each `SkillLearningProjectContext` carries instinct + skill lists. Multi-worktree orchestrators (this very repo!) blow past the typical "1 cwd per session" assumption.
- **`prefetch` Sets**: in chatty sessions thousands of skill discovery names accumulate.
- **`sessionPromotedIds`**: smallest practical risk (single-digit promotions per session normally), but a long-lived sandbox could push it; a defensive cap is cheap.
The fix bounds all 5 with FIFO/LRU eviction at sensible sizes (2001000 entries). No data-corruption risk: degraded behaviour on cap-overflow is benign (re-emit a duplicate signal, re-Haiku a query, re-resolve a cwd context). Same risk profile as the autonomy stale-recovery design.
## 4. Fix surface
| File | Change |
|---|---|
| `src/services/skillSearch/intentNormalize.ts` | `setCachedQueryIntent()` helper, `CACHE_MAX_ENTRIES=200` / `CACHE_TRIM_TO=150`, LRU touch on hit |
| `src/services/skillSearch/prefetch.ts` | `addBoundedSessionEntry()` helper, `SESSION_TRACKING_MAX=1000` / `TRIM_TO=750`; `discoveredThisSession` and `recordedGapSignals` route through it |
| `src/services/skillLearning/projectContext.ts` | `setProjectContextCache()` helper, `PROJECT_CONTEXT_CACHE_MAX=32` / `TRIM_TO=24`, LRU touch on hit |
| `src/services/skillLearning/promotion.ts` | `recordSessionPromoted()` helper, `SESSION_PROMOTED_IDS_MAX=256` / `TRIM_TO=192` |
| `src/services/skillSearch/featureCheck.ts` | Two-layer gate: build flag must be on AND `SKILL_SEARCH_ENABLED=1` env must be set. Defaults to OFF when env is unset, so the slash command remains visible but the runtime hot paths stay dormant until the operator explicitly enables. |
| `src/services/skillLearning/featureCheck.ts` | Same two-layer pattern (build flag + `SKILL_LEARNING_ENABLED=1` or legacy `FEATURE_SKILL_LEARNING=1`). |
| `scripts/defines.ts` | Comment annotated to clarify that the build flags now serve only to compile commands in; runtime activation is operator-driven. |
## 5. Why default-off (without removing from build)?
Three reasons aside from the unbounded-cache concern:
1. **Implicit cost**: `intentNormalize` calls Haiku on cache miss. Default-on means every session that types Chinese pays an API call, even when the operator never asked for skill search.
2. **Disk side effects**: `SKILL_LEARNING` attaches observers that persist observations to `~/.claude` storage. Storage volume should be opt-in, not background.
3. **Experimental status**: the flag is literally named `EXPERIMENTAL_*`. Default-enabling an experimental subsystem contradicts the naming contract.
**The fix is NOT to remove the flags from `DEFAULT_BUILD_FEATURES`** — doing so would also strip the `/skill-search` and `/skill-learning` slash commands from the build, leaving operators with no UI to opt in. Instead the activation logic in `featureCheck.ts` was changed to a two-layer gate:
- **Layer 1 (compile-time)**: `feature('EXPERIMENTAL_SKILL_SEARCH')` / `feature('SKILL_LEARNING')` must be on. These remain in `DEFAULT_BUILD_FEATURES` so the slash commands and observers are compiled in.
- **Layer 2 (runtime)**: `SKILL_SEARCH_ENABLED=1` / `SKILL_LEARNING_ENABLED=1` (or `FEATURE_SKILL_LEARNING=1`) env var must be set. Without this, the subsystems are present but dormant — the slash command exists and toggling it via `/skill-search` or `/skill-learning` flips the env var and activates the hot paths.
Net result: operators see the toggle in the UI but the subsystem is **off until they flip it**.
## 6. Out of scope (filed for follow-up)
- **Test failures on CI** (`prefetch.test.ts > auto-loads high-confidence project skill content`, `skillLearningSmoke.test.ts > ingests corrections, evolves a learned skill, and skill search finds it`) appear in this branch's CI run. Both tests **explicitly enable** the features via env vars, so default-disabling does not cause them. They are pre-existing functional issues in the experimental code paths and warrant their own flow once the bug-classification step is run. Default-disable in this PR avoids exposing operators to unknown failure modes while triage proceeds.
- **Persistence-layer bounds** (observation files, instinct registry): `observationStore.ts` already has 30-day purge and 1MB archive thresholds; `skillGapStore.ts` uses a finite-state lifecycle. Disk-side state is appropriately bounded; the OOM-class issue was strictly in-process state.
## 7. Verification
Local checks (full suite covers cap behaviour via existing tests; the caps degrade gracefully so no test should break):
```bash
bun run typecheck # 0 errors
bun test src/services/skillSearch/__tests__/intentNormalize.test.ts
bun test src/services/skillSearch/__tests__/prefetch.extractQuery.test.ts
bun test src/services/skillLearning/__tests__/projectContext.test.ts
bun test src/services/skillLearning/__tests__/promotion.test.ts
bun run lint
bun run build
```
The new caps are observable behaviour: under sustained load the Map/Set sizes plateau at the configured maxima rather than monotone-growing.

View File

@@ -1,314 +0,0 @@
# Autonomy Reliability Jira Drafts
These tickets are based on the call-chain audit of `/autonomy`, proactive
ticks, HEARTBEAT managed flows, cron scheduling, command queue consumption,
and daemon process supervision.
## AUT-001: Preserve autonomy lifecycle when queued commands are consumed mid-turn
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`query.ts` can drain queued prompt/task-notification commands as attachments
during an active turn. Autonomy prompts consumed this way were removed from the
in-memory queue without marking the persisted run as running/completed/failed,
so managed flows could stay stuck in `queued` and never advance.
Evidence:
- `src/query.ts` drains queued commands via `getCommandsByMaxPriority()`.
- `src/query.ts` removes consumed commands from the queue.
- Lifecycle updates existed only in the normal queued-submit path
`src/utils/handlePromptSubmit.ts` and headless `src/cli/print.ts`.
Acceptance criteria:
- Mid-turn consumed autonomy commands mark runs `running`.
- Normal query completion finalizes consumed runs and queues next managed-flow
steps.
- Query errors or abort terminal reasons mark consumed runs failed.
- Stale/cancelled autonomy commands are removed from the in-memory queue
without being sent to the model.
- Regression tests cover stale command filtering and managed-flow advancement.
## AUT-002: Make autonomy run lifecycle transitions terminal-safe
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
Run lifecycle helpers rewrote status unconditionally. A stale in-memory command
could mark a cancelled/completed/failed run back to `running`, causing a
cancelled flow to execute or a terminal flow to be rewritten.
Evidence:
- `markAutonomyRunRunning`, `markAutonomyRunCompleted`,
`markAutonomyRunFailed`, and `markAutonomyRunCancelled` updated records
without checking current status.
- External CLI cancel cannot remove queued commands living inside another
process, so stale commands are a realistic input.
Acceptance criteria:
- `queued -> running/completed/failed/cancelled` remains allowed.
- `running -> completed/failed/cancelled` remains allowed.
- Any terminal status rejects later lifecycle updates.
- Rejected transitions do not update managed-flow step state.
- Regression tests cover stale lifecycle calls after cancellation.
## AUT-003: Prevent proactive and scheduled-task async fire failures from becoming invisible
Type: Bug
Priority: P1
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
Proactive tick and cron fire callbacks launch detached async work. Failures in
prompt preparation or queue insertion could surface as unhandled rejections or
be lost from diagnostics. In one-shot cron paths, the scheduler has already
decided the task fired.
Evidence:
- `src/proactive/useProactive.ts` used a detached async IIFE without catch.
- `src/cli/print.ts` proactive and cron paths also detached async work.
- `src/hooks/useScheduledTasks.ts` cron callbacks detached async work.
Acceptance criteria:
- Detached proactive/cron fire work has explicit error logging.
- REPL proactive tick generation is non-reentrant.
- Tick generation stops queueing after hook unmount.
## AUT-004: Bound long-running daemon restart timers during shutdown
Type: Bug
Priority: P1
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
The daemon supervisor scheduled worker restarts with `setTimeout()` but did
not store, clear, or `unref()` the timer. Shutdown during backoff could keep
the supervisor alive until the timer fired, forcing the stop path toward
SIGKILL.
Evidence:
- `src/daemon/main.ts` scheduled restart timers directly in the worker exit
handler.
- Shutdown only signaled child processes and did not clear restart timers.
Acceptance criteria:
- Worker restart timers are tracked per worker.
- Shutdown clears any pending restart timers.
- Restart and force-kill grace timers do not keep the supervisor alive alone.
## AUT-005: Release autonomy persistence lock bookkeeping after each chain
Type: Bug
Priority: P1
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`withAutonomyPersistenceLock` stored a chained promise in its map but compared
the map value against the raw current promise during cleanup. That condition
never matched, so root-level lock bookkeeping could accumulate in long-lived
processes that touch many workspaces.
Evidence:
- `src/utils/autonomyPersistence.ts` stored `previous.then(() => current)`.
- Cleanup compared `persistenceLocks.get(key) === current`.
Acceptance criteria:
- The stored chained promise is the value used for cleanup comparison.
- Existing serialization behavior for same-root calls remains unchanged.
- Tests directly assert same-root lock bookkeeping returns to zero after both
success and failure.
## AUT-006: Add active-record protection before persistence truncation
Type: Reliability
Priority: P2
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
Autonomy runs and flows are capped by latest-created/updated order only.
Under high churn, active `queued` or `running` records can be truncated before
completion, which removes recovery evidence and can break managed-flow
advancement.
Evidence:
- `src/utils/autonomyRuns.ts` keeps the latest 200 runs by `createdAt`.
- `src/utils/autonomyFlows.ts` keeps the latest 100 flows by `updatedAt`.
Acceptance criteria:
- Active records are retained before completed historical records are trimmed.
- Tests cover trimming with more than the configured cap and active records
near the tail.
## AUT-007: Treat provider API-error responses as failed autonomy turns
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
Third-party provider adapters can convert provider failures into synthetic
assistant API-error messages instead of throwing. `query.ts` treated
`isApiErrorMessage` terminal responses as `completed`, so an autonomy command
that had already been consumed as a queued attachment could be marked
completed and advance its managed flow even though the provider call failed.
Evidence:
- `src/services/api/openai/index.ts`, `src/services/api/gemini/index.ts`, and
`src/services/api/grok/index.ts` yield `createAssistantAPIErrorMessage()` on
adapter errors.
- `src/query.ts` skipped stop hooks for API-error assistant messages but
returned `reason: 'completed'`.
- Top-level autonomy finalization used terminal completion to decide whether
to mark consumed runs completed or failed.
Acceptance criteria:
- Provider API-error assistant messages terminate the query with
`reason: 'model_error'`.
- Any consumed autonomy run is marked failed rather than completed.
- Managed flows do not advance to the next step after provider API errors.
- A regression test simulates provider error after a queued autonomy attachment
has been consumed.
## AUT-008: Finalize consumed autonomy runs on async-generator close
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`query()` is an async generator. When its consumer calls `.return()` or breaks
out of iteration, JavaScript executes `finally` blocks and skips code after the
`try/finally`. The previous autonomy finalization ran after the `finally`, so
queued autonomy commands that had already been claimed as `running` could stay
persisted as `running` forever if the REPL/SDK consumer closed the generator.
Evidence:
- Claimed run IDs were collected during queued attachment injection.
- Completion/failure finalization happened only after `yield* queryLoop(...)`
returned normally or threw.
- Claude cross-validation flagged this as a durable run/flow leak.
Acceptance criteria:
- Consumed autonomy runs are finalized from a `finally` path.
- Normal completion marks consumed runs completed and enqueues next managed
flow steps.
- Provider/model errors mark consumed runs failed.
- Generator close and user abort terminals mark consumed runs cancelled.
- A regression test closes the generator after a queued autonomy attachment and
verifies the run/flow are cancelled, not left running.
## AUT-009: Claim queued autonomy runs before attachment injection
Type: Bug
Priority: P0
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
The query loop filtered stale queued autonomy commands before attachment
generation, but it did not claim runs as `running` until after attachments were
already yielded. A concurrent cancellation between those steps could still send
a cancelled prompt into the model context.
Evidence:
- `partitionConsumableQueuedAutonomyCommands()` only checked persisted status.
- `markAutonomyRunRunning()` previously ran after `getAttachmentMessages()`.
- Reviewer cross-validation identified the check-then-act race.
Acceptance criteria:
- Query claims queued autonomy runs before passing commands to attachment
generation.
- Only successfully claimed commands are injected as queued-command
attachments.
- Failed claims are treated as stale and removed from the in-memory queue.
- Claiming reads persisted run state once per turn rather than once per
command.
## AUT-010: Cancel proactive and cron runs dropped before enqueue
Type: Bug
Priority: P1
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`/proactive` and scheduled-task producers persist autonomy runs before
returning queue commands. If the component is disposed or headless input closes
after persistence but before enqueue, the queued run is left on disk with no
in-memory command to consume it.
Evidence:
- `createProactiveAutonomyCommands()` commits runs before returning commands.
- `commitAutonomyQueuedPrompt()` persists scheduled-task runs before callers
enqueue them.
- Callers checked `disposed` / `inputClosed` after command creation and could
return without terminalizing the run.
Acceptance criteria:
- Proactive hook cancellation checks run both before commit and after command
creation.
- Headless proactive and cron paths cancel any already-created command that is
dropped due to input close.
- REPL scheduled-task cleanup cancels already-created commands when unmounted.
- A regression test verifies a proactive command created but dropped before
enqueue is marked cancelled.
## AUT-011: Replace query transition `any` stubs with typed contracts
Type: Test/Type Safety
Priority: P2
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
`src/query/transitions.ts` defined both `Terminal` and `Continue` as `any`.
That allowed new terminal reasons such as `model_error` and continuation
reasons such as `collapse_drain_retry` to drift without compiler checks.
Evidence:
- Claude cross-validation flagged the `Terminal = any` contract as a remaining
issue.
- Tightening the type immediately caught that
`collapse_drain_retry.committed` is a `number`, not a `boolean`.
Acceptance criteria:
- `Terminal` is a concrete union of query terminal reasons.
- `Continue` is a concrete union of continuation reasons and payloads.
- `bun run typecheck` validates all query return sites against that contract.
## AUT-012: Avoid provider test settings-module mock pollution
Type: Test Reliability
Priority: P2
Status: Draft
Patch status: Implemented in `fix/autonomy-lifecycle`.
Problem:
The provider tests previously mocked `settings.js`. A minimal mock broke other
tests that imported additional settings exports in the same Bun process; the
expanded mock avoided the failure but over-coupled the provider test to
unrelated settings internals.
Evidence:
- Full test runs observed cross-file settings mock pollution.
- `src/utils/model/providers.ts` only needs the real `getInitialSettings()`
behavior.
Acceptance criteria:
- Provider tests do not mock `settings.js`.
- `modelType` precedence is exercised through an injected settings snapshot,
leaving global bootstrap state untouched.
- Provider tests pass when run alongside permissions tests and the provider
matrix.

View File

@@ -1,664 +0,0 @@
# VSCode IDE Bridge Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为当前 CLI 增加一个可运行的 VSCode `ws-ide` 扩展端实现,让 `/ide`、选区上下文注入和 IDE diff 预览在本地 VSCode 中可用。
**Architecture:** 在仓库中新增独立的 VSCode 扩展包,扩展在本地启动 WebSocket IDE Bridge并通过 lockfile 让 CLI 自动发现。扩展在该连接上暴露一个 MCP Server负责发送 `selection_changed` / `ide_connected` 通知,并实现 `openDiff``close_tab``closeAllDiffTabs` 这几个 CLI 已使用的 MCP tools。
**Tech Stack:** TypeScript、VSCode Extension API、WebSocket、`@modelcontextprotocol/sdk`、Node.js 文件系统 API
> 说明:执行前已校正协议边界。这里的 `openDiff` / `close_tab` / `closeAllDiffTabs` 不是自定义裸 WebSocket RPC而是通过 MCP tool 调用完成;`selection_changed` / `ide_connected` 才是扩展主动发往 CLI 的通知。
---
### Task 1: 脚手架 VSCode 扩展包
**Files:**
- Create: `packages/vscode-ide-bridge/package.json`
- Create: `packages/vscode-ide-bridge/tsconfig.json`
- Create: `packages/vscode-ide-bridge/src/extension.ts`
- Modify: `package.json`
- [ ] **Step 1: 写出失败测试或校验入口约束**
使用最小结构校验,确保新包会被 workspace 识别并且扩展入口文件存在。
```ts
import { describe, expect, test } from "bun:test";
import pkg from "../../vscode-ide-bridge/package.json";
describe("vscode-ide-bridge package", () => {
test("declares a VSCode extension entry", () => {
expect(pkg.main).toBe("./dist/extension.js");
expect(pkg.engines.vscode).toBeDefined();
});
});
```
- [ ] **Step 2: 运行测试并确认失败**
Run: `bun test packages/vscode-ide-bridge/test/package.test.ts`
Expected: FAIL提示包文件不存在或字段缺失
- [ ] **Step 3: 写最小扩展包结构**
`packages/vscode-ide-bridge/package.json`
```json
{
"name": "vscode-ide-bridge",
"private": true,
"version": "0.0.1",
"type": "module",
"main": "./dist/extension.js",
"engines": {
"vscode": "^1.90.0"
},
"activationEvents": [
"onStartupFinished",
"onCommand:claudeCodeBridge.restart",
"onCommand:claudeCodeBridge.showStatus"
],
"contributes": {
"commands": [
{
"command": "claudeCodeBridge.restart",
"title": "Claude Code Bridge: Restart"
},
{
"command": "claudeCodeBridge.showStatus",
"title": "Claude Code Bridge: Show Status"
}
]
}
}
```
`packages/vscode-ide-bridge/tsconfig.json`
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node", "vscode"]
},
"include": ["src/**/*.ts"]
}
```
`packages/vscode-ide-bridge/src/extension.ts`
```ts
import * as vscode from "vscode";
export async function activate(context: vscode.ExtensionContext): Promise<void> {
context.subscriptions.push(
vscode.commands.registerCommand("claudeCodeBridge.restart", () => {}),
vscode.commands.registerCommand("claudeCodeBridge.showStatus", () => {})
);
}
export async function deactivate(): Promise<void> {}
```
根目录 `package.json` workspace 增加:
```json
{
"workspaces": [
"packages/*",
"packages/@ant/*",
"packages/vscode-ide-bridge"
]
}
```
- [ ] **Step 4: 运行测试确认通过**
Run: `bun test packages/vscode-ide-bridge/test/package.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add package.json packages/vscode-ide-bridge/package.json packages/vscode-ide-bridge/tsconfig.json packages/vscode-ide-bridge/src/extension.ts packages/vscode-ide-bridge/test/package.test.ts
git commit -m "feat: scaffold vscode ide bridge extension"
```
### Task 2: 实现 lockfile 与状态模型
**Files:**
- Create: `packages/vscode-ide-bridge/src/server/lockfile.ts`
- Create: `packages/vscode-ide-bridge/src/server/workspaceInfo.ts`
- Create: `packages/vscode-ide-bridge/src/server/protocol.ts`
- Create: `packages/vscode-ide-bridge/test/lockfile.test.ts`
- [ ] **Step 1: 写失败测试**
```ts
import { describe, expect, test } from "bun:test";
import { buildLockfilePayload } from "../src/server/lockfile";
describe("buildLockfilePayload", () => {
test("includes ws transport, auth token and workspace folders", () => {
const payload = buildLockfilePayload({
port: 8123,
pid: 100,
ideName: "VS Code",
workspaceFolders: ["D:/repo"],
authToken: "token-1",
runningInWindows: true
});
expect(payload.transport).toBe("ws");
expect(payload.authToken).toBe("token-1");
expect(payload.workspaceFolders).toEqual(["D:/repo"]);
});
});
```
- [ ] **Step 2: 运行测试并确认失败**
Run: `bun test packages/vscode-ide-bridge/test/lockfile.test.ts`
Expected: FAIL提示模块不存在
- [ ] **Step 3: 写最小实现**
`packages/vscode-ide-bridge/src/server/protocol.ts`
```ts
export type LockfilePayload = {
workspaceFolders: string[];
pid: number;
ideName: string;
transport: "ws";
runningInWindows: boolean;
authToken: string;
};
```
`packages/vscode-ide-bridge/src/server/lockfile.ts`
```ts
import { mkdir, rm, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import type { LockfilePayload } from "./protocol";
export function buildLockfilePayload(input: {
port: number;
pid: number;
ideName: string;
workspaceFolders: string[];
authToken: string;
runningInWindows: boolean;
}): LockfilePayload {
return {
workspaceFolders: input.workspaceFolders,
pid: input.pid,
ideName: input.ideName,
transport: "ws",
runningInWindows: input.runningInWindows,
authToken: input.authToken
};
}
export function getLockfilePath(port: number): string {
return join(homedir(), ".claude", "ide", `${port}.lock`);
}
export async function writeLockfile(port: number, payload: LockfilePayload): Promise<string> {
const path = getLockfilePath(port);
await mkdir(join(homedir(), ".claude", "ide"), { recursive: true });
await writeFile(path, JSON.stringify(payload), "utf8");
return path;
}
export async function removeLockfile(path: string | null): Promise<void> {
if (!path) return;
await rm(path, { force: true });
}
```
`packages/vscode-ide-bridge/src/server/workspaceInfo.ts`
```ts
import * as vscode from "vscode";
export function getWorkspaceFolders(): string[] {
return (vscode.workspace.workspaceFolders ?? []).map(folder => folder.uri.fsPath);
}
```
- [ ] **Step 4: 运行测试确认通过**
Run: `bun test packages/vscode-ide-bridge/test/lockfile.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add packages/vscode-ide-bridge/src/server/protocol.ts packages/vscode-ide-bridge/src/server/lockfile.ts packages/vscode-ide-bridge/src/server/workspaceInfo.ts packages/vscode-ide-bridge/test/lockfile.test.ts
git commit -m "feat: add vscode ide bridge lockfile support"
```
### Task 3: 实现选区发布链路
**Files:**
- Create: `packages/vscode-ide-bridge/src/server/selectionPublisher.ts`
- Create: `packages/vscode-ide-bridge/test/selectionPublisher.test.ts`
- Modify: `packages/vscode-ide-bridge/src/extension.ts`
- [ ] **Step 1: 写失败测试**
```ts
import { describe, expect, test } from "bun:test";
import { buildSelectionChangedParams } from "../src/server/selectionPublisher";
describe("buildSelectionChangedParams", () => {
test("serializes editor selection and text", () => {
const params = buildSelectionChangedParams({
filePath: "D:/repo/src/app.ts",
text: "const x = 1;",
start: { line: 1, character: 0 },
end: { line: 1, character: 12 }
});
expect(params.filePath).toBe("D:/repo/src/app.ts");
expect(params.text).toBe("const x = 1;");
expect(params.selection?.start.line).toBe(1);
});
});
```
- [ ] **Step 2: 运行测试并确认失败**
Run: `bun test packages/vscode-ide-bridge/test/selectionPublisher.test.ts`
Expected: FAIL提示导出不存在
- [ ] **Step 3: 写最小实现**
`packages/vscode-ide-bridge/src/server/selectionPublisher.ts`
```ts
export type SelectionPoint = {
line: number;
character: number;
};
export type SelectionChangedParams = {
selection: {
start: SelectionPoint;
end: SelectionPoint;
} | null;
text?: string;
filePath?: string;
};
export function buildSelectionChangedParams(input: {
filePath?: string;
text?: string;
start?: SelectionPoint;
end?: SelectionPoint;
}): SelectionChangedParams {
if (!input.start || !input.end) {
return {
selection: null,
text: input.text,
filePath: input.filePath
};
}
return {
selection: {
start: input.start,
end: input.end
},
text: input.text,
filePath: input.filePath
};
}
```
`packages/vscode-ide-bridge/src/extension.ts` 先增加一个占位发布调用:
```ts
import * as vscode from "vscode";
import { buildSelectionChangedParams } from "./server/selectionPublisher";
export async function activate(context: vscode.ExtensionContext): Promise<void> {
const disposable = vscode.window.onDidChangeTextEditorSelection(event => {
const editor = event.textEditor;
const selection = editor.selection;
buildSelectionChangedParams({
filePath: editor.document.uri.fsPath,
text: editor.document.getText(selection),
start: {
line: selection.start.line,
character: selection.start.character
},
end: {
line: selection.end.line,
character: selection.end.character
}
});
});
context.subscriptions.push(
disposable,
vscode.commands.registerCommand("claudeCodeBridge.restart", () => {}),
vscode.commands.registerCommand("claudeCodeBridge.showStatus", () => {})
);
}
```
- [ ] **Step 4: 运行测试确认通过**
Run: `bun test packages/vscode-ide-bridge/test/selectionPublisher.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add packages/vscode-ide-bridge/src/server/selectionPublisher.ts packages/vscode-ide-bridge/test/selectionPublisher.test.ts packages/vscode-ide-bridge/src/extension.ts
git commit -m "feat: add vscode selection publisher primitives"
```
### Task 4: 实现 WebSocket bridge server 与鉴权
**Files:**
- Create: `packages/vscode-ide-bridge/src/server/bridgeServer.ts`
- Create: `packages/vscode-ide-bridge/test/bridgeServer.test.ts`
- Modify: `packages/vscode-ide-bridge/src/extension.ts`
- [ ] **Step 1: 写失败测试**
```ts
import { describe, expect, test } from "bun:test";
import { isAuthorizedUpgrade } from "../src/server/bridgeServer";
describe("isAuthorizedUpgrade", () => {
test("accepts matching token", () => {
expect(isAuthorizedUpgrade("abc", "abc")).toBe(true);
});
test("rejects mismatched token", () => {
expect(isAuthorizedUpgrade("abc", "def")).toBe(false);
});
});
```
- [ ] **Step 2: 运行测试并确认失败**
Run: `bun test packages/vscode-ide-bridge/test/bridgeServer.test.ts`
Expected: FAIL提示模块不存在
- [ ] **Step 3: 写最小实现**
`packages/vscode-ide-bridge/src/server/bridgeServer.ts`
```ts
import { WebSocketServer } from "ws";
export function isAuthorizedUpgrade(expected: string, actual: string | undefined): boolean {
return Boolean(actual) && expected === actual;
}
export class BridgeServer {
private server: WebSocketServer | null = null;
constructor(private readonly authToken: string) {}
async start(port: number): Promise<void> {
this.server = new WebSocketServer({
host: "127.0.0.1",
port
});
}
async stop(): Promise<void> {
await new Promise<void>(resolve => {
if (!this.server) return resolve();
this.server.close(() => resolve());
this.server = null;
});
}
}
```
`packages/vscode-ide-bridge/src/extension.ts` 中接入:
```ts
import * as vscode from "vscode";
import { randomUUID } from "node:crypto";
import { BridgeServer } from "./server/bridgeServer";
let bridgeServer: BridgeServer | null = null;
export async function activate(context: vscode.ExtensionContext): Promise<void> {
bridgeServer = new BridgeServer(randomUUID());
await bridgeServer.start(0);
context.subscriptions.push({
dispose() {
void bridgeServer?.stop();
}
});
}
```
- [ ] **Step 4: 运行测试确认通过**
Run: `bun test packages/vscode-ide-bridge/test/bridgeServer.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add packages/vscode-ide-bridge/src/server/bridgeServer.ts packages/vscode-ide-bridge/test/bridgeServer.test.ts packages/vscode-ide-bridge/src/extension.ts
git commit -m "feat: add vscode ide bridge websocket server"
```
### Task 5: 实现 diff RPC 和状态命令
**Files:**
- Create: `packages/vscode-ide-bridge/src/server/diffController.ts`
- Modify: `packages/vscode-ide-bridge/src/extension.ts`
- Create: `packages/vscode-ide-bridge/test/diffController.test.ts`
- [ ] **Step 1: 写失败测试**
```ts
import { describe, expect, test } from "bun:test";
import { DiffSessionStore } from "../src/server/diffController";
describe("DiffSessionStore", () => {
test("stores and removes tab mappings by tab name", () => {
const store = new DiffSessionStore();
store.set("tab-1", "memfs:/right.ts");
expect(store.get("tab-1")).toBe("memfs:/right.ts");
store.delete("tab-1");
expect(store.get("tab-1")).toBeUndefined();
});
});
```
- [ ] **Step 2: 运行测试并确认失败**
Run: `bun test packages/vscode-ide-bridge/test/diffController.test.ts`
Expected: FAIL提示模块不存在
- [ ] **Step 3: 写最小实现**
`packages/vscode-ide-bridge/src/server/diffController.ts`
```ts
export class DiffSessionStore {
private readonly sessions = new Map<string, string>();
set(tabName: string, uri: string): void {
this.sessions.set(tabName, uri);
}
get(tabName: string): string | undefined {
return this.sessions.get(tabName);
}
delete(tabName: string): void {
this.sessions.delete(tabName);
}
clear(): void {
this.sessions.clear();
}
}
```
`packages/vscode-ide-bridge/src/extension.ts` 增加状态命令:
```ts
import * as vscode from "vscode";
export async function activate(context: vscode.ExtensionContext): Promise<void> {
const output = vscode.window.createOutputChannel("Claude Code IDE Bridge");
context.subscriptions.push(
output,
vscode.commands.registerCommand("claudeCodeBridge.showStatus", async () => {
output.appendLine("Claude Code IDE Bridge is running.");
output.show(true);
})
);
}
```
- [ ] **Step 4: 运行测试确认通过**
Run: `bun test packages/vscode-ide-bridge/test/diffController.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add packages/vscode-ide-bridge/src/server/diffController.ts packages/vscode-ide-bridge/test/diffController.test.ts packages/vscode-ide-bridge/src/extension.ts
git commit -m "feat: add vscode ide bridge diff state and status command"
```
### Task 6: 接通完整激活流程与手工验证说明
**Files:**
- Modify: `packages/vscode-ide-bridge/src/extension.ts`
- Modify: `README.md`
- Modify: `README_EN.md`
- [ ] **Step 1: 写失败校验**
用文档断言确保 README 中包含 bridge 启动与 `/ide` 使用说明。
```ts
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
describe("README bridge docs", () => {
test("documents vscode ide bridge usage", () => {
const readme = readFileSync("README.md", "utf8");
expect(readme.includes("VSCode IDE Bridge")).toBe(true);
expect(readme.includes("/ide")).toBe(true);
});
});
```
- [ ] **Step 2: 运行测试并确认失败**
Run: `bun test packages/vscode-ide-bridge/test/readme.test.ts`
Expected: FAIL提示 README 中没有 bridge 文档
- [ ] **Step 3: 实现激活主流程与文档**
`packages/vscode-ide-bridge/src/extension.ts` 最终需要做到:
```ts
import * as vscode from "vscode";
import { randomUUID } from "node:crypto";
import { writeLockfile, removeLockfile, buildLockfilePayload } from "./server/lockfile";
import { getWorkspaceFolders } from "./server/workspaceInfo";
import { BridgeServer } from "./server/bridgeServer";
let lockfilePath: string | null = null;
let bridgeServer: BridgeServer | null = null;
export async function activate(context: vscode.ExtensionContext): Promise<void> {
const authToken = randomUUID();
const output = vscode.window.createOutputChannel("Claude Code IDE Bridge");
bridgeServer = new BridgeServer(authToken);
await bridgeServer.start(0);
const payload = buildLockfilePayload({
port: 0,
pid: process.pid,
ideName: "VS Code",
workspaceFolders: getWorkspaceFolders(),
authToken,
runningInWindows: process.platform === "win32"
});
lockfilePath = await writeLockfile(0, payload);
output.appendLine(`Bridge started. Lockfile: ${lockfilePath}`);
context.subscriptions.push(output, {
dispose() {
void bridgeServer?.stop();
void removeLockfile(lockfilePath);
}
});
}
export async function deactivate(): Promise<void> {
await bridgeServer?.stop();
await removeLockfile(lockfilePath);
}
```
README 中文和英文各补一个简短章节,说明:
- 扩展启动后会暴露本地 bridge
- 启动 CLI 后执行 `/ide`
- 在 VSCode 里选中代码,再向 CLI 提问
- diff 预览由 CLI 主动触发
- [ ] **Step 4: 运行验证**
Run: `bun test packages/vscode-ide-bridge/test/readme.test.ts`
Expected: PASS
Run: `bun test packages/vscode-ide-bridge/test/*.test.ts`
Expected: PASS
手工验证:
Run: `bun run build.ts`
Expected: 构建完成,无本次改动引入的额外错误
手工步骤:
1. 在 VSCode 启动扩展开发宿主
2. 打开本仓库
3. 启动 CLI
4. 执行 `/ide`
5. 在编辑器中选中文本后提问
6. 验证 CLI 可见 IDE 选区上下文
- [ ] **Step 5: Commit**
```bash
git add packages/vscode-ide-bridge/src/extension.ts README.md README_EN.md packages/vscode-ide-bridge/test/readme.test.ts
git commit -m "feat: wire vscode ide bridge activation and docs"
```

View File

@@ -1,350 +0,0 @@
# VSCode IDE Bridge 设计文档
**日期:** 2026-04-07
## 1. 背景
当前仓库已经具备一套较完整的 IDE 接入链路:
- CLI 能发现 `ws-ide` / `sse-ide` 类型的 IDE 连接
- CLI 能接收 `selection_changed` 并将其注入为 `<ide_selection>` 上下文
- CLI 能调用 `openDiff``close_tab``closeAllDiffTabs` 等 IDE RPC
- `/ide`、diff 预览、选区提示、已打开文件提示都依赖这套链路
但当前仓库中没有可直接使用的 VSCode 扩展实现,导致本地 VSCode 无法真正把这些能力提供给 CLI。目标不是重做一个聊天面板而是补齐一个兼容现有 CLI 协议的 VSCode 扩展,让 CLI “像连接到原生 IDE 扩展一样”工作。
## 2. 目标
构建一个独立的 VSCode 扩展,在本地暴露一个与当前 CLI 兼容的 `ws-ide` 服务,完成以下能力:
1. 让 CLI 能自动发现 VSCode
2. 让 VSCode 当前文件和选区变化能进入 CLI 的 IDE 上下文链路
3. 让 CLI 发起的 diff 预览能在 VSCode 中打开和关闭
4. 保持实现最小、可调试、可逐步扩展
## 3. 非目标
第一版明确不做以下内容:
- 不实现 VSCode 聊天面板
- 不接入远程工作区、Codespaces、Dev Container、SSH Remote
- 不兼容多台机器之间的桥接
- 不实现复杂的会话恢复或扩展端持久化缓存
- 不覆盖官方扩展的所有功能
## 4. 总体方案
采用“独立 sidecar 扩展 + 本地 WebSocket IDE Bridge”的方式。
### 4.1 连接模型
VSCode 扩展启动后:
1.`127.0.0.1` 上启动一个随机可用端口的 WebSocket 服务
2. 生成与 CLI 现有 IDE 发现逻辑兼容的 lockfile
3. 等待 CLI 以 `ws-ide` MCP 客户端身份连接
4. 扩展在该 WebSocket 连接上暴露 MCP Server负责把 IDE 事件推送给 CLI并响应 CLI 发来的 MCP tool 调用
### 4.2 复用现有 CLI 能力
扩展尽量不改 CLI 的上层交互,只复用现有协议:
- VSCode -> CLI`selection_changed``ide_connected` 通知
- CLI -> VSCode通过 MCP tool 调用 `openDiff``close_tab``closeAllDiffTabs`
这样可以最大化复用:
- `src/hooks/useIdeSelection.ts`
- `src/utils/attachments.ts`
- `src/utils/messages.ts`
- `src/hooks/useDiffInIDE.ts`
- `/ide` 命令及 IDE 状态展示
## 5. 协议设计
### 5.1 Lockfile
扩展写出的 lockfile 需要满足 CLI 的 IDE 自动发现逻辑。内容至少包含:
- `workspaceFolders`
- `pid`
- `ideName`
- `transport: "ws"`
- `runningInWindows`
- `authToken`
文件名使用端口号,例如 `<port>.lock`
### 5.2 鉴权
扩展启动时生成一次随机 `authToken`
- 写入 lockfile
- CLI 连接 `ws-ide` 时通过 `X-Claude-Code-Ide-Authorization` 头带上
- 扩展端校验成功后才允许建立 MCP/WebSocket 会话
第一版只允许本地回环地址,不暴露到公网。
### 5.3 VSCode -> CLI 通知
#### `selection_changed`
在下列事件触发后发送:
- `window.onDidChangeTextEditorSelection`
- `window.onDidChangeActiveTextEditor`
- 扩展激活完成后的初始同步
消息字段包含:
- `selection.start.line`
- `selection.start.character`
- `selection.end.line`
- `selection.end.character`
- `text`
- `filePath`
若当前没有活动选区:
- `selection` 允许为 `null`
- 仍尽量发送 `filePath`
这样 CLI 至少可以知道“用户当前打开的是哪个文件”。
### 5.4 CLI -> VSCode MCP tools
#### `openDiff`
入参:
- `old_file_path`
- `new_file_path`
- `new_file_contents`
- `tab_name`
行为:
- 读取当前磁盘文件内容作为左侧内容
- 使用临时文档或内存文档构造右侧内容
- 在 VSCode 中打开 diff 视图
- 记录 `tab_name -> 资源引用` 映射
#### `close_tab`
入参:
- `tab_name`
行为:
- 根据映射关闭对应 diff 视图
- 清理映射与临时资源
#### `closeAllDiffTabs`
行为:
- 关闭所有由本扩展打开的 diff 标签
- 清理内部状态
## 6. 扩展内部结构
建议新增独立包:`packages/vscode-ide-bridge`
目录结构如下:
```text
packages/vscode-ide-bridge/
package.json
tsconfig.json
src/
extension.ts
server/
bridgeServer.ts
lockfile.ts
workspaceInfo.ts
selectionPublisher.ts
diffController.ts
protocol.ts
util/
randomToken.ts
disposables.ts
test/
selectionPublisher.test.ts
lockfile.test.ts
bridgeServer.test.ts
diffController.test.ts
```
各模块职责如下:
- `extension.ts`
VSCode 扩展入口,负责激活、停用、启动 bridge、注册命令。
- `bridgeServer.ts`
本地 WebSocket 服务与消息路由层,负责握手、鉴权、连接管理,以及把单个 WebSocket 连接桥接为 MCP transport。
- `lockfile.ts`
负责写 lockfile、更新 lockfile、删除 lockfile。
- `workspaceInfo.ts`
负责采集工作区目录、平台信息、活动编辑器文件路径。
- `selectionPublisher.ts`
监听 VSCode 编辑器事件,并把选区信息转换为 `selection_changed`
- `diffController.ts`
处理 `openDiff` / `close_tab` / `closeAllDiffTabs` 这三个 MCP tools维护临时资源和 tab 映射。
- `protocol.ts`
统一定义扩展端需要识别和发送的消息结构,避免字符串散落。
## 7. 命令与可观察性
虽然主流程是自动连接,但第一版仍建议提供两个调试命令:
- `Claude Code Bridge: Restart`
- `Claude Code Bridge: Show Status`
状态信息至少包含:
- 当前监听端口
- lockfile 路径
- 是否有 CLI 已连接
- 当前工作区数量
- 最近一次选区推送时间
另外建议注册一个 output channel
- `Claude Code IDE Bridge`
用于输出:
- 启动日志
- 鉴权失败
- lockfile 写入失败
- diff 打开失败
- 连接断开原因
## 8. 错误处理策略
### 8.1 端口占用
- 自动尝试新的随机端口
- 更新 lockfile
- 在 output channel 中记录端口变化
### 8.2 lockfile 写入失败
- bridge 不进入 ready 状态
- 弹出 VSCode 错误通知
- output channel 记录完整错误
### 8.3 WebSocket 鉴权失败
- 拒绝连接
- 记录远端地址和失败原因
### 8.4 活动编辑器为空
- 发送空选区状态或仅跳过通知
- 不抛异常、不打断 bridge 生命周期
### 8.5 diff 打开失败
- 返回明确错误结果给 CLI
- 不留下半开的临时资源
### 8.6 扩展退出
- 关闭 WebSocket server
- 删除 lockfile
- 释放临时文档资源
- 清空 tab 映射
## 9. 测试方案
### 9.1 单元测试
覆盖以下逻辑:
- lockfile 内容生成与路径选择
- 选区对象到协议消息的转换
- tab 映射和关闭逻辑
- 鉴权令牌校验
### 9.2 集成测试
通过 Node/WebSocket 客户端模拟 CLI
- 连接本地 bridge server
- 验证鉴权成功与失败
- 验证 `selection_changed` 是否按预期发送
- 验证 `openDiff` / `close_tab` 是否触发预期行为
### 9.3 手工验证
手工验证路径:
1. 启动 VSCode 扩展
2. 启动 `claude-code-best`
3. 执行 `/ide`
4. 确认 CLI 能识别到 VSCode
5. 在 VSCode 中选中一段代码并提问
6. 确认 CLI 能注入 `<ide_selection>`
7. 触发一次 IDE diff
8. 确认 diff 标签可打开、保存、关闭
## 10. 风险与取舍
### 10.1 MCP 完整兼容风险
仓库当前 CLI 连接 `ws-ide` 时使用的是 MCP 客户端通路,因此扩展端若实现过薄,可能在握手或工具注册阶段与 CLI 预期不一致。
**取舍:**
第一版只实现 CLI 当前实际会调用到的最小工具与通知,不尝试泛化为完整 MCP server但协议层要留出扩展空间。
### 10.2 VSCode diff 资源回收
VSCode diff 视图不是纯命名 tab直接按 `tab_name` 定位关闭可能和实际标签生命周期有偏差。
**取舍:**
扩展内部维护显式映射,以资源 URI 为主、`tab_name` 为辅,不依赖 UI 文本匹配。
### 10.3 多工作区与路径兼容
Windows、WSL、单根工作区、多根工作区在路径表示上会不同。
**取舍:**
第一版先以本机本地工作区为主路径统一走绝对路径WSL/Windows 转换尽量复用 CLI 现有约定,不在扩展端重新发明路径映射。
## 11. 分阶段交付
### 第一阶段
目标:打通本地 VSCode 与 CLI 的最小闭环。
范围:
- 启动 `ws-ide`
- 写 lockfile
- 发送 `selection_changed`
- 实现 `openDiff`
- 实现 `close_tab`
- 实现 `closeAllDiffTabs`
- 提供状态命令和日志输出
### 第二阶段
目标:增强稳定性和调试能力。
范围:
- 更细的错误提示
- 更稳定的 tab 生命周期管理
- 更多 IDE 状态信息展示
- 更完整的集成测试
## 12. 结论
推荐按本设计实现独立的 VSCode IDE Bridge 扩展,并让它完全对齐当前 CLI 已有的 `ws-ide` 连接与 IDE 上下文/差异视图协议。这样能在不大改 CLI 上层逻辑的前提下,把 VSCode 选区、当前文件和 diff 预览能力真正打通。

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-best",
"version": "1.11.0",
"version": "1.10.11",
"description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal",
"type": "module",
"author": "claude-code-best <claude-code-best@proton.me>",

View File

@@ -1,8 +1,19 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { authMock } from '../../../../../../tests/mocks/auth'
import { mkdir, readFile, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import {
resetStateForTests,
setOriginalCwd,
setProjectRoot,
} from 'src/bootstrap/state.js'
import { logMock } from '../../../../../../tests/mocks/log'
import { debugMock } from '../../../../../../tests/mocks/debug'
let requestStatus = 200
const auditRecords: Record<string, unknown>[] = []
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
mock.module('axios', () => ({
default: {
@@ -13,12 +24,20 @@ mock.module('axios', () => ({
},
}))
mock.module('src/utils/auth.js', authMock)
mock.module('src/utils/auth.js', () => ({
checkAndRefreshOAuthTokenIfNeeded: async () => {},
getClaudeAIOAuthTokens: () => ({ accessToken: 'token' }),
}))
mock.module('src/services/oauth/client.js', () => ({
getOrganizationUUID: async () => 'org',
}))
mock.module('src/constants/oauth.js', () => ({
getOauthConfig: () => ({ BASE_API_URL: 'https://example.test' }),
fileSuffixForOauthConfig: () => '',
}))
mock.module('src/services/analytics/growthbook.js', () => ({
getFeatureValue_CACHED_MAY_BE_STALE: () => true,
}))
@@ -27,41 +46,40 @@ mock.module('src/services/policyLimits/index.js', () => ({
isPolicyAllowed: () => true,
}))
// Narrow mock for the side-effectful entries in `src/constants/oauth.js`.
// Pure data exports (ALL_OAUTH_SCOPES, CLAUDE_AI_*_SCOPE, etc.) come from
// the real module and are not mocked, per the test policy that constants
// modules without side effects should not be replaced wholesale.
mock.module('src/constants/oauth.js', () => {
const actual = require('../../../../../../src/constants/oauth.js')
return {
...actual,
fileSuffixForOauthConfig: () => '',
getOauthConfig: () => ({ BASE_API_URL: 'https://example.test' }),
MCP_CLIENT_METADATA_URL: 'https://example.test/oauth/metadata',
}
})
mock.module('src/utils/remoteTriggerAudit.js', () => ({
appendRemoteTriggerAuditRecord: async (
record: Record<string, unknown>,
) => {
const fullRecord = {
auditId: `audit-${auditRecords.length + 1}`,
createdAt: Date.now(),
...record,
}
auditRecords.push(fullRecord)
return fullRecord
},
mock.module('bun:bundle', () => ({
feature: () => false,
}))
beforeEach(() => {
let cwd = ''
let previousCwd = ''
let auditRecords: Array<Record<string, unknown>> = []
mock.module('src/utils/remoteTriggerAudit.js', () => ({
appendRemoteTriggerAuditRecord: async (record: Record<string, unknown>) => {
const full = { ...record, auditId: record.auditId ?? 'test-audit-id', createdAt: Date.now() }
auditRecords.push(full)
return full
},
resolveRemoteTriggerAuditPath: () => join(cwd, '.claude', 'remote-trigger-audit.jsonl'),
}))
beforeEach(async () => {
requestStatus = 200
auditRecords.length = 0
auditRecords = []
previousCwd = process.cwd()
cwd = join(tmpdir(), `remote-trigger-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`)
await mkdir(cwd, { recursive: true })
await mkdir(join(cwd, '.claude'), { recursive: true })
process.chdir(cwd)
resetStateForTests()
setOriginalCwd(cwd)
setProjectRoot(cwd)
})
afterEach(() => {
auditRecords.length = 0
afterEach(async () => {
resetStateForTests()
process.chdir(previousCwd)
await rm(cwd, { recursive: true, force: true })
})
describe('RemoteTriggerTool audit', () => {
@@ -73,14 +91,10 @@ describe('RemoteTriggerTool audit', () => {
)
expect(result.data.audit_id).toBeString()
expect(result.data.audit_id).toBe('audit-1')
expect(auditRecords).toHaveLength(1)
expect(auditRecords[0]).toMatchObject({
action: 'run',
triggerId: 'trigger-1',
ok: true,
status: 200,
})
expect(auditRecords[0].action).toBe('run')
expect(auditRecords[0].triggerId).toBe('trigger-1')
expect(auditRecords[0].ok).toBe(true)
})
test('writes an audit record before rethrowing validation failures', async () => {
@@ -94,10 +108,8 @@ describe('RemoteTriggerTool audit', () => {
).rejects.toThrow('run requires trigger_id')
expect(auditRecords).toHaveLength(1)
expect(auditRecords[0]).toMatchObject({
action: 'run',
ok: false,
error: 'run requires trigger_id',
})
expect(auditRecords[0].action).toBe('run')
expect(auditRecords[0].ok).toBe(false)
expect(auditRecords[0].error).toBe('run requires trigger_id')
})
})

View File

@@ -18,19 +18,76 @@
*/
import { diffArrays } from 'diff'
import hljs from 'highlight.js'
// Import the minimal highlight.js core (no languages) instead of the full
// bundle that loads 190+ grammars (~5-15MB). Individual languages are
// imported statically below and registered on the core instance. Static
// imports work in Bun --compile mode (only createRequire fails).
import hljs from 'highlight.js/lib/core'
import { basename, extname } from 'path'
// Static import — createRequire(import.meta.url) fails in Bun --compile mode
// because the resolved path points to the internal bunfs binary path where
// node_modules cannot be found. A top-level import ensures the module is
// bundled and accessible at runtime.
// --- Register commonly-used languages (~25 instead of 190+) ---
import langBash from 'highlight.js/lib/languages/bash'
import langC from 'highlight.js/lib/languages/c'
import langCmake from 'highlight.js/lib/languages/cmake'
import langCpp from 'highlight.js/lib/languages/cpp'
import langCsharp from 'highlight.js/lib/languages/csharp'
import langCss from 'highlight.js/lib/languages/css'
import langDiff from 'highlight.js/lib/languages/diff'
import langDockerfile from 'highlight.js/lib/languages/dockerfile'
import langGo from 'highlight.js/lib/languages/go'
import langGraphQL from 'highlight.js/lib/languages/graphql'
import langJava from 'highlight.js/lib/languages/java'
import langJavaScript from 'highlight.js/lib/languages/javascript'
import langJson from 'highlight.js/lib/languages/json'
import langKotlin from 'highlight.js/lib/languages/kotlin'
import langMakefile from 'highlight.js/lib/languages/makefile'
import langMarkdown from 'highlight.js/lib/languages/markdown'
import langPerl from 'highlight.js/lib/languages/perl'
import langPhp from 'highlight.js/lib/languages/php'
import langPython from 'highlight.js/lib/languages/python'
import langRuby from 'highlight.js/lib/languages/ruby'
import langRust from 'highlight.js/lib/languages/rust'
import langShell from 'highlight.js/lib/languages/shell'
import langSql from 'highlight.js/lib/languages/sql'
import langTypeScript from 'highlight.js/lib/languages/typescript'
import langXml from 'highlight.js/lib/languages/xml'
import langYaml from 'highlight.js/lib/languages/yaml'
hljs.registerLanguage('bash', langBash)
hljs.registerLanguage('c', langC)
hljs.registerLanguage('cmake', langCmake)
hljs.registerLanguage('cpp', langCpp)
hljs.registerLanguage('csharp', langCsharp)
hljs.registerLanguage('css', langCss)
hljs.registerLanguage('diff', langDiff)
hljs.registerLanguage('dockerfile', langDockerfile)
hljs.registerLanguage('go', langGo)
hljs.registerLanguage('graphql', langGraphQL)
hljs.registerLanguage('java', langJava)
hljs.registerLanguage('javascript', langJavaScript)
hljs.registerLanguage('json', langJson)
hljs.registerLanguage('kotlin', langKotlin)
hljs.registerLanguage('makefile', langMakefile)
hljs.registerLanguage('markdown', langMarkdown)
hljs.registerLanguage('perl', langPerl)
hljs.registerLanguage('php', langPhp)
hljs.registerLanguage('python', langPython)
hljs.registerLanguage('ruby', langRuby)
hljs.registerLanguage('rust', langRust)
hljs.registerLanguage('shell', langShell)
hljs.registerLanguage('sql', langSql)
hljs.registerLanguage('typescript', langTypeScript)
hljs.registerLanguage('xml', langXml)
hljs.registerLanguage('yaml', langYaml)
// JavaScript grammar also handles .mjs/.cjs extensions
// TypeScript grammar also handles .tsx via auto-detection
type HLJSApi = typeof hljs
let cachedHljs: HLJSApi | null = null
function hljsApi(): HLJSApi {
if (cachedHljs) return cachedHljs
// highlight.js uses `export =` (CJS). Under bun/ESM the interop wraps it
// in .default; under node CJS the module IS the API. Check at runtime.
// highlight.js/lib/core uses `export =` (CJS). Under bun/ESM the interop
// wraps it in .default; under node CJS the module IS the API. Check at runtime.
const mod = hljs as HLJSApi & { default?: HLJSApi }
cachedHljs = 'default' in mod && mod.default ? mod.default : mod
return cachedHljs!
@@ -502,50 +559,6 @@ function hasRootNode(emitter: unknown): emitter is { rootNode: HljsNode } {
let loggedEmitterShapeError = false
// Per-line hljs AST cache — ColorFile.render re-highlights every line on
// width change (terminal resize). The AST is theme-independent; flattenHljs
// applies theme colors separately. Capped at 2048 entries (~1 MB typical).
const HL_LINE_CACHE_MAX = 2048
const hlLineCache = new Map<string, HljsNode | null>()
function cachedHljsAst(
lang: string,
code: string,
): HljsNode | null {
const key = lang + '\0' + code
const hit = hlLineCache.get(key)
if (hit !== undefined) return hit
let result
try {
result = hljsApi().highlight(code, {
language: lang,
ignoreIllegals: true,
})
} catch {
hlLineCache.set(key, null)
return null
}
const emitter = result._emitter || {}
if (!hasRootNode(emitter)) {
if (!loggedEmitterShapeError) {
loggedEmitterShapeError = true
logError(
new Error(
`color-diff: hljs emitter shape mismatch (keys: ${Object.keys(emitter).join(',')}). Syntax highlighting disabled.`,
),
)
}
hlLineCache.set(key, null)
return null
}
const node = emitter.rootNode
if (hlLineCache.size >= HL_LINE_CACHE_MAX) {
const first = hlLineCache.keys().next().value
if (first !== undefined) hlLineCache.delete(first)
}
hlLineCache.set(key, node)
return node
}
function highlightLine(
state: { lang: string | null; stack: unknown },
line: string,
@@ -556,12 +569,30 @@ function highlightLine(
if (!state.lang) {
return [[defaultStyle(theme), code]]
}
const rootNode = cachedHljsAst(state.lang, code)
if (!rootNode) {
let result
try {
result = hljsApi().highlight(code, {
language: state.lang,
ignoreIllegals: true,
})
} catch {
// hljs throws on unknown language despite ignoreIllegals
return [[defaultStyle(theme), code]]
}
const emitter = result._emitter || {};
if (!hasRootNode(emitter)) {
if (!loggedEmitterShapeError) {
loggedEmitterShapeError = true
logError(
new Error(
`color-diff: hljs emitter shape mismatch (keys: ${Object.keys(emitter).join(',')}). Syntax highlighting disabled.`,
),
)
}
return [[defaultStyle(theme), code]]
}
const blocks: Block[] = []
flattenHljs(rootNode, theme, undefined, blocks)
flattenHljs(emitter.rootNode, theme, undefined, blocks)
return blocks
}

View File

@@ -1,36 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run VSCode IDE Bridge",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--new-window",
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}"
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "Build VSCode IDE Bridge"
},
{
"name": "Run VSCode IDE Bridge (Open Claude Code Root)",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--new-window",
"--disable-extensions",
"--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}/../.."
],
"outFiles": [
"${workspaceFolder}/dist/**/*.js"
],
"preLaunchTask": "Build VSCode IDE Bridge"
}
]
}

View File

@@ -1,47 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Build VSCode IDE Bridge",
"type": "shell",
"command": "bunx",
"args": [
"tsc",
"-p",
"tsconfig.json"
],
"options": {
"cwd": "${workspaceFolder}"
},
"group": "build",
"problemMatcher": "$tsc"
},
{
"label": "Test VSCode IDE Bridge",
"type": "shell",
"command": "bun",
"args": [
"test",
"test"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": []
},
{
"label": "Package VSCode IDE Bridge",
"type": "shell",
"command": "bun",
"args": [
"run",
"package"
],
"options": {
"cwd": "${workspaceFolder}"
},
"group": "build",
"problemMatcher": []
}
]
}

View File

@@ -1,6 +0,0 @@
src/**
test/**
.vscode/**
tsconfig.json
*.tsbuildinfo
dist/server/**

View File

@@ -1,3 +0,0 @@
UNLICENSED
This package is not licensed for public redistribution.

View File

@@ -1,59 +0,0 @@
# VSCode IDE Bridge
这是一个给当前仓库配套的本地 VSCode 扩展,用来把 VSCode 和现有 Claude Code CLI 的 `ws-ide` 链路接起来。
## 当前能力
- 在本地 `127.0.0.1` 启动 `ws-ide` WebSocket 服务
- 写出 CLI 可发现的 `~/.claude/ide/<port>.lock`
- 把 VSCode 当前活动文件和选区变化发送为 `selection_changed`
- 实现 `openDiff``close_tab``closeAllDiffTabs` 三个 IDE MCP tools
- 提供 `Claude Code Bridge: Restart``Claude Code Bridge: Show Status` 两个调试命令
## 当前限制
- diff 现在支持通过保存右侧文件把修改回传给 CLI但还没有补“未保存直接接受右侧手工编辑”这类更细的交互
- 还没有补 `openFile``getDiagnostics``at_mentioned``log_event` 这些附加能力
- 目前按单个活动 CLI 连接设计,新连接会替换旧连接
## 本地使用
推荐把这个目录单独当成一个扩展工程来打开,而不是总是从 monorepo 根目录调试。
1. 在 VSCode 中直接打开 `packages/vscode-ide-bridge`
2. 打开“运行和调试”
3. 二选一:
- `Run VSCode IDE Bridge`
- `Run VSCode IDE Bridge (Open Claude Code Root)`,会直接在测试窗口里打开 monorepo 根目录
4. 这会自动先执行 `Build VSCode IDE Bridge`
5. 如果用了第一个启动项,就在新开的 Extension Development Host 窗口中再打开你真正要联调的目标工作区
如果用了第二个启动项,会直接打开 `claude-code` 根目录
6. 打开命令面板,执行 `Claude Code Bridge: Show Status`
7. 确认输出中已经出现监听端口和 lockfile 路径
8. 在这个测试窗口的集成终端里启动 Claude Code CLI如果没有自动连上再执行 `/ide`
这个目录自带自己的 VSCode 配置:
- `Run VSCode IDE Bridge`
- `Run VSCode IDE Bridge (Open Claude Code Root)`
- `Build VSCode IDE Bridge`
- `Test VSCode IDE Bridge`
- `Package VSCode IDE Bridge`
如果你仍然从 monorepo 根目录开发,也可以继续使用根目录下的 `.vscode` 配置。
## 打包
可以直接在这个包目录里执行:
```bash
bun run package
```
成功后会在 `dist/vscode-ide-bridge.vsix` 生成可安装的 VSCode 扩展包。
## 验证建议
- 选中一段代码后发起提问,确认 CLI prompt 中出现 `<ide_selection>`
- 触发一次文件 diff确认 VSCode 中会打开 diff并能通过通知选择“接受”或“拒绝”
- 查看 `Claude Code IDE Bridge` output channel确认没有鉴权失败或 lockfile 写入失败

View File

@@ -1,59 +0,0 @@
{
"name": "vscode-ide-bridge",
"private": true,
"version": "0.0.1",
"description": "Local VSCode ws-ide bridge for Claude Code",
"displayName": "Claude Code IDE Bridge",
"publisher": "claude-code-best",
"license": "UNLICENSED",
"type": "module",
"main": "./dist/extension.js",
"repository": {
"type": "git",
"url": "git+https://github.com/claude-code-best/claude-code.git",
"directory": "packages/vscode-ide-bridge"
},
"homepage": "https://github.com/claude-code-best/claude-code/tree/main/packages/vscode-ide-bridge",
"bugs": {
"url": "https://github.com/claude-code-best/claude-code/issues"
},
"categories": [
"Other"
],
"engines": {
"vscode": "^1.90.0"
},
"activationEvents": [
"onStartupFinished",
"onCommand:claudeCodeBridge.restart",
"onCommand:claudeCodeBridge.showStatus"
],
"contributes": {
"commands": [
{
"command": "claudeCodeBridge.restart",
"title": "Claude Code Bridge: Restart"
},
{
"command": "claudeCodeBridge.showStatus",
"title": "Claude Code Bridge: Show Status"
}
]
},
"scripts": {
"build": "bunx tsc -p tsconfig.json",
"bundle": "bun build ./src/extension.ts --outdir dist --target node --format esm --external vscode",
"test": "bun test",
"check": "bunx tsc -p tsconfig.json --pretty false",
"package": "bun run bundle && bunx @vscode/vsce package --no-dependencies --out dist/vscode-ide-bridge.vsix"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"ws": "^8.20.0"
},
"devDependencies": {
"@vscode/vsce": "^3.7.0",
"@types/bun": "^1.3.11",
"typescript": "^6.0.2"
}
}

View File

@@ -1,61 +0,0 @@
import * as vscode from 'vscode'
import { LocalIdeBridgeService } from './server/localIdeBridgeService.js'
let bridgeService: LocalIdeBridgeService | null = null
export async function activate(context: any): Promise<void> {
const outputChannel = vscode.window.createOutputChannel(
'Claude Code IDE Bridge',
)
bridgeService = new LocalIdeBridgeService(
vscode,
outputChannel,
context.environmentVariableCollection,
)
await bridgeService.start()
context.subscriptions.push(
outputChannel,
{
dispose: () => {
void bridgeService?.dispose()
},
},
vscode.commands.registerCommand('claudeCodeBridge.restart', async () => {
await bridgeService?.restart()
const status = bridgeService?.getStatus()
vscode.window.showInformationMessage(
`Claude Code Bridge 已重启${status?.port ? `,端口 ${status.port}` : ''}`,
)
}),
vscode.commands.registerCommand('claudeCodeBridge.showStatus', () => {
const status = bridgeService?.getStatus()
outputChannel.show(true)
outputChannel.appendLine(
`[status] port=${status?.port ?? 'n/a'} connected=${String(status?.hasConnectedClient ?? false)} cliPid=${status?.connectedCliPid ?? 'n/a'} lockfile=${status?.lockfilePath ?? 'n/a'}`,
)
vscode.window.showInformationMessage(
status?.port
? `Claude Code Bridge 正在监听 127.0.0.1:${status.port}`
: 'Claude Code Bridge 尚未启动',
)
}),
vscode.window.onDidChangeTextEditorSelection(() => {
void bridgeService?.publishActiveSelection()
}),
vscode.window.onDidChangeActiveTextEditor(() => {
void bridgeService?.publishActiveSelection()
}),
vscode.workspace.onDidChangeWorkspaceFolders(() => {
void bridgeService?.refreshLockfile()
}),
)
await bridgeService.publishActiveSelection()
}
export async function deactivate(): Promise<void> {
await bridgeService?.dispose()
bridgeService = null
}

View File

@@ -1,139 +0,0 @@
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import {
CallToolRequestSchema,
type CallToolResult,
ListToolsRequestSchema,
type Tool,
} from '@modelcontextprotocol/sdk/types.js'
import type { SelectionChangedParams } from './selectionPublisher.js'
import {
CloseAllDiffTabsArgumentsSchema,
CloseTabArgumentsSchema,
IdeConnectedNotificationSchema,
OpenDiffArgumentsSchema,
type CloseTabArguments,
type OpenDiffArguments,
} from './protocol.js'
export type DiffController = {
openDiff(args: OpenDiffArguments): Promise<CallToolResult>
closeTab(args: CloseTabArguments): Promise<CallToolResult>
closeAllDiffTabs(): Promise<CallToolResult>
}
type CreateIdeBridgeServerOptions = {
diffController: DiffController
}
const IDE_BRIDGE_TOOLS: Tool[] = [
{
name: 'openDiff',
description: 'Open a diff view in the IDE and resolve when the user acts.',
inputSchema: {
type: 'object',
properties: {
old_file_path: { type: 'string' },
new_file_path: { type: 'string' },
new_file_contents: { type: 'string' },
tab_name: { type: 'string' },
},
required: [
'old_file_path',
'new_file_path',
'new_file_contents',
'tab_name',
],
additionalProperties: false,
},
},
{
name: 'close_tab',
description: 'Close a previously opened IDE tab by Claude Code tab name.',
inputSchema: {
type: 'object',
properties: {
tab_name: { type: 'string' },
},
required: ['tab_name'],
additionalProperties: false,
},
},
{
name: 'closeAllDiffTabs',
description: 'Close all diff tabs created by the IDE bridge.',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
]
export function createIdeBridgeServer(options: CreateIdeBridgeServerOptions): {
server: Server
notifySelectionChanged(params: SelectionChangedParams): Promise<void>
getConnectedCliPid(): number | null
} {
const server = new Server(
{
name: 'claude-code-vscode-ide-bridge',
version: '0.0.1',
},
{
capabilities: {
tools: {},
},
},
)
let connectedCliPid: number | null = null
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: IDE_BRIDGE_TOOLS,
}
})
server.setRequestHandler(CallToolRequestSchema, async request => {
switch (request.params.name) {
case 'openDiff':
return options.diffController.openDiff(
OpenDiffArgumentsSchema.parse(request.params.arguments ?? {}),
)
case 'close_tab':
return options.diffController.closeTab(
CloseTabArgumentsSchema.parse(request.params.arguments ?? {}),
)
case 'closeAllDiffTabs':
CloseAllDiffTabsArgumentsSchema.parse(request.params.arguments ?? {})
return options.diffController.closeAllDiffTabs()
default:
return {
isError: true,
content: [
{
type: 'text',
text: `Unsupported IDE tool: ${request.params.name}`,
},
],
}
}
})
server.setNotificationHandler(IdeConnectedNotificationSchema, notification => {
connectedCliPid = notification.params.pid
})
return {
server,
async notifySelectionChanged(params) {
await server.notification({
method: 'selection_changed',
params,
})
},
getConnectedCliPid() {
return connectedCliPid
},
}
}

View File

@@ -1,350 +0,0 @@
import { readFile } from 'node:fs/promises'
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import * as vscode from 'vscode'
import type { DiffController } from './bridgeServer.js'
import type { OpenDiffArguments } from './protocol.js'
const DIFF_SCHEME = 'claude-code-bridge'
const ACCEPT_LABEL = '接受'
const REJECT_LABEL = '拒绝'
type DiffSession = {
tabName: string
leftUri: any
rightUri: any
filePath: string
hasBeenVisible: boolean
settled: boolean
resolve: (result: CallToolResult) => void
}
class VirtualDocumentProvider {
private readonly contents = new Map<string, string>()
provideTextDocumentContent(uri: any): string {
return this.contents.get(uri.toString()) ?? ''
}
set(uri: any, content: string): void {
this.contents.set(uri.toString(), content)
}
delete(uri: any): void {
this.contents.delete(uri.toString())
}
}
function createTextResult(text: string): CallToolResult {
return {
content: [
{
type: 'text',
text,
},
],
}
}
function createFileSavedResult(contents: string): CallToolResult {
return {
content: [
{
type: 'text',
text: 'FILE_SAVED',
},
{
type: 'text',
text: contents,
},
],
}
}
function buildDiffUri(kind: 'left' | 'right', tabName: string, filePath: string) {
return vscode.Uri.parse(
`${DIFF_SCHEME}:/${kind}/${encodeURIComponent(tabName)}?filePath=${encodeURIComponent(filePath)}`,
)
}
function getDocumentFullRange(document: any): any {
const lineCount = Math.max(document?.lineCount ?? 1, 1)
const lastLine = document?.lineAt?.(lineCount - 1)
const lastCharacter = lastLine?.text?.length ?? 0
return new vscode.Range(0, 0, lineCount - 1, lastCharacter)
}
async function replaceDocumentContents(
editor: any,
nextContent: string,
): Promise<void> {
const currentContent = editor?.document?.getText?.() ?? ''
if (currentContent === nextContent) {
return
}
await editor.edit((editBuilder: any) => {
editBuilder.replace(
getDocumentFullRange(editor.document),
nextContent,
)
})
}
function matchesSessionDocument(session: DiffSession, document: any): boolean {
const uriString = document?.uri?.toString?.()
const fsPath = document?.uri?.fsPath
return (
uriString === session.rightUri.toString() ||
(typeof fsPath === 'string' && fsPath === session.filePath)
)
}
export function createDiffController(outputChannel: any): DiffController & {
dispose(): Promise<void>
} {
const provider = new VirtualDocumentProvider()
const sessions = new Map<string, DiffSession>()
const providerDisposable =
vscode.workspace.registerTextDocumentContentProvider(
DIFF_SCHEME,
provider,
)
const visibilityDisposable = vscode.window.onDidChangeVisibleTextEditors(
(editors: any[]) => {
const visibleUris = new Set(
editors.map(editor => editor?.document?.uri?.toString?.()),
)
for (const session of sessions.values()) {
const leftVisible = visibleUris.has(session.leftUri.toString())
const rightVisible = visibleUris.has(session.rightUri.toString())
if (leftVisible || rightVisible) {
session.hasBeenVisible = true
continue
}
if (session.hasBeenVisible) {
void settleSession(
session.tabName,
createTextResult('TAB_CLOSED'),
false,
)
}
}
},
)
const saveDisposable = vscode.workspace.onDidSaveTextDocument(
(document: any) => {
for (const session of sessions.values()) {
if (!matchesSessionDocument(session, document)) {
continue
}
void settleSession(
session.tabName,
createFileSavedResult(document.getText()),
true,
)
}
},
)
async function settleSession(
tabName: string,
result: CallToolResult,
closeEditors: boolean,
): Promise<void> {
const session = sessions.get(tabName)
if (!session || session.settled) {
return
}
session.settled = true
sessions.delete(tabName)
provider.delete(session.leftUri)
provider.delete(session.rightUri)
if (closeEditors) {
await closeSessionEditors(session).catch(() => {})
}
session.resolve(result)
}
async function closeSessionEditors(session: DiffSession): Promise<void> {
for (const editor of vscode.window.visibleTextEditors ?? []) {
if (
matchesSessionDocument(session, editor?.document) &&
editor?.document?.isDirty
) {
await vscode.window.showTextDocument(editor.document, {
preview: false,
preserveFocus: false,
viewColumn: editor.viewColumn,
})
await vscode.commands.executeCommand('workbench.action.files.revert')
}
}
const matchedTabs: any[] = []
for (const group of vscode.window.tabGroups?.all ?? []) {
for (const tab of group.tabs ?? []) {
const original = tab?.input?.original?.toString?.()
const modified = tab?.input?.modified?.toString?.()
const uri = tab?.input?.uri?.toString?.()
if (
original === session.leftUri.toString() ||
modified === session.rightUri.toString() ||
uri === session.rightUri.toString() ||
tab?.input?.uri?.fsPath === session.filePath ||
tab?.label === session.tabName
) {
matchedTabs.push(tab)
}
}
}
if (matchedTabs.length > 0 && vscode.window.tabGroups?.close) {
await vscode.window.tabGroups.close(matchedTabs, true)
return
}
for (const editor of vscode.window.visibleTextEditors ?? []) {
const uri = editor?.document?.uri?.toString?.()
if (
uri === session.leftUri.toString() ||
uri === session.rightUri.toString()
) {
await vscode.window.showTextDocument(editor.document, {
preview: false,
preserveFocus: false,
viewColumn: editor.viewColumn,
})
await vscode.commands.executeCommand('workbench.action.closeActiveEditor')
}
}
}
return {
async openDiff(args: OpenDiffArguments): Promise<CallToolResult> {
await settleSession(args.tab_name, createTextResult('TAB_CLOSED'), true)
const leftContent = await readFile(args.old_file_path, 'utf8').catch(
() => '',
)
const leftUri = buildDiffUri('left', args.tab_name, args.old_file_path)
const rightUri = vscode.Uri.file(args.new_file_path)
provider.set(leftUri, leftContent)
const rightDocument = await vscode.workspace.openTextDocument(rightUri)
const rightEditor = await vscode.window.showTextDocument(rightDocument, {
preview: false,
preserveFocus: true,
})
await replaceDocumentContents(rightEditor, args.new_file_contents)
const resultPromise = new Promise<CallToolResult>(resolve => {
sessions.set(args.tab_name, {
tabName: args.tab_name,
leftUri,
rightUri,
filePath: args.new_file_path,
hasBeenVisible: false,
settled: false,
resolve,
})
})
outputChannel.appendLine(
`[diff] open ${args.tab_name} -> ${args.new_file_path}`,
)
await vscode.commands.executeCommand(
'vscode.diff',
leftUri,
rightUri,
args.tab_name,
{
preview: false,
},
)
queueMicrotask(() => {
const visibleUris = new Set(
(vscode.window.visibleTextEditors ?? []).map((editor: any) =>
editor?.document?.uri?.toString?.(),
),
)
const session = sessions.get(args.tab_name)
if (!session) {
return
}
if (
visibleUris.has(session.leftUri.toString()) ||
visibleUris.has(session.rightUri.toString())
) {
session.hasBeenVisible = true
}
})
void vscode.window
.showInformationMessage(
`Claude Code 提议了对 ${args.new_file_path} 的修改`,
ACCEPT_LABEL,
REJECT_LABEL,
)
.then((choice: string | undefined) => {
if (choice === ACCEPT_LABEL) {
void settleSession(
args.tab_name,
createTextResult('TAB_CLOSED'),
true,
)
} else if (choice === REJECT_LABEL) {
void settleSession(
args.tab_name,
createTextResult('DIFF_REJECTED'),
true,
)
}
})
return resultPromise
},
async closeTab(args): Promise<CallToolResult> {
const session = sessions.get(args.tab_name)
if (session) {
await closeSessionEditors(session).catch(() => {})
await settleSession(args.tab_name, createTextResult('TAB_CLOSED'), false)
}
return createTextResult('TAB_CLOSED')
},
async closeAllDiffTabs(): Promise<CallToolResult> {
for (const tabName of [...sessions.keys()]) {
const session = sessions.get(tabName)
if (!session) {
continue
}
await closeSessionEditors(session).catch(() => {})
await settleSession(tabName, createTextResult('TAB_CLOSED'), false)
}
return createTextResult('OK')
},
async dispose(): Promise<void> {
visibilityDisposable.dispose()
saveDisposable.dispose()
providerDisposable.dispose()
await this.closeAllDiffTabs()
},
}
}

View File

@@ -1,231 +0,0 @@
import { WebSocketServer } from 'ws'
import { createIdeBridgeServer } from './bridgeServer.js'
import { createDiffController } from './diffController.js'
import {
buildLockfilePayload,
removeLockfile,
writeLockfile,
} from './lockfile.js'
import { createAuthToken } from './randomToken.js'
import { ServerWebSocketTransport } from './serverWebSocketTransport.js'
import {
clearClaudeCodeIdePort,
setClaudeCodeIdePort,
} from './terminalEnvironment.js'
import { getActiveSelectionSnapshot, getWorkspaceFolderPaths } from './workspaceInfo.js'
type BridgeStatus = {
port: number | null
lockfilePath: string | null
hasConnectedClient: boolean
connectedCliPid: number | null
workspaceFolders: string[]
lastSelectionSentAt: string | null
}
type ActiveConnection = {
socket: any
bridge: ReturnType<typeof createIdeBridgeServer>
transport: ServerWebSocketTransport
}
export class LocalIdeBridgeService {
private readonly diffController
private readonly ideName = 'VS Code'
private readonly runningInWindows = process.platform === 'win32'
private server: any | null = null
private port: number | null = null
private lockfilePath: string | null = null
private authToken = ''
private activeConnection: ActiveConnection | null = null
private lastSelectionSentAt: string | null = null
private disposed = false
constructor(
private readonly vscode: any,
private readonly outputChannel: any,
private readonly environmentVariableCollection?: {
replace(name: string, value: string): void
delete(name: string): void
},
) {
this.diffController = createDiffController(outputChannel)
}
async start(): Promise<void> {
if (this.server || this.disposed) {
return
}
this.authToken = createAuthToken()
this.server = await this.createWebSocketServer()
this.port = this.getServerPort()
await this.refreshLockfile()
this.outputChannel.appendLine(
`[bridge] listening on ws://127.0.0.1:${this.port}`,
)
}
async restart(): Promise<void> {
await this.stop()
this.disposed = false
await this.start()
}
async refreshLockfile(): Promise<void> {
if (!this.port) {
return
}
setClaudeCodeIdePort(this.environmentVariableCollection, this.port)
await removeLockfile(this.lockfilePath)
this.lockfilePath = await writeLockfile(
this.port,
buildLockfilePayload({
pid: process.pid,
ideName: this.ideName,
workspaceFolders: getWorkspaceFolderPaths(
this.vscode.workspace.workspaceFolders,
),
authToken: this.authToken,
runningInWindows: this.runningInWindows,
}),
)
this.outputChannel.appendLine(`[bridge] lockfile -> ${this.lockfilePath}`)
this.outputChannel.appendLine(
`[bridge] terminal env CLAUDE_CODE_SSE_PORT=${this.port}`,
)
}
async publishActiveSelection(): Promise<void> {
if (!this.activeConnection) {
return
}
const snapshot = getActiveSelectionSnapshot(this.vscode.window.activeTextEditor)
if (!snapshot.selection && !snapshot.filePath) {
return
}
await this.activeConnection.bridge.notifySelectionChanged(snapshot)
this.lastSelectionSentAt = new Date().toISOString()
}
getStatus(): BridgeStatus {
return {
port: this.port,
lockfilePath: this.lockfilePath,
hasConnectedClient: this.activeConnection !== null,
connectedCliPid:
this.activeConnection?.bridge.getConnectedCliPid() ?? null,
workspaceFolders: getWorkspaceFolderPaths(
this.vscode.workspace.workspaceFolders,
),
lastSelectionSentAt: this.lastSelectionSentAt,
}
}
async stop(): Promise<void> {
await this.closeActiveConnection()
if (this.server) {
await new Promise<void>(resolve => {
this.server?.close(() => resolve())
})
this.server = null
}
await removeLockfile(this.lockfilePath)
clearClaudeCodeIdePort(this.environmentVariableCollection)
this.lockfilePath = null
this.port = null
}
async dispose(): Promise<void> {
if (this.disposed) {
return
}
this.disposed = true
await this.stop()
await this.diffController.dispose()
}
private async createWebSocketServer(): Promise<any> {
const server = new WebSocketServer({
host: '127.0.0.1',
port: 0,
})
await new Promise<void>((resolve, reject) => {
server.once('listening', () => resolve())
server.once('error', (error: Error) => reject(error))
})
server.on('connection', (socket: any, request: any) => {
const authHeader = request.headers['x-claude-code-ide-authorization']
if (authHeader !== this.authToken) {
this.outputChannel.appendLine('[bridge] rejected unauthorized client')
socket.close(4003, 'unauthorized')
return
}
void this.handleConnection(socket)
})
return server
}
private getServerPort(): number {
const address = this.server?.address()
if (!address || typeof address === 'string') {
throw new Error('Unable to determine bridge port')
}
return address.port
}
private async handleConnection(socket: any): Promise<void> {
await this.closeActiveConnection()
const bridge = createIdeBridgeServer({
diffController: this.diffController,
})
const transport = new ServerWebSocketTransport(socket)
socket.on('close', () => {
if (this.activeConnection?.socket === socket) {
this.activeConnection = null
}
})
await bridge.server.connect(transport)
this.activeConnection = {
socket,
bridge,
transport,
}
this.outputChannel.appendLine('[bridge] CLI client connected')
await this.publishActiveSelection().catch(error => {
this.outputChannel.appendLine(
`[bridge] failed to publish initial selection: ${(error as Error).message}`,
)
})
}
private async closeActiveConnection(): Promise<void> {
if (!this.activeConnection) {
return
}
const connection = this.activeConnection
this.activeConnection = null
await connection.transport.close().catch(() => {})
}
}

View File

@@ -1,56 +0,0 @@
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { LockfilePayload } from './protocol.js'
type BuildLockfilePayloadInput = {
pid: number
ideName: string
workspaceFolders: string[]
authToken: string
runningInWindows: boolean
}
function getClaudeConfigDir(): string {
return (process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude')).normalize(
'NFC',
)
}
export function buildLockfilePayload(
input: BuildLockfilePayloadInput,
): LockfilePayload {
return {
workspaceFolders: input.workspaceFolders,
pid: input.pid,
ideName: input.ideName,
transport: 'ws',
runningInWindows: input.runningInWindows,
authToken: input.authToken,
}
}
export function getLockfileDir(): string {
return join(getClaudeConfigDir(), 'ide')
}
export function getLockfilePath(port: number): string {
return join(getLockfileDir(), `${port}.lock`)
}
export async function writeLockfile(
port: number,
payload: LockfilePayload,
): Promise<string> {
const lockfilePath = getLockfilePath(port)
await mkdir(getLockfileDir(), { recursive: true })
await writeFile(lockfilePath, JSON.stringify(payload), 'utf8')
return lockfilePath
}
export async function removeLockfile(lockfilePath: string | null): Promise<void> {
if (!lockfilePath) {
return
}
await rm(lockfilePath, { force: true })
}

View File

@@ -1,33 +0,0 @@
import { z } from 'zod/v4'
export type LockfilePayload = {
workspaceFolders: string[]
pid: number
ideName: string
transport: 'ws'
runningInWindows: boolean
authToken: string
}
export const OpenDiffArgumentsSchema = z.object({
old_file_path: z.string(),
new_file_path: z.string(),
new_file_contents: z.string(),
tab_name: z.string(),
})
export const CloseTabArgumentsSchema = z.object({
tab_name: z.string(),
})
export const CloseAllDiffTabsArgumentsSchema = z.object({})
export const IdeConnectedNotificationSchema = z.object({
method: z.literal('ide_connected'),
params: z.object({
pid: z.number(),
}),
})
export type OpenDiffArguments = z.infer<typeof OpenDiffArgumentsSchema>
export type CloseTabArguments = z.infer<typeof CloseTabArgumentsSchema>

View File

@@ -1,5 +0,0 @@
import { randomBytes } from 'node:crypto'
export function createAuthToken(): string {
return randomBytes(24).toString('hex')
}

View File

@@ -1,41 +0,0 @@
export type SelectionPoint = {
line: number
character: number
}
export type SelectionChangedParams = {
selection: {
start: SelectionPoint
end: SelectionPoint
} | null
text?: string
filePath?: string
}
type BuildSelectionChangedParamsInput = {
filePath?: string
text?: string
start?: SelectionPoint
end?: SelectionPoint
}
export function buildSelectionChangedParams(
input: BuildSelectionChangedParamsInput,
): SelectionChangedParams {
if (!input.start || !input.end) {
return {
selection: null,
text: input.text,
filePath: input.filePath,
}
}
return {
selection: {
start: input.start,
end: input.end,
},
text: input.text,
filePath: input.filePath,
}
}

View File

@@ -1,92 +0,0 @@
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
import {
type JSONRPCMessage,
JSONRPCMessageSchema,
} from '@modelcontextprotocol/sdk/types.js'
type WebSocketLike = {
readyState: number
send(data: string, callback?: (error?: Error) => void): void
close(): void
on(event: 'message', listener: (data: Buffer | string) => void): void
on(event: 'close', listener: () => void): void
on(event: 'error', listener: (error: Error) => void): void
off(event: 'message', listener: (data: Buffer | string) => void): void
off(event: 'close', listener: () => void): void
off(event: 'error', listener: (error: Error) => void): void
}
const WS_OPEN = 1
export class ServerWebSocketTransport implements Transport {
private started = false
constructor(private readonly socket: WebSocketLike) {
this.socket.on('message', this.handleMessage)
this.socket.on('close', this.handleClose)
this.socket.on('error', this.handleError)
}
onclose?: () => void
onerror?: (error: Error) => void
onmessage?: (message: JSONRPCMessage) => void
async start(): Promise<void> {
if (this.started) {
throw new Error('Start can only be called once per transport.')
}
if (this.socket.readyState !== WS_OPEN) {
throw new Error('WebSocket is not open. Cannot start transport.')
}
this.started = true
}
async send(message: JSONRPCMessage): Promise<void> {
if (this.socket.readyState !== WS_OPEN) {
throw new Error('WebSocket is not open. Cannot send message.')
}
await new Promise<void>((resolve, reject) => {
this.socket.send(JSON.stringify(message), error => {
if (error) {
reject(error)
return
}
resolve()
})
})
}
async close(): Promise<void> {
if (this.socket.readyState === WS_OPEN) {
this.socket.close()
return
}
this.cleanup()
}
private handleMessage = (data: Buffer | string) => {
try {
const raw = typeof data === 'string' ? data : data.toString('utf8')
const parsed = JSONRPCMessageSchema.parse(JSON.parse(raw))
this.onmessage?.(parsed)
} catch (error) {
this.handleError(error instanceof Error ? error : new Error(String(error)))
}
}
private handleClose = () => {
this.cleanup()
this.onclose?.()
}
private handleError = (error: Error) => {
this.onerror?.(error)
}
private cleanup() {
this.socket.off('message', this.handleMessage)
this.socket.off('close', this.handleClose)
this.socket.off('error', this.handleError)
}
}

View File

@@ -1,19 +0,0 @@
type EnvironmentVariableCollectionLike = {
replace(name: string, value: string): void
delete(name: string): void
}
const CLAUDE_CODE_SSE_PORT = 'CLAUDE_CODE_SSE_PORT'
export function setClaudeCodeIdePort(
collection: EnvironmentVariableCollectionLike | undefined,
port: number,
): void {
collection?.replace(CLAUDE_CODE_SSE_PORT, String(port))
}
export function clearClaudeCodeIdePort(
collection: EnvironmentVariableCollectionLike | undefined,
): void {
collection?.delete(CLAUDE_CODE_SSE_PORT)
}

View File

@@ -1,53 +0,0 @@
import { buildSelectionChangedParams } from './selectionPublisher.js'
type WorkspaceFolderLike = {
uri?: {
fsPath?: string
}
}
type EditorLike = {
document?: {
uri?: {
fsPath?: string
}
getText(selection: unknown): string
}
selection?: {
start: {
line: number
character: number
}
end: {
line: number
character: number
}
isEmpty?: boolean
}
}
export function getWorkspaceFolderPaths(
workspaceFolders: WorkspaceFolderLike[] | undefined,
): string[] {
return (workspaceFolders ?? [])
.map(folder => folder.uri?.fsPath)
.filter((value): value is string => Boolean(value))
}
export function getActiveSelectionSnapshot(editor: EditorLike | undefined) {
const filePath = editor?.document?.uri?.fsPath
const selection = editor?.selection
if (!editor?.document || !selection || selection.isEmpty) {
return buildSelectionChangedParams({
filePath,
})
}
return buildSelectionChangedParams({
filePath,
text: editor.document.getText(selection),
start: selection.start,
end: selection.end,
})
}

View File

@@ -1,4 +0,0 @@
declare module 'vscode' {
const vscode: any
export = vscode
}

View File

@@ -1,3 +0,0 @@
declare module 'ws' {
export const WebSocketServer: any
}

View File

@@ -1,135 +0,0 @@
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { describe, expect, test } from 'bun:test'
import { z } from 'zod/v4'
import { createLinkedTransportPair } from '../../../src/services/mcp/InProcessTransport.js'
import {
createIdeBridgeServer,
type DiffController,
} from '../src/server/bridgeServer.js'
const SelectionChangedSchema = z.object({
method: z.literal('selection_changed'),
params: z.object({
selection: z
.object({
start: z.object({ line: z.number(), character: z.number() }),
end: z.object({ line: z.number(), character: z.number() }),
})
.nullable(),
text: z.string().optional(),
filePath: z.string().optional(),
}),
})
function createTestClient() {
return new Client({
name: 'vscode-ide-bridge-test-client',
version: '0.0.1',
})
}
describe('ide bridge MCP server', () => {
test('lists the bridge tools and delegates openDiff calls', async () => {
const openDiffCalls: Array<Record<string, unknown>> = []
const diffController: DiffController = {
async openDiff(args) {
openDiffCalls.push(args)
return {
content: [{ type: 'text', text: 'TAB_CLOSED' }],
}
},
async closeTab() {
return {
content: [{ type: 'text', text: 'TAB_CLOSED' }],
}
},
async closeAllDiffTabs() {
return {
content: [{ type: 'text', text: 'OK' }],
}
},
}
const bridge = createIdeBridgeServer({ diffController })
const client = createTestClient()
const [clientTransport, serverTransport] = createLinkedTransportPair()
await bridge.server.connect(serverTransport)
await client.connect(clientTransport)
const toolResult = await client.listTools()
expect(toolResult.tools.map(tool => tool.name)).toEqual([
'openDiff',
'close_tab',
'closeAllDiffTabs',
])
const openDiffResult = await client.callTool({
name: 'openDiff',
arguments: {
old_file_path: 'D:/vibe/claude-code/src/cli/print.ts',
new_file_path: 'D:/vibe/claude-code/src/cli/print.ts',
new_file_contents: 'new content',
tab_name: 'tab-1',
},
})
expect(openDiffResult.content[0]).toEqual({
type: 'text',
text: 'TAB_CLOSED',
})
expect(openDiffCalls).toHaveLength(1)
expect(openDiffCalls[0]?.tab_name).toBe('tab-1')
})
test('forwards selection_changed notifications to the connected client', async () => {
const diffController: DiffController = {
async openDiff() {
return {
content: [{ type: 'text', text: 'TAB_CLOSED' }],
}
},
async closeTab() {
return {
content: [{ type: 'text', text: 'TAB_CLOSED' }],
}
},
async closeAllDiffTabs() {
return {
content: [{ type: 'text', text: 'OK' }],
}
},
}
const bridge = createIdeBridgeServer({ diffController })
const client = createTestClient()
const [clientTransport, serverTransport] = createLinkedTransportPair()
await bridge.server.connect(serverTransport)
await client.connect(clientTransport)
const notificationPromise = new Promise<z.infer<typeof SelectionChangedSchema>>(
resolve => {
client.setNotificationHandler(SelectionChangedSchema, notification => {
resolve(notification)
})
},
)
await bridge.notifySelectionChanged({
selection: {
start: { line: 4, character: 2 },
end: { line: 6, character: 0 },
},
text: 'selected text',
filePath: 'D:/vibe/claude-code/src/cli/print.ts',
})
const notification = await notificationPromise
expect(notification.params.filePath).toBe(
'D:/vibe/claude-code/src/cli/print.ts',
)
expect(notification.params.text).toBe('selected text')
expect(notification.params.selection?.start.line).toBe(4)
})
})

View File

@@ -1,247 +0,0 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, mock, test } from 'bun:test'
type FakeUri = {
scheme: string
fsPath: string
path: string
query: string
toString(): string
}
type FakeDocument = {
uri: FakeUri
isDirty: boolean
lineCount: number
lineAt(index: number): { text: string }
getText(): string
setText(next: string): void
}
function createFakeUri(
scheme: string,
fsPath: string,
query = '',
): FakeUri {
const normalizedFsPath = fsPath.replaceAll('\\', '/')
return {
scheme,
fsPath,
path: fsPath,
query,
toString() {
if (scheme === 'file') {
return `file://${normalizedFsPath}`
}
return `${scheme}:/${normalizedFsPath}${query ? `?${query}` : ''}`
},
}
}
function createFakeVscode() {
const documents = new Map<string, FakeDocument>()
const saveListeners = new Set<(document: FakeDocument) => void>()
const visibleEditorListeners = new Set<(editors: any[]) => void>()
const visibleTextEditors: any[] = []
function createDocument(uri: FakeUri, initialText = ''): FakeDocument {
let text = initialText
return {
uri,
isDirty: false,
get lineCount() {
return Math.max(text.split('\n').length, 1)
},
lineAt(index: number) {
return {
text: text.split('\n')[index] ?? '',
}
},
getText() {
return text
},
setText(next: string) {
text = next
this.isDirty = true
},
}
}
const vscode = {
Uri: {
parse(value: string) {
const match = value.match(/^([a-z-]+):\/(.+?)(?:\?(.*))?$/i)
if (!match) {
throw new Error(`Unsupported URI: ${value}`)
}
const [, scheme, path, query = ''] = match
return createFakeUri(
scheme,
decodeURIComponent(path),
query,
)
},
file(filePath: string) {
return createFakeUri('file', filePath)
},
},
Range: class {
constructor(
public startLine: number,
public startCharacter: number,
public endLine: number,
public endCharacter: number,
) {}
},
workspace: {
registerTextDocumentContentProvider() {
return { dispose() {} }
},
onDidSaveTextDocument(handler: (document: FakeDocument) => void) {
saveListeners.add(handler)
return {
dispose() {
saveListeners.delete(handler)
},
}
},
async openTextDocument(uri: FakeUri) {
const key = uri.toString()
const existing = documents.get(key)
if (existing) {
return existing
}
const doc = createDocument(uri)
documents.set(key, doc)
return doc
},
},
window: {
visibleTextEditors,
tabGroups: {
all: [],
async close() {},
},
onDidChangeVisibleTextEditors(handler: (editors: any[]) => void) {
visibleEditorListeners.add(handler)
return {
dispose() {
visibleEditorListeners.delete(handler)
},
}
},
async showTextDocument(document: FakeDocument) {
const editor = {
document,
viewColumn: 1,
async edit(
callback: (editBuilder: { replace(range: unknown, text: string): void }) => void,
) {
callback({
replace(_range, text) {
document.setText(text)
},
})
return true
},
}
if (!visibleTextEditors.includes(editor)) {
visibleTextEditors.splice(0, visibleTextEditors.length, editor)
for (const listener of visibleEditorListeners) {
listener([...visibleTextEditors])
}
}
return editor
},
async showInformationMessage() {
return undefined
},
},
commands: {
async executeCommand() {},
},
__documents: documents,
async __emitSave(document: FakeDocument) {
document.isDirty = false
for (const listener of saveListeners) {
listener(document)
}
},
}
return vscode
}
async function waitForDocument(
filePath: string,
attempts = 20,
): Promise<FakeDocument | undefined> {
for (let i = 0; i < attempts; i++) {
const document = fakeVscode.__documents.get(
fakeVscode.Uri.file(filePath).toString(),
)
if (document) {
return document
}
await new Promise(resolve => setTimeout(resolve, 10))
}
return undefined
}
const fakeVscode = createFakeVscode()
mock.module('vscode', () => fakeVscode)
afterEach(() => {
fakeVscode.__documents.clear()
fakeVscode.window.visibleTextEditors.splice(
0,
fakeVscode.window.visibleTextEditors.length,
)
})
describe('diff controller', () => {
test('returns FILE_SAVED with the saved file contents', async () => {
const { createDiffController } = await import(
'../src/server/diffController.js'
)
const tempDir = mkdtempSync(join(tmpdir(), 'claude-code-bridge-'))
const filePath = join(tempDir, 'sample.ts')
writeFileSync(filePath, 'const before = true\n')
const controller = createDiffController({
appendLine() {},
})
const resultPromise = controller.openDiff({
old_file_path: filePath,
new_file_path: filePath,
new_file_contents: 'const proposed = true\n',
tab_name: 'sample.ts',
})
const savedDocument = await waitForDocument(filePath)
expect(savedDocument).toBeDefined()
savedDocument?.setText('const saved = true\n')
await fakeVscode.__emitSave(savedDocument as FakeDocument)
const result = await Promise.race([
resultPromise,
new Promise(resolve =>
setTimeout(() => resolve('timed-out'), 200),
),
])
expect(result).toEqual({
content: [
{ type: 'text', text: 'FILE_SAVED' },
{ type: 'text', text: 'const saved = true\n' },
],
})
await controller.dispose()
})
})

View File

@@ -1,40 +0,0 @@
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
import {
buildLockfilePayload,
getLockfilePath,
} from '../src/server/lockfile.js'
describe('lockfile helpers', () => {
test('builds a ws-ide lockfile payload with auth token and workspace folders', () => {
const payload = buildLockfilePayload({
pid: 123,
ideName: 'VS Code',
workspaceFolders: ['D:/vibe/claude-code'],
authToken: 'token-123',
runningInWindows: true,
})
expect(payload.transport).toBe('ws')
expect(payload.authToken).toBe('token-123')
expect(payload.workspaceFolders).toEqual(['D:/vibe/claude-code'])
expect(payload.pid).toBe(123)
})
test('derives the lockfile path from CLAUDE_CONFIG_DIR when provided', () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = 'D:/tmp/claude-config'
try {
expect(getLockfilePath(4567)).toBe(
join('D:/tmp/claude-config', 'ide', '4567.lock'),
)
} finally {
if (originalConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
}
}
})
})

View File

@@ -1,32 +0,0 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
const packageRoot = join(import.meta.dir, '..')
const packageJsonPath = join(packageRoot, 'package.json')
describe('vscode-ide-bridge package', () => {
test('declares a VSCode extension entry', () => {
expect(existsSync(packageJsonPath)).toBe(true)
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
main?: string
engines?: { vscode?: string }
activationEvents?: string[]
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
}
expect(packageJson.main).toBe('./dist/extension.js')
expect(packageJson.engines?.vscode).toBeDefined()
expect(packageJson.activationEvents).toContain('onStartupFinished')
expect(packageJson.dependencies).toMatchObject({
'@modelcontextprotocol/sdk': expect.any(String),
ws: expect.any(String),
})
expect(packageJson.devDependencies).toMatchObject({
'@types/bun': expect.any(String),
typescript: expect.any(String),
})
})
})

View File

@@ -1,71 +0,0 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
type PackageJson = {
displayName?: string
publisher?: string
license?: string
scripts?: Record<string, string>
}
type TaskConfig = {
label?: string
command?: string
args?: string[]
}
const packageRoot = join(import.meta.dir, '..')
const packageJsonPath = join(packageRoot, 'package.json')
const tasksJsonPath = join(packageRoot, '.vscode', 'tasks.json')
const vscodeIgnorePath = join(packageRoot, '.vscodeignore')
const readmePath = join(packageRoot, 'README.md')
describe('vscode-ide-bridge packaging workflow', () => {
test('declares the metadata and script needed to package a .vsix', () => {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as PackageJson
expect(packageJson.displayName).toBe('Claude Code IDE Bridge')
expect(packageJson.publisher).toBe('claude-code-best')
expect(packageJson.license).toBeDefined()
expect(packageJson.scripts?.bundle).toBe(
'bun build ./src/extension.ts --outdir dist --target node --format esm --external vscode',
)
expect(packageJson.scripts?.package).toBe(
'bun run bundle && bunx @vscode/vsce package --no-dependencies --out dist/vscode-ide-bridge.vsix',
)
})
test('declares a package-local task for building a .vsix', () => {
expect(existsSync(tasksJsonPath)).toBe(true)
const tasksJson = JSON.parse(readFileSync(tasksJsonPath, 'utf8')) as {
tasks?: TaskConfig[]
}
const packageTask = tasksJson.tasks?.find(
item => item.label === 'Package VSCode IDE Bridge',
)
expect(packageTask).toBeDefined()
expect(packageTask?.command).toBe('bun')
expect(packageTask?.args).toEqual(['run', 'package'])
})
test('excludes development-only files from the packaged extension', () => {
expect(existsSync(vscodeIgnorePath)).toBe(true)
const contents = readFileSync(vscodeIgnorePath, 'utf8')
expect(contents).toContain('src/**')
expect(contents).toContain('test/**')
expect(contents).toContain('tsconfig.json')
})
test('keeps the packaged README free of local absolute file links', () => {
const contents = readFileSync(readmePath, 'utf8')
expect(contents).not.toContain('](/')
expect(contents).not.toContain(':/')
})
})

View File

@@ -1,89 +0,0 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
type LaunchConfig = {
name?: string
type?: string
request?: string
preLaunchTask?: string
args?: string[]
}
type TaskConfig = {
label?: string
command?: string
args?: string[]
}
const packageRoot = join(import.meta.dir, '..')
const launchJsonPath = join(packageRoot, '.vscode', 'launch.json')
const tasksJsonPath = join(packageRoot, '.vscode', 'tasks.json')
describe('standalone package workspace workflow', () => {
test('declares a package-local extension host launch config', () => {
expect(existsSync(launchJsonPath)).toBe(true)
const launchJson = JSON.parse(readFileSync(launchJsonPath, 'utf8')) as {
configurations?: LaunchConfig[]
}
const config = launchJson.configurations?.find(
item => item.name === 'Run VSCode IDE Bridge',
)
expect(config).toBeDefined()
expect(config?.type).toBe('extensionHost')
expect(config?.request).toBe('launch')
expect(config?.preLaunchTask).toBe('Build VSCode IDE Bridge')
expect(config?.args).toContain('--new-window')
expect(config?.args).toContain('--disable-extensions')
expect(config?.args).toContain(
'--extensionDevelopmentPath=${workspaceFolder}',
)
})
test('declares a launch config that opens the claude-code workspace root', () => {
const launchJson = JSON.parse(readFileSync(launchJsonPath, 'utf8')) as {
configurations?: LaunchConfig[]
}
const config = launchJson.configurations?.find(
item => item.name === 'Run VSCode IDE Bridge (Open Claude Code Root)',
)
expect(config).toBeDefined()
expect(config?.type).toBe('extensionHost')
expect(config?.request).toBe('launch')
expect(config?.preLaunchTask).toBe('Build VSCode IDE Bridge')
expect(config?.args).toContain('--new-window')
expect(config?.args).toContain('--disable-extensions')
expect(config?.args).toContain(
'--extensionDevelopmentPath=${workspaceFolder}',
)
expect(config?.args).toContain('${workspaceFolder}/../..')
})
test('declares package-local build and test tasks', () => {
expect(existsSync(tasksJsonPath)).toBe(true)
const tasksJson = JSON.parse(readFileSync(tasksJsonPath, 'utf8')) as {
tasks?: TaskConfig[]
}
const buildTask = tasksJson.tasks?.find(
item => item.label === 'Build VSCode IDE Bridge',
)
const testTask = tasksJson.tasks?.find(
item => item.label === 'Test VSCode IDE Bridge',
)
expect(buildTask).toBeDefined()
expect(buildTask?.command).toBe('bunx')
expect(buildTask?.args).toEqual(['tsc', '-p', 'tsconfig.json'])
expect(testTask).toBeDefined()
expect(testTask?.command).toBe('bun')
expect(testTask?.args).toEqual(['test', 'test'])
})
})

View File

@@ -1,27 +0,0 @@
import { describe, expect, test } from 'bun:test'
import { buildSelectionChangedParams } from '../src/server/selectionPublisher.js'
describe('selection publisher helpers', () => {
test('serializes a selected range with text and file path', () => {
const params = buildSelectionChangedParams({
filePath: 'D:/vibe/claude-code/src/cli/print.ts',
text: 'const value = 1',
start: { line: 10, character: 2 },
end: { line: 10, character: 17 },
})
expect(params.filePath).toBe('D:/vibe/claude-code/src/cli/print.ts')
expect(params.text).toBe('const value = 1')
expect(params.selection?.start.line).toBe(10)
expect(params.selection?.end.character).toBe(17)
})
test('keeps file context when there is no active selection', () => {
const params = buildSelectionChangedParams({
filePath: 'D:/vibe/claude-code/src/cli/print.ts',
})
expect(params.filePath).toBe('D:/vibe/claude-code/src/cli/print.ts')
expect(params.selection).toBeNull()
})
})

View File

@@ -1,71 +0,0 @@
import { EventEmitter } from 'node:events'
import { describe, expect, test } from 'bun:test'
import { ServerWebSocketTransport } from '../src/server/serverWebSocketTransport.js'
class FakeWebSocket extends EventEmitter {
readyState = 1
sent: string[] = []
closed = false
send(data: string, callback?: (error?: Error) => void) {
this.sent.push(data)
callback?.()
}
close() {
this.closed = true
this.emit('close')
}
}
describe('server web socket transport', () => {
test('forwards incoming JSON-RPC messages to the MCP server', async () => {
const socket = new FakeWebSocket()
const transport = new ServerWebSocketTransport(socket)
const messages: unknown[] = []
transport.onmessage = message => {
messages.push(message)
}
await transport.start()
socket.emit(
'message',
Buffer.from(
JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'ping',
params: {},
}),
),
)
expect(messages).toHaveLength(1)
expect(messages[0]).toEqual({
jsonrpc: '2.0',
id: 1,
method: 'ping',
params: {},
})
})
test('serializes outgoing JSON-RPC messages back to the websocket', async () => {
const socket = new FakeWebSocket()
const transport = new ServerWebSocketTransport(socket)
await transport.start()
await transport.send({
jsonrpc: '2.0',
id: 2,
result: {},
})
expect(socket.sent).toHaveLength(1)
expect(JSON.parse(socket.sent[0] ?? 'null')).toEqual({
jsonrpc: '2.0',
id: 2,
result: {},
})
})
})

View File

@@ -1,48 +0,0 @@
import { describe, expect, test } from 'bun:test'
import {
clearClaudeCodeIdePort,
setClaudeCodeIdePort,
} from '../src/server/terminalEnvironment.js'
type FakeEnvironmentVariableCollection = {
replaceCalls: Array<{ name: string; value: string }>
deleteCalls: string[]
replace(name: string, value: string): void
delete(name: string): void
}
function createFakeCollection(): FakeEnvironmentVariableCollection {
return {
replaceCalls: [],
deleteCalls: [],
replace(name, value) {
this.replaceCalls.push({ name, value })
},
delete(name) {
this.deleteCalls.push(name)
},
}
}
describe('terminal environment sync', () => {
test('sets CLAUDE_CODE_SSE_PORT to the active bridge port', () => {
const collection = createFakeCollection()
setClaudeCodeIdePort(collection, 52075)
expect(collection.replaceCalls).toEqual([
{
name: 'CLAUDE_CODE_SSE_PORT',
value: '52075',
},
])
})
test('clears CLAUDE_CODE_SSE_PORT when the bridge stops', () => {
const collection = createFakeCollection()
clearClaudeCodeIdePort(collection)
expect(collection.deleteCalls).toEqual(['CLAUDE_CODE_SSE_PORT'])
})
})

View File

@@ -1,61 +0,0 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
type LaunchConfig = {
name?: string
type?: string
request?: string
preLaunchTask?: string
args?: string[]
}
type TaskConfig = {
label?: string
command?: string
args?: string[]
}
const workspaceRoot = join(import.meta.dir, '..', '..', '..')
const launchJsonPath = join(workspaceRoot, '.vscode', 'launch.json')
const tasksJsonPath = join(workspaceRoot, '.vscode', 'tasks.json')
describe('VSCode IDE bridge developer workflow', () => {
test('declares a one-click extension host launch config', () => {
const launchJson = JSON.parse(readFileSync(launchJsonPath, 'utf8')) as {
configurations?: LaunchConfig[]
}
const config = launchJson.configurations?.find(
item => item.name === 'Run VSCode IDE Bridge',
)
expect(config).toBeDefined()
expect(config?.type).toBe('extensionHost')
expect(config?.request).toBe('launch')
expect(config?.preLaunchTask).toBe('Build VSCode IDE Bridge')
expect(config?.args).toContain('--new-window')
expect(config?.args).toContain('--disable-extensions')
expect(config?.args).toContain(
'--extensionDevelopmentPath=${workspaceFolder}/packages/vscode-ide-bridge',
)
})
test('declares a build task for the bridge package', () => {
const tasksJson = JSON.parse(readFileSync(tasksJsonPath, 'utf8')) as {
tasks?: TaskConfig[]
}
const task = tasksJson.tasks?.find(
item => item.label === 'Build VSCode IDE Bridge',
)
expect(task).toBeDefined()
expect(task?.command).toBe('bunx')
expect(task?.args).toEqual([
'tsc',
'-p',
'packages/vscode-ide-bridge/tsconfig.json',
])
})
})

View File

@@ -1,41 +0,0 @@
import { describe, expect, test } from 'bun:test'
import {
getActiveSelectionSnapshot,
getWorkspaceFolderPaths,
} from '../src/server/workspaceInfo.js'
describe('workspace info helpers', () => {
test('collects workspace folder fs paths', () => {
expect(
getWorkspaceFolderPaths([
{ uri: { fsPath: 'D:/vibe/claude-code' } },
{ uri: { fsPath: 'D:/vibe/another-project' } },
]),
).toEqual(['D:/vibe/claude-code', 'D:/vibe/another-project'])
})
test('extracts the active editor selection text and file path', () => {
const snapshot = getActiveSelectionSnapshot({
document: {
uri: { fsPath: 'D:/vibe/claude-code/src/cli/print.ts' },
getText(selection: unknown) {
expect(selection).toEqual({
start: { line: 3, character: 1 },
end: { line: 5, character: 0 },
isEmpty: false,
})
return 'selected lines'
},
},
selection: {
start: { line: 3, character: 1 },
end: { line: 5, character: 0 },
isEmpty: false,
},
})
expect(snapshot.filePath).toBe('D:/vibe/claude-code/src/cli/print.ts')
expect(snapshot.text).toBe('selected lines')
expect(snapshot.selection?.start.line).toBe(3)
})
})

View File

@@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": [
"bun"
]
},
"include": [
"src/**/*.ts"
]
}

View File

@@ -66,16 +66,9 @@ export const DEFAULT_BUILD_FEATURES = [
'COMMIT_ATTRIBUTION', // Git 提交归属追踪(记录 AI 辅助贡献)
// Server mode (claude server / claude open)
'DIRECT_CONNECT', // 直连模式claude server / claude open
// Skill search & learning — feature flags compiled in (so the slash
// commands /skill-* etc. exist), but the runtime "enabled" toggle
// defaults to OFF (see featureCheck.ts). Operators turn on via the
// slash-command toggle or env vars (SKILL_SEARCH_ENABLED=1,
// SKILL_LEARNING_ENABLED=1). Rationale: bounded caches added on
// this branch (see docs/agent/sur-skill-overflow-bugs.md) close the
// overflow risk, but Haiku-on-first-Chinese-query and disk-side
// observation accumulation remain operator-discretion concerns.
'EXPERIMENTAL_SKILL_SEARCH',
'SKILL_LEARNING',
// Skill search & learning
'EXPERIMENTAL_SKILL_SEARCH', // 实验性技能搜索DiscoverSkills
// 'SKILL_LEARNING', // projectContext cache 无淘汰机制(非 GB 级主因)
// P3: poor mode
'POOR', // 穷鬼模式,跳过 extract_memories/prompt_suggestion 减少消耗
// Team Memory

View File

@@ -178,19 +178,6 @@ export type ToolUseContext = {
querySource?: QuerySource
/** Optional callback to get the latest tools (e.g., after MCP servers connect mid-query) */
refreshTools?: () => Tools
/**
* @internal TEST-ONLY ESCAPE HATCH. MUST remain undefined in production.
*
* Allows non-bundled unit-test harnesses to exercise the background
* forked slash command path that production assistant mode gates behind
* `feature('KAIROS')`. Still requires `AppState.kairosEnabled`. This
* field is constructed in-process by trusted application code only;
* no external surface (MCP, plugin, slash command, network) writes to
* `ToolUseContext.options`. Setting this true outside a test bypasses
* the KAIROS feature flag; `processSlashCommand` rejects this flag
* outside `NODE_ENV=test`.
*/
allowBackgroundForkedSlashCommands?: boolean
}
abortController: AbortController
readFileState: FileStateCache

View File

@@ -1,18 +1,8 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { createAbortController } from '../utils/abortController'
import { QueryGuard } from '../utils/QueryGuard'
import { handlePromptSubmit } from '../utils/handlePromptSubmit'
import {
getCommandQueue,
resetCommandQueue,
} from '../utils/messageQueueManager'
import { cleanupTempDir, createTempDir } from '../../tests/mocks/file-system'
import {
createAutonomyQueuedPrompt,
markAutonomyRunCancelled,
} from '../utils/autonomyRuns'
let tempDirs: string[] = []
import { getCommandQueue, resetCommandQueue } from '../utils/messageQueueManager'
function createBaseParams() {
const queryGuard = new QueryGuard()
@@ -38,9 +28,11 @@ function createBaseParams() {
commands: [],
setUserInputOnProcessing: mock((_prompt?: string) => {}),
setAbortController: mock((_abortController: AbortController | null) => {}),
onQuery: mock(async () => true) as unknown as (
onQuery: mock(
async () => undefined,
) as unknown as (
...args: unknown[]
) => Promise<boolean>,
) => Promise<void>,
setAppState: mock((_updater: unknown) => {}),
}
}
@@ -48,13 +40,6 @@ function createBaseParams() {
describe('handlePromptSubmit', () => {
beforeEach(() => {
resetCommandQueue()
tempDirs = []
})
afterEach(async () => {
for (const tempDir of tempDirs) {
await cleanupTempDir(tempDir)
}
})
test('aborts the current turn when only cancel-interrupt tools are running', async () => {
@@ -133,34 +118,4 @@ describe('handlePromptSubmit', () => {
bridgeOrigin: true,
})
})
test('skips stale autonomy commands in the idle queued path', async () => {
const params = createBaseParams()
const abortController = createAbortController()
const tempDir = await createTempDir('handle-prompt-autonomy-')
tempDirs.push(tempDir)
const command = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
await markAutonomyRunCancelled(command!.autonomy!.runId, tempDir)
await handlePromptSubmit({
...params,
input: '',
mode: 'prompt',
pastedContents: {},
abortController,
streamMode: 'normal' as any,
hasInterruptibleToolInProgress: false,
isExternalLoading: false,
queuedCommands: [command!],
})
expect(params.getToolUseContext).not.toHaveBeenCalled()
expect(params.onQuery).not.toHaveBeenCalled()
})
})

View File

@@ -1,337 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { randomUUID } from 'crypto'
import {
resetStateForTests,
setCwdState,
setOriginalCwd,
setProjectRoot,
} from '../bootstrap/state'
import { query } from '../query'
import { getEmptyToolPermissionContext } from '../Tool'
import type { AssistantMessage } from '../types/message'
import { asSystemPrompt } from '../utils/systemPromptType'
import {
createAssistantAPIErrorMessage,
createUserMessage,
} from '../utils/messages'
import { cleanupTempDir, createTempDir } from '../../tests/mocks/file-system'
import {
enqueue,
getCommandsByMaxPriority,
resetCommandQueue,
} from '../utils/messageQueueManager'
import { getAutonomyFlowById, listAutonomyFlows } from '../utils/autonomyFlows'
import {
getAutonomyRunById,
startManagedAutonomyFlowFromHeartbeatTask,
} from '../utils/autonomyRuns'
let tempDir = ''
let originalProcessCwd = ''
beforeEach(async () => {
originalProcessCwd = process.cwd()
tempDir = await createTempDir('query-autonomy-provider-boundary-')
resetStateForTests()
resetCommandQueue()
setOriginalCwd(tempDir)
setCwdState(tempDir)
setProjectRoot(tempDir)
})
afterEach(async () => {
resetStateForTests()
resetCommandQueue()
if (originalProcessCwd) {
process.chdir(originalProcessCwd)
}
if (tempDir) {
let lastError: unknown
for (let attempt = 0; attempt < 20; attempt++) {
try {
await cleanupTempDir(tempDir)
lastError = undefined
break
} catch (error) {
lastError = error
await new Promise(resolve => setTimeout(resolve, 100))
}
}
if (lastError) {
throw lastError
}
}
})
function createToolUseAssistantMessage(): AssistantMessage {
return {
type: 'assistant',
uuid: randomUUID(),
timestamp: new Date().toISOString(),
requestId: undefined,
message: {
id: 'msg_tool_use',
type: 'message',
role: 'assistant',
model: 'test-model',
stop_reason: 'tool_use',
stop_sequence: null,
usage: {
input_tokens: 1,
output_tokens: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
content: [
{
type: 'tool_use',
id: 'toolu_provider_boundary',
name: 'MissingBoundaryTool',
input: {},
},
],
},
} as unknown as AssistantMessage
}
function createToolUseContext(): any {
let inProgressToolUseIds = new Set<string>()
let responseLength = 0
let appState = {
toolPermissionContext: getEmptyToolPermissionContext(),
fastMode: false,
mcp: {
tools: [],
clients: [],
},
effortValue: undefined,
advisorModel: undefined,
sessionHooks: new Map(),
}
return {
options: {
commands: [],
debug: false,
mainLoopModel: 'claude-sonnet-4-5-20250929',
tools: [],
verbose: false,
thinkingConfig: { type: 'disabled' },
mcpClients: [],
mcpResources: {},
isNonInteractiveSession: true,
agentDefinitions: {
activeAgents: [],
allowedAgentTypes: [],
},
},
abortController: new AbortController(),
readFileState: new Map(),
getAppState: () => appState,
setAppState: (updater: (state: any) => any) => {
appState = updater(appState as never)
},
setInProgressToolUseIDs: (updater: (state: Set<string>) => Set<string>) => {
inProgressToolUseIds = updater(inProgressToolUseIds)
},
setResponseLength: (updater: (state: number) => number) => {
responseLength = updater(responseLength)
},
updateFileHistoryState: () => {},
updateAttributionState: () => {},
messages: [],
} as any
}
describe('query autonomy/provider boundary', () => {
test('provider api-error messages fail a consumed autonomy run instead of advancing the flow', async () => {
const previousDisableAttachments =
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = '1'
try {
const command = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'provider-boundary',
interval: '1h',
prompt: 'Exercise provider boundary',
steps: [
{ name: 'first', prompt: 'First provider-boundary step' },
{ name: 'second', prompt: 'Second provider-boundary step' },
],
},
rootDir: tempDir,
currentDir: tempDir,
priority: 'next',
})
expect(command).not.toBeNull()
enqueue(command!)
const toolUseContext = createToolUseContext()
let callCount = 0
const deps = {
uuid: () => 'query-chain-id',
microcompact: async (messages: unknown[]) => ({ messages }),
autocompact: async () => ({
compactionResult: undefined,
consecutiveFailures: 0,
}),
callModel: async function* () {
callCount += 1
if (callCount === 1) {
yield createToolUseAssistantMessage()
return
}
yield createAssistantAPIErrorMessage({
content: 'API Error: provider unavailable',
apiError: 'api_error',
error: new Error('provider unavailable') as never,
})
},
}
const emitted: any[] = []
const generator = query({
messages: [
createUserMessage({
content: 'start provider-boundary test',
}),
],
systemPrompt: asSystemPrompt([]),
userContext: {},
systemContext: {},
canUseTool: async (_tool, input) => ({
behavior: 'allow',
updatedInput: input,
}),
toolUseContext,
querySource: 'sdk',
maxTurns: 3,
deps: deps as never,
})
let next = await generator.next()
while (!next.done) {
emitted.push(next.value)
next = await generator.next()
}
const [flow] = await listAutonomyFlows(tempDir)
const finalFlow = await getAutonomyFlowById(flow!.flowId, tempDir)
const run = await getAutonomyRunById(command!.autonomy!.runId, tempDir)
expect(next.value.reason).toBe('model_error')
expect(callCount).toBe(2)
expect(
emitted.some(
message =>
message.type === 'attachment' &&
message.attachment.type === 'queued_command',
),
).toBe(true)
expect(run!.status).toBe('failed')
expect(run!.error).toBe('provider api_error')
expect(finalFlow!.status).toBe('failed')
expect(finalFlow!.stateJson!.steps.map(step => step.status)).toEqual([
'failed',
'pending',
])
expect(getCommandsByMaxPriority('later')).toHaveLength(0)
} finally {
if (previousDisableAttachments === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
} else {
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = previousDisableAttachments
}
}
})
test('generator return cancels a consumed autonomy run instead of leaving it running', async () => {
const previousDisableAttachments =
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = '1'
try {
const command = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'return-boundary',
interval: '1h',
prompt: 'Exercise generator return boundary',
steps: [
{ name: 'first', prompt: 'First return-boundary step' },
{ name: 'second', prompt: 'Second return-boundary step' },
],
},
rootDir: tempDir,
currentDir: tempDir,
priority: 'next',
})
expect(command).not.toBeNull()
enqueue(command!)
const toolUseContext = createToolUseContext()
const deps = {
uuid: () => 'query-chain-id',
microcompact: async (messages: unknown[]) => ({ messages }),
autocompact: async () => ({
compactionResult: undefined,
consecutiveFailures: 0,
}),
callModel: async function* () {
yield createToolUseAssistantMessage()
},
}
const generator = query({
messages: [
createUserMessage({
content: 'start return-boundary test',
}),
],
systemPrompt: asSystemPrompt([]),
userContext: {},
systemContext: {},
canUseTool: async (_tool, input) => ({
behavior: 'allow',
updatedInput: input,
}),
toolUseContext,
querySource: 'sdk',
maxTurns: 3,
deps: deps as never,
})
let sawQueuedAttachment = false
let next = await generator.next()
while (!next.done) {
const message = next.value as any
if (
message.type === 'attachment' &&
message.attachment.type === 'queued_command'
) {
sawQueuedAttachment = true
await generator.return(undefined as never)
break
}
next = await generator.next()
}
const [flow] = await listAutonomyFlows(tempDir)
const finalFlow = await getAutonomyFlowById(flow!.flowId, tempDir)
const run = await getAutonomyRunById(command!.autonomy!.runId, tempDir)
expect(sawQueuedAttachment).toBe(true)
expect(run!.status).toBe('cancelled')
expect(finalFlow!.status).toBe('cancelled')
expect(finalFlow!.stateJson!.steps.map(step => step.status)).toEqual([
'cancelled',
'cancelled',
])
expect(getCommandsByMaxPriority('later')).toHaveLength(0)
} finally {
if (previousDisableAttachments === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
} else {
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = previousDisableAttachments
}
}
})
})

View File

@@ -321,15 +321,16 @@ import {
} from 'src/utils/queryProfiler.js'
import { asSessionId } from 'src/types/ids.js'
import {
createAutonomyQueuedPromptIfNoActiveSource,
commitAutonomyQueuedPrompt,
createAutonomyQueuedPrompt,
createProactiveAutonomyCommands,
finalizeAutonomyRunCompleted,
finalizeAutonomyRunFailed,
markAutonomyRunCompleted,
markAutonomyRunFailed,
markAutonomyRunRunning,
} from 'src/utils/autonomyRuns.js'
import {
cancelQueuedAutonomyCommands,
claimConsumableQueuedAutonomyCommands,
finalizeAutonomyCommandsForTurn,
} from 'src/utils/autonomyQueueLifecycle.js'
import { prepareAutonomyTurnPrompt } from 'src/utils/autonomyAuthority.js'
import { jsonStringify } from '../utils/slowOperations.js'
import { skillChangeDetector } from '../utils/skills/skillChangeDetector.js'
import { getCommands, clearCommandsCache } from '../commands.js'
@@ -1864,26 +1865,17 @@ function runHeadlessStreaming(
currentDir: cwd(),
shouldCreate: () => !inputClosed,
})
if (inputClosed) {
await cancelQueuedAutonomyCommands({ commands })
return
}
for (const command of commands) {
if (inputClosed) {
return
}
enqueue({
...command,
uuid: randomUUID(),
})
}
void run()
})().catch(error => {
logError(error)
logForDebugging(
`[Proactive] failed to create headless tick: ${error}`,
{
level: 'error',
},
)
})
})()
}, 0)
}
: undefined
@@ -1979,24 +1971,17 @@ function runHeadlessStreaming(
// Non-prompt commands (task-notification, orphaned-permission) carry
// side effects or orphanedPermission state, so they process singly.
// Prompt commands greedily collect followers with matching workload.
let batch: QueuedCommand[] = [command]
const batch: QueuedCommand[] = [command]
if (command.mode === 'prompt') {
while (canBatchWith(command, peek(isMainThread))) {
batch.push(dequeue(isMainThread)!)
}
}
const queuedAutonomyClaim =
await claimConsumableQueuedAutonomyCommands(batch)
batch = queuedAutonomyClaim.attachmentCommands
if (batch.length === 0) {
continue
}
command = batch[0]!
if (command.mode === 'prompt' && batch.length > 1) {
command = {
...command,
value: joinPromptValues(batch.map(c => c.value)),
uuid: batch.findLast(c => c.uuid)?.uuid ?? command.uuid,
if (batch.length > 1) {
command = {
...command,
value: joinPromptValues(batch.map(c => c.value)),
uuid: batch.findLast(c => c.uuid)?.uuid ?? command.uuid,
}
}
}
const batchUuids = batch.map(c => c.uuid).filter(u => u !== undefined)
@@ -2135,7 +2120,9 @@ function runHeadlessStreaming(
}
const input = command.value
const claimedAutonomyCommands = queuedAutonomyClaim.claimedCommands
const autonomyRunIds = batch
.map(item => item.autonomy?.runId)
.filter((runId): runId is string => Boolean(runId))
if (structuredIO instanceof RemoteIO && command.mode === 'prompt') {
logEvent('tengu_bridge_message_received', {
@@ -2185,6 +2172,9 @@ function runHeadlessStreaming(
// const-capture: TS loses `while ((command = dequeue()))` narrowing
// inside the closure.
const cmd = command
for (const runId of autonomyRunIds) {
await markAutonomyRunRunning(runId)
}
let lastResultIsError = false
try {
await runWithWorkload(
@@ -2296,39 +2286,35 @@ function runHeadlessStreaming(
},
) // end runWithWorkload
if (lastResultIsError) {
await finalizeAutonomyCommandsForTurn({
commands: claimedAutonomyCommands,
outcome: {
type: 'failed',
message: 'ask() returned an error result',
},
currentDir: cwd(),
priority: 'later',
workload: cmd.workload ?? options.workload,
})
} else {
const nextCommands = await finalizeAutonomyCommandsForTurn({
commands: claimedAutonomyCommands,
outcome: { type: 'completed' },
currentDir: cwd(),
priority: 'later',
workload: cmd.workload ?? options.workload,
})
for (const nextCommand of nextCommands) {
enqueue({
...nextCommand,
uuid: randomUUID(),
for (const runId of autonomyRunIds) {
await finalizeAutonomyRunFailed({
runId,
error: 'ask() returned an error result',
})
}
} else {
for (const runId of autonomyRunIds) {
const nextCommands = await finalizeAutonomyRunCompleted({
runId,
currentDir: cwd(),
priority: 'later',
workload: cmd.workload ?? options.workload,
})
for (const nextCommand of nextCommands) {
enqueue({
...nextCommand,
uuid: randomUUID(),
})
}
}
}
} catch (error) {
await finalizeAutonomyCommandsForTurn({
commands: claimedAutonomyCommands,
outcome: { type: 'failed', error },
currentDir: cwd(),
priority: 'later',
workload: cmd.workload ?? options.workload,
})
for (const runId of autonomyRunIds) {
await finalizeAutonomyRunFailed({
runId,
error: String(error),
})
}
throw error
}
@@ -2819,90 +2805,72 @@ function runHeadlessStreaming(
let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null =
null
if (cronGate.isKairosCronEnabled()) {
// Shared dedup-claim → input-close-recheck → onSuccess pipeline for the
// three cron entry points (legacy onFire, onFireTask agent, onFireTask
// non-agent). Centralizing the cancel-on-late-shutdown contract here keeps
// the three branches from drifting on what happens between claim and
// dispatch. onSuccess receives the claimed QueuedCommand and decides
// whether to enqueue it (normal path) or mark the run failed (agent path).
const dispatchHeadlessCronCommand = (params: {
basePrompt: string
sourceId: string
sourceLabel: string
logSuffix: string
onSuccess: (command: QueuedCommand) => void | Promise<void>
}): void => {
if (inputClosed) return
void (async () => {
const command = await createAutonomyQueuedPromptIfNoActiveSource({
basePrompt: params.basePrompt,
trigger: 'scheduled-task',
currentDir: cwd(),
sourceId: params.sourceId,
sourceLabel: params.sourceLabel,
workload: WORKLOAD_CRON,
shouldCreate: () => !inputClosed,
})
if (!command) return
if (inputClosed) {
await cancelQueuedAutonomyCommands({ commands: [command] })
return
}
await params.onSuccess(command)
})().catch(error => {
logError(error)
logForDebugging(
`[ScheduledTasks] failed to enqueue headless task${params.logSuffix}: ${error}`,
{ level: 'error' },
)
})
}
const enqueueAndRun = (command: QueuedCommand): void => {
enqueue({
...command,
uuid: randomUUID(),
})
void run()
}
cronScheduler = cronSchedulerModule.createCronScheduler({
onFire: prompt => {
// Legacy KAIROS-style entries: the prompt text is what uniquely
// identifies the cron entry, so it doubles as both source id and
// source label for dedup.
dispatchHeadlessCronCommand({
basePrompt: prompt,
sourceId: prompt,
sourceLabel: prompt,
logSuffix: '',
onSuccess: enqueueAndRun,
})
if (inputClosed) return
void (async () => {
const prepared = await prepareAutonomyTurnPrompt({
basePrompt: prompt,
trigger: 'scheduled-task',
currentDir: cwd(),
})
if (inputClosed) return
const command = await commitAutonomyQueuedPrompt({
prepared,
currentDir: cwd(),
workload: WORKLOAD_CRON,
})
if (inputClosed) return
enqueue({
...command,
uuid: randomUUID(),
})
void run()
})()
},
onFireTask: task => {
if (task.agentId) {
dispatchHeadlessCronCommand({
if (inputClosed) return
void (async () => {
if (task.agentId) {
const prepared = await prepareAutonomyTurnPrompt({
basePrompt: task.prompt,
trigger: 'scheduled-task',
currentDir: cwd(),
})
if (inputClosed) return
const command = await commitAutonomyQueuedPrompt({
prepared,
currentDir: cwd(),
sourceId: task.id,
sourceLabel: task.prompt,
workload: WORKLOAD_CRON,
})
await markAutonomyRunFailed(
command.autonomy!.runId,
`No teammate runtime available for scheduled task owner ${task.agentId} in headless mode.`,
)
return
}
const prepared = await prepareAutonomyTurnPrompt({
basePrompt: task.prompt,
trigger: 'scheduled-task',
currentDir: cwd(),
})
if (inputClosed) return
const command = await commitAutonomyQueuedPrompt({
prepared,
currentDir: cwd(),
sourceId: task.id,
sourceLabel: task.prompt,
logSuffix: ` ${task.id}`,
onSuccess: async command => {
await markAutonomyRunFailed(
command.autonomy!.runId,
`No teammate runtime available for scheduled task owner ${task.agentId} in headless mode.`,
command.autonomy!.rootDir,
)
},
workload: WORKLOAD_CRON,
})
return
}
dispatchHeadlessCronCommand({
basePrompt: task.prompt,
sourceId: task.id,
sourceLabel: task.prompt,
logSuffix: ` ${task.id}`,
onSuccess: enqueueAndRun,
})
if (inputClosed) return
enqueue({
...command,
uuid: randomUUID(),
})
void run()
})()
},
isLoading: () => running || inputClosed,
getJitterConfig: cronJitterConfigModule?.getCronJitterConfig,

View File

@@ -1,5 +1,5 @@
import type { Command } from '../../commands.js'
import { isSkillLearningCompiledIn } from '../../services/skillLearning/featureCheck.js'
import { isSkillLearningEnabled } from '../../services/skillLearning/featureCheck.js'
const skillLearning = {
type: 'local-jsx',
@@ -7,10 +7,7 @@ const skillLearning = {
description: 'Manage skill learning (observe, analyze, evolve)',
argumentHint:
'[start|stop|about|status|ingest|evolve|export|import|prune|promote|projects]',
// The slash command is visible whenever the subsystem is compiled in.
// Whether the runtime feature is actually doing work is a separate
// concern controlled by `/skill-learning start` (see featureCheck.ts).
isEnabled: () => isSkillLearningCompiledIn(),
isEnabled: () => isSkillLearningEnabled(),
isHidden: false,
load: () => import('./skillPanel.js'),
} satisfies Command

View File

@@ -1,14 +1,10 @@
import type { Command } from '../../commands.js'
import { isSkillSearchCompiledIn } from '../../services/skillSearch/featureCheck.js'
const skillSearch = {
type: 'local-jsx',
name: 'skill-search',
description: 'Control automatic skill matching during conversations',
argumentHint: '[start|stop|about|status]',
// Visible whenever the subsystem is compiled in (build flag); runtime
// activation is separate and operator-controlled via /skill-search start.
isEnabled: () => isSkillSearchCompiledIn(),
isHidden: false,
load: () => import('./skillSearchPanel.js'),
} satisfies Command

View File

@@ -1,7 +1,6 @@
import { extname } from 'path'
import React, { Suspense, use, useMemo } from 'react'
import { Ansi, Text } from '@anthropic/ink'
import { LRUCache } from 'lru-cache'
import { getCliHighlightPromise } from '../../utils/cliHighlight.js'
import { logForDebugging } from '../../utils/debug.js'
import { convertLeadingTabsToSpaces } from '../../utils/file.js'
@@ -17,7 +16,8 @@ type Props = {
// Module-level highlight cache — hl.highlight() is the hot cost on virtual-
// scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash
// of code+language to avoid retaining full source strings (#24180 RSS fix).
const hlCache = new LRUCache<string, string>({ max: 500 })
const HL_CACHE_MAX = 500
const hlCache = new Map<string, string>()
function cachedHighlight(
hl: NonNullable<Awaited<ReturnType<typeof getCliHighlightPromise>>>,
code: string,
@@ -25,8 +25,16 @@ function cachedHighlight(
): string {
const key = hashPair(language, code)
const hit = hlCache.get(key)
if (hit !== undefined) return hit
if (hit !== undefined) {
hlCache.delete(key)
hlCache.set(key, hit)
return hit
}
const out = hl.highlight(code, { language })
if (hlCache.size >= HL_CACHE_MAX) {
const first = hlCache.keys().next().value
if (first !== undefined) hlCache.delete(first)
}
hlCache.set(key, out)
return out
}

View File

@@ -1,6 +1,5 @@
import { marked, type Token, type Tokens } from 'marked'
import React, { Suspense, use, useMemo, useRef } from 'react'
import { LRUCache } from 'lru-cache'
import { useSettings } from '../hooks/useSettings.js'
import { Ansi, Box, useTheme } from '@anthropic/ink'
import {
@@ -23,7 +22,8 @@ type Props = {
// scrolling back to a previously-visible message re-parses. Messages are
// immutable in history; same content → same tokens. Keyed by hash to avoid
// retaining full content strings (turn50→turn99 RSS regression, #24180).
const tokenCache = new LRUCache<string, Token[]>({ max: 500 })
const TOKEN_CACHE_MAX = 500
const tokenCache = new Map<string, Token[]>()
// Characters that indicate markdown syntax. If none are present, skip the
// ~3ms marked.lexer call entirely — render as a single paragraph. Covers
@@ -55,8 +55,19 @@ function cachedLexer(content: string): Token[] {
}
const key = hashContent(content)
const hit = tokenCache.get(key)
if (hit) return hit
if (hit) {
// Promote to MRU — without this the eviction is FIFO (scrolling back to
// an early message evicts the very item you're looking at).
tokenCache.delete(key)
tokenCache.set(key, hit)
return hit
}
const tokens = marked.lexer(content)
if (tokenCache.size >= TOKEN_CACHE_MAX) {
// LRU-ish: drop oldest. Map preserves insertion order.
const first = tokenCache.keys().next().value
if (first !== undefined) tokenCache.delete(first)
}
tokenCache.set(key, tokens)
return tokens
}

View File

@@ -279,7 +279,6 @@ export function ModelPicker({
<Text color="subtle">
<EffortLevelIndicator effort={undefined} /> 1M context off
{focusedModelName ? ` for ${focusedModelName}` : ''}
<Text color="subtle"> · Space to toggle</Text>
</Text>
)}
</Box>

View File

@@ -30,7 +30,6 @@ interface WorkerState {
failureCount: number
parked: boolean
lastStartTime: number
restartTimer: ReturnType<typeof setTimeout> | null
}
/**
@@ -242,7 +241,6 @@ async function runSupervisor(args: string[]): Promise<void> {
failureCount: 0,
parked: false,
lastStartTime: 0,
restartTimer: null,
},
]
@@ -263,10 +261,6 @@ async function runSupervisor(args: string[]): Promise<void> {
controller.abort()
removeDaemonState()
for (const w of workers) {
if (w.restartTimer) {
clearTimeout(w.restartTimer)
w.restartTimer = null
}
if (w.process && !w.process.killed) {
w.process.kill('SIGTERM')
}
@@ -294,30 +288,22 @@ async function runSupervisor(args: string[]): Promise<void> {
// Wait for all workers to exit
await Promise.all(
workers
.filter(w => w.process && w.process.exitCode === null)
.filter(w => w.process && !w.process.killed)
.map(
w =>
new Promise<void>(resolve => {
if (!w.process || w.process.exitCode !== null) {
if (!w.process) {
resolve()
return
}
let killTimer: ReturnType<typeof setTimeout> | null = null
w.process.on('exit', () => {
if (killTimer) {
clearTimeout(killTimer)
killTimer = null
}
resolve()
})
w.process.on('exit', () => resolve())
// Force kill after grace period
killTimer = setTimeout(() => {
if (w.process && w.process.exitCode === null) {
setTimeout(() => {
if (w.process && !w.process.killed) {
w.process.kill('SIGKILL')
}
resolve()
}, 30_000)
killTimer.unref?.()
}),
),
)
@@ -412,13 +398,11 @@ function spawnWorker(
`[daemon] worker '${worker.kind}' exited (code=${code}, signal=${sig}), restarting in ${worker.backoffMs}ms`,
)
worker.restartTimer = setTimeout(() => {
worker.restartTimer = null
setTimeout(() => {
if (!signal.aborted && !worker.parked) {
spawnWorker(worker, dir, config, signal)
}
}, worker.backoffMs)
worker.restartTimer.unref?.()
// Exponential backoff
worker.backoffMs = Math.min(

View File

@@ -255,29 +255,6 @@ async function main(): Promise<void> {
return
}
// Fast-path for `claude autonomy ...`: state inspection/management commands
// do not need the full interactive CLI bootstrap. The full Commander path
// imports main.tsx and runs root preAction initialization before the autonomy
// action; under coverage/CI that leaves unrelated handles around simple
// state-only subprocess calls.
if (args[0] === 'autonomy') {
profileCheckpoint('cli_autonomy_path')
const { getAutonomyCommandText } = await import(
'../cli/handlers/autonomy.js'
)
const text = await getAutonomyCommandText(args.slice(1).join(' '))
await new Promise<void>((resolve, reject) => {
process.stdout.write(`${text}\n`, error => {
if (error) {
reject(error)
return
}
resolve()
})
})
process.exit(0)
}
// Fast-path for `--bg`/`--background` shortcut → daemon bg.
if (
feature('BG_SESSIONS') &&
@@ -421,4 +398,4 @@ async function main(): Promise<void> {
}
// eslint-disable-next-line custom-rules/no-top-level-side-effects
await main()
void main()

View File

@@ -1,80 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
resetStateForTests,
setCwdState,
setOriginalCwd,
setProjectRoot,
} from '../../bootstrap/state'
import { createScheduledTaskQueuedCommand } from '../useScheduledTasks'
import {
listAutonomyRuns,
markAutonomyRunCompleted,
} from '../../utils/autonomyRuns'
import { resetAutonomyAuthorityForTests } from '../../utils/autonomyAuthority'
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
let tempDir = ''
beforeEach(async () => {
tempDir = await createTempDir('scheduled-tasks-')
resetStateForTests()
resetAutonomyAuthorityForTests()
setOriginalCwd(tempDir)
setProjectRoot(tempDir)
setCwdState(tempDir)
})
afterEach(async () => {
resetStateForTests()
resetAutonomyAuthorityForTests()
if (tempDir) {
await cleanupTempDir(tempDir)
}
})
describe('createScheduledTaskQueuedCommand', () => {
function createCommandForTest(task: { id: string; prompt: string }) {
return createScheduledTaskQueuedCommand(task, {
rootDir: tempDir,
currentDir: tempDir,
})
}
test('skips a scheduled task when the same source already has an active run', async () => {
const task = {
id: 'cron-1',
prompt: '/loop review the repository',
}
const first = await createCommandForTest(task)
const second = await createCommandForTest(task)
const runs = await listAutonomyRuns(tempDir)
expect(first).not.toBeNull()
expect(second).toBeNull()
expect(runs).toHaveLength(1)
expect(runs[0]).toMatchObject({
trigger: 'scheduled-task',
status: 'queued',
sourceId: 'cron-1',
})
})
test('allows a scheduled task after the previous same-source run completes', async () => {
const task = {
id: 'cron-1',
prompt: '/loop review the repository',
}
const first = await createCommandForTest(task)
expect(first?.autonomy?.runId).toBeDefined()
await markAutonomyRunCompleted(first!.autonomy!.runId, tempDir, 100)
const second = await createCommandForTest(task)
const runs = await listAutonomyRuns(tempDir)
expect(second).not.toBeNull()
expect(runs).toHaveLength(2)
expect(runs.map(run => run.status).sort()).toEqual(['completed', 'queued'])
})
})

View File

@@ -189,6 +189,12 @@ export function useReplBridge(
}
let cancelled = false
// Map of pending bridge permission response handlers, keyed by request_id.
// Defined at useEffect scope so the cleanup function can clear it on unmount.
const pendingPermissionHandlers = new Map<
string,
(response: BridgePermissionResponse) => void
>()
// Capture messages.length now so we don't re-send initial messages
// through writeMessages after the bridge connects.
const initialMessageCount = messages.length
@@ -461,13 +467,6 @@ export function useReplBridge(
}
}
// Map of pending bridge permission response handlers, keyed by request_id.
// Each entry is an onResponse handler waiting for CCR to reply.
const pendingPermissionHandlers = new Map<
string,
(response: BridgePermissionResponse) => void
>()
// Dispatch incoming control_response messages to registered handlers
function handlePermissionResponse(msg: SDKControlResponse): void {
const requestId = msg.response?.request_id
@@ -818,6 +817,10 @@ export function useReplBridge(
return () => {
cancelled = true
// Release all pending permission handlers so their closures (which
// may capture React state/setters) can be GC'd immediately rather
// than waiting for the entire useEffect closure to become unreachable.
pendingPermissionHandlers.clear()
clearTimeout(failureTimeoutRef.current)
failureTimeoutRef.current = undefined
if (handleRef.current) {

View File

@@ -10,18 +10,13 @@ import type { Message } from '../types/message.js'
import { getCwd } from '../utils/cwd.js'
import { getCronJitterConfig } from '../utils/cronJitterConfig.js'
import { createCronScheduler } from '../utils/cronScheduler.js'
import { removeCronTasks, type CronTask } from '../utils/cronTasks.js'
import {
createAutonomyQueuedPrompt,
createAutonomyQueuedPromptIfNoActiveSource,
markAutonomyRunCancelled,
markAutonomyRunFailed,
} from '../utils/autonomyRuns.js'
import { removeCronTasks } from '../utils/cronTasks.js'
import { createAutonomyQueuedPrompt } from '../utils/autonomyRuns.js'
import { markAutonomyRunFailed } from '../utils/autonomyRuns.js'
import { logForDebugging } from '../utils/debug.js'
import { enqueuePendingNotification } from '../utils/messageQueueManager.js'
import { createScheduledTaskFireMessage } from '../utils/messages.js'
import { WORKLOAD_CRON } from '../utils/workloadContext.js'
import type { QueuedCommand } from '../types/textInputTypes.js'
type Props = {
isLoading: boolean
@@ -37,32 +32,6 @@ type Props = {
setMessages: React.Dispatch<React.SetStateAction<Message[]>>
}
export async function createScheduledTaskQueuedCommand(
task: Pick<CronTask, 'id' | 'prompt'>,
options?: {
rootDir?: string
currentDir?: string
shouldCreate?: () => boolean
},
): Promise<QueuedCommand | null> {
const command = await createAutonomyQueuedPromptIfNoActiveSource({
basePrompt: task.prompt,
trigger: 'scheduled-task',
rootDir: options?.rootDir,
currentDir: options?.currentDir ?? getCwd(),
sourceId: task.id,
sourceLabel: task.prompt,
workload: WORKLOAD_CRON,
shouldCreate: options?.shouldCreate,
})
if (!command) {
logForDebugging(
`[ScheduledTasks] skipping ${task.id}: previous run still queued or running`,
)
}
return command
}
/**
* REPL wrapper for the cron scheduler. Mounts the scheduler once and tears
* it down on unmount. Fired prompts go into the command queue as 'later'
@@ -102,25 +71,16 @@ export function useScheduledTasks({
// forward isMeta, so their messages remain visible in the
// transcript. This is acceptable since normal mode is not the
// primary use case for scheduled tasks.
let disposed = false
const enqueueForLead = async (prompt: string) => {
const command = await createAutonomyQueuedPrompt({
basePrompt: prompt,
trigger: 'scheduled-task',
currentDir: getCwd(),
workload: WORKLOAD_CRON,
shouldCreate: () => !disposed,
})
if (!command) {
return
}
if (disposed) {
await markAutonomyRunCancelled(
command.autonomy!.runId,
command.autonomy!.rootDir,
)
return
}
enqueuePendingNotification(command)
}
@@ -130,12 +90,7 @@ export function useScheduledTasks({
// which is populated from disk at scheduler startup — this path only
// handles team-lead durable crons.
onFire: prompt => {
void enqueueForLead(prompt).catch(error =>
logForDebugging(
`[ScheduledTasks] failed to enqueue missed task prompt: ${error}`,
{ level: 'error' },
),
)
void enqueueForLead(prompt)
},
// Normal fires receive the full CronTask so we can route by agentId.
onFireTask: task => {
@@ -146,26 +101,22 @@ export function useScheduledTasks({
store.getState().tasks,
)
if (teammate && !isTerminalTaskStatus(teammate.status)) {
const command = await createScheduledTaskQueuedCommand(
task,
{ shouldCreate: () => !disposed },
)
const command = await createAutonomyQueuedPrompt({
basePrompt: task.prompt,
trigger: 'scheduled-task',
currentDir: getCwd(),
sourceId: task.id,
sourceLabel: task.prompt,
workload: WORKLOAD_CRON,
})
if (!command) {
return
}
if (disposed) {
await markAutonomyRunCancelled(
command.autonomy!.runId,
command.autonomy!.rootDir,
)
return
}
const injected = injectUserMessageToTeammate(
teammate.id,
command.value as string,
{
autonomyRunId: command.autonomy?.runId,
autonomyRootDir: command.autonomy?.rootDir,
origin: command.origin,
},
setAppState,
@@ -174,7 +125,6 @@ export function useScheduledTasks({
await markAutonomyRunFailed(
command.autonomy.runId,
`Teammate ${task.agentId} exited before the scheduled message could be delivered.`,
command.autonomy.rootDir,
)
}
return
@@ -189,32 +139,24 @@ export function useScheduledTasks({
return
}
const command = await createScheduledTaskQueuedCommand(
task,
{ shouldCreate: () => !disposed },
)
const command = await createAutonomyQueuedPrompt({
basePrompt: task.prompt,
trigger: 'scheduled-task',
currentDir: getCwd(),
sourceId: task.id,
sourceLabel: task.prompt,
workload: WORKLOAD_CRON,
})
if (!command) {
return
}
if (disposed) {
await markAutonomyRunCancelled(
command.autonomy!.runId,
command.autonomy!.rootDir,
)
return
}
const msg = createScheduledTaskFireMessage(
`Running scheduled task (${formatCronFireTime(new Date())})`,
)
setMessages(prev => [...prev, msg])
enqueuePendingNotification(command)
})().catch(error =>
logForDebugging(
`[ScheduledTasks] failed to enqueue task ${task.id}: ${error}`,
{ level: 'error' },
),
)
})()
},
isLoading: () => isLoadingRef.current,
assistantMode,
@@ -222,10 +164,7 @@ export function useScheduledTasks({
isKilled: () => !isKairosCronEnabled(),
})
scheduler.start()
return () => {
disposed = true
scheduler.stop()
}
return () => scheduler.stop()
// assistantMode is stable for the session lifetime; store/setAppState are
// stable refs from useSyncExternalStore; setMessages is a stable useCallback.
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@@ -9,9 +9,7 @@ import { useEffect, useRef } from 'react'
import type { QueuedCommand } from '../types/textInputTypes.js'
import { TICK_TAG } from '../constants/xml.js'
import { getCwd } from '../utils/cwd.js'
import { cancelQueuedAutonomyCommands } from '../utils/autonomyQueueLifecycle.js'
import { createProactiveAutonomyCommands } from '../utils/autonomyRuns.js'
import { logForDebugging } from '../utils/debug.js'
import {
isProactiveActive,
isProactivePaused,
@@ -40,8 +38,6 @@ export function useProactive(opts: UseProactiveOpts): void {
if (!isProactiveActive()) return
let timer: ReturnType<typeof setTimeout> | null = null
let disposed = false
let generating = false
function scheduleTick(): void {
const nextTs = Date.now() + TICK_INTERVAL_MS
@@ -70,51 +66,25 @@ export function useProactive(opts: UseProactiveOpts): void {
isLoading ||
isInPlanMode ||
hasActiveLocalJsxUI ||
queuedCommandsLength > 0 ||
generating
queuedCommandsLength > 0
) {
scheduleTick()
return
}
generating = true
void (async () => {
const commands = await createProactiveAutonomyCommands({
basePrompt: `<${TICK_TAG}>${new Date().toLocaleTimeString()}</${TICK_TAG}>`,
currentDir: getCwd(),
shouldCreate: () => !disposed,
})
if (disposed) {
await cancelQueuedAutonomyCommands({ commands })
return
}
const queuedCommands: QueuedCommand[] = []
try {
for (const command of commands) {
// Always queue proactive turns. This avoids races where the prompt
// is built asynchronously, a user turn starts meanwhile, and a
// direct-submit path would silently drop the autonomy turn after
// consuming its heartbeat due-state.
optsRef.current.onQueueTick(command)
queuedCommands.push(command)
}
} catch (error) {
await cancelQueuedAutonomyCommands({
commands: commands.filter(
command => !queuedCommands.includes(command),
),
})
throw error
for (const command of commands) {
// Always queue proactive turns. This avoids races where the prompt
// is built asynchronously, a user turn starts meanwhile, and a
// direct-submit path would silently drop the autonomy turn after
// consuming its heartbeat due-state.
optsRef.current.onQueueTick(command)
}
})()
.catch(error =>
logForDebugging(`[Proactive] failed to create tick: ${error}`, {
level: 'error',
}),
)
.finally(() => {
generating = false
})
// Schedule next tick
scheduleTick()
@@ -124,7 +94,6 @@ export function useProactive(opts: UseProactiveOpts): void {
scheduleTick()
return () => {
disposed = true
if (timer !== null) {
clearTimeout(timer)
timer = null

View File

@@ -71,16 +71,10 @@ const jobClassifier = feature('TEMPLATES')
: null
/* eslint-enable @typescript-eslint/no-require-imports */
import {
enqueue,
remove as removeFromQueue,
getCommandsByMaxPriority,
isSlashCommand,
} from './utils/messageQueueManager.js'
import {
type AutonomyTurnOutcome,
claimConsumableQueuedAutonomyCommands,
finalizeAutonomyCommandsForTurn,
} from './utils/autonomyQueueLifecycle.js'
import { notifyCommandLifecycle } from './utils/commandLifecycle.js'
import { headlessProfilerCheckpoint } from './utils/headlessProfiler.js'
import {
@@ -98,7 +92,6 @@ import { SLEEP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SleepTool
import { executePostSamplingHooks } from './utils/hooks/postSamplingHooks.js'
import { executeStopFailureHooks } from './utils/hooks.js'
import type { QuerySource } from './constants/querySource.js'
import type { QueuedCommand } from './types/textInputTypes.js'
import { createDumpPromptsFetch } from './services/api/dumpPrompts.js'
import { StreamingToolExecutor } from './services/tools/StreamingToolExecutor.js'
import { queryCheckpoint } from './utils/queryProfiler.js'
@@ -118,11 +111,7 @@ import {
} from './bootstrap/state.js'
import { createBudgetTracker, checkTokenBudget } from './query/tokenBudget.js'
import { count } from './utils/array.js'
import {
createTrace,
endTrace,
isLangfuseEnabled,
} from './services/langfuse/index.js'
import { createTrace, endTrace, isLangfuseEnabled } from './services/langfuse/index.js'
import { getAPIProvider } from './utils/model/providers.js'
/* eslint-disable @typescript-eslint/no-require-imports */
@@ -140,11 +129,7 @@ function* yieldMissingToolResultBlocks(
) {
for (const assistantMessage of assistantMessages) {
// Extract all tool use blocks from this assistant message
const toolUseBlocks = (
Array.isArray(assistantMessage.message?.content)
? assistantMessage.message.content
: []
).filter(
const toolUseBlocks = (Array.isArray(assistantMessage.message?.content) ? assistantMessage.message.content : []).filter(
(content: { type: string }) => content.type === 'tool_use',
) as ToolUseBlock[]
@@ -196,33 +181,6 @@ function isWithheldMaxOutputTokens(
return msg?.type === 'assistant' && msg.apiError === 'max_output_tokens'
}
function getAutonomyTurnOutcome(params: {
terminal?: Terminal
thrownError?: unknown
}): AutonomyTurnOutcome {
if (params.thrownError !== undefined) {
return { type: 'failed', error: params.thrownError }
}
const terminal = params.terminal
const reason = terminal?.reason
switch (reason) {
case 'completed':
return { type: 'completed' }
case undefined:
case 'aborted_streaming':
case 'aborted_tools':
return { type: 'cancelled' }
case 'model_error':
return { type: 'failed', error: terminal.error }
default:
return {
type: 'failed',
message: `query ended without successful completion: ${reason}`,
}
}
}
export type QueryParams = {
messages: Message[]
systemPrompt: SystemPrompt
@@ -272,7 +230,6 @@ export async function* query(
Terminal
> {
const consumedCommandUuids: string[] = []
const consumedAutonomyCommands: QueuedCommand[] = []
// Create Langfuse trace for this query turn (no-op if not configured).
// When called as a sub-agent, langfuseTrace is already set by runAgent()
@@ -281,9 +238,8 @@ export async function* query(
logForDebugging(
`[query] ownsTrace=${ownsTrace} incoming langfuseTrace=${params.toolUseContext.langfuseTrace ? 'present' : 'null/undefined'} isLangfuseEnabled=${isLangfuseEnabled()}`,
)
const langfuseTrace =
params.toolUseContext.langfuseTrace ??
(isLangfuseEnabled()
const langfuseTrace = params.toolUseContext.langfuseTrace
?? (isLangfuseEnabled()
? createTrace({
sessionId: getSessionId(),
model: params.toolUseContext.options.mainLoopModel,
@@ -302,34 +258,9 @@ export async function* query(
: params
let terminal: Terminal | undefined
let didThrow = false
let thrownError: unknown
try {
terminal = yield* queryLoop(
paramsWithTrace,
consumedCommandUuids,
consumedAutonomyCommands,
)
} catch (error) {
didThrow = true
thrownError = error
throw error
terminal = yield* queryLoop(paramsWithTrace, consumedCommandUuids)
} finally {
await finalizeAutonomyCommandsForTurn({
commands: consumedAutonomyCommands,
outcome: getAutonomyTurnOutcome({
terminal,
...(didThrow ? { thrownError } : {}),
}),
priority: 'later',
})
.then(nextCommands => {
for (const command of nextCommands) {
enqueue(command)
}
})
.catch(logError)
// Only end the trace if we created it — sub-agents own their traces
if (ownsTrace) {
const isAborted =
@@ -352,7 +283,6 @@ export async function* query(
async function* queryLoop(
params: QueryParams,
consumedCommandUuids: string[],
consumedAutonomyCommands: QueuedCommand[],
): AsyncGenerator<
| StreamEvent
| RequestStartEvent
@@ -860,14 +790,7 @@ async function* queryLoop(
let yieldMessage: typeof message = message
if (message.type === 'assistant') {
const assistantMsg = message as AssistantMessage
const contentArr = Array.isArray(assistantMsg.message?.content)
? (assistantMsg.message.content as unknown as Array<{
type: string
input?: unknown
name?: string
[key: string]: unknown
}>)
: []
const contentArr = Array.isArray(assistantMsg.message?.content) ? assistantMsg.message.content as unknown as Array<{ type: string; input?: unknown; name?: string; [key: string]: unknown }> : []
let clonedContent: typeof contentArr | undefined
for (let i = 0; i < contentArr.length; i++) {
const block = contentArr[i]!
@@ -903,10 +826,7 @@ async function* queryLoop(
if (clonedContent) {
yieldMessage = {
...message,
message: {
...(assistantMsg.message ?? {}),
content: clonedContent,
},
message: { ...(assistantMsg.message ?? {}), content: clonedContent },
} as typeof message
}
}
@@ -952,11 +872,7 @@ async function* queryLoop(
const assistantMessage = message as AssistantMessage
assistantMessages.push(assistantMessage)
const msgToolUseBlocks = (
Array.isArray(assistantMessage.message?.content)
? assistantMessage.message.content
: []
).filter(
const msgToolUseBlocks = (Array.isArray(assistantMessage.message?.content) ? assistantMessage.message.content : []).filter(
(content: { type: string }) => content.type === 'tool_use',
) as ToolUseBlock[]
if (msgToolUseBlocks.length > 0) {
@@ -1089,10 +1005,7 @@ async function* queryLoop(
logEvent('tengu_query_error', {
assistantMessages: assistantMessages.length,
toolUses: assistantMessages.flatMap(_ =>
(Array.isArray(_.message?.content)
? (_.message.content as Array<{ type: string }>)
: []
).filter(content => content.type === 'tool_use'),
(Array.isArray(_.message?.content) ? _.message.content as Array<{ type: string }> : []).filter(content => content.type === 'tool_use'),
).length,
queryChainId: queryChainIdForAnalytics,
@@ -1394,10 +1307,7 @@ async function* queryLoop(
// error → hook blocking → retry → error → …
if (lastMessage?.isApiErrorMessage) {
void executeStopFailureHooks(lastMessage, toolUseContext)
return {
reason: 'model_error',
error: lastMessage.error ?? lastMessage.apiError ?? 'api_error',
}
return { reason: 'completed' }
}
const stopHookResult = yield* handleStopHooks(
@@ -1498,6 +1408,7 @@ async function* queryLoop(
queryCheckpoint('query_tool_execution_start')
if (streamingToolExecutor) {
logEvent('tengu_streaming_tool_execution_used', {
tool_count: toolUseBlocks.length,
@@ -1557,14 +1468,9 @@ async function* queryLoop(
const lastAssistantMessage = assistantMessages.at(-1)
let lastAssistantText: string | undefined
if (lastAssistantMessage) {
const textBlocks = (
Array.isArray(lastAssistantMessage.message?.content)
? (lastAssistantMessage.message.content as Array<{
type: string
text?: string
}>)
: []
).filter(block => block.type === 'text')
const textBlocks = (Array.isArray(lastAssistantMessage.message?.content) ? lastAssistantMessage.message.content as Array<{ type: string; text?: string }> : []).filter(
block => block.type === 'text',
)
if (textBlocks.length > 0) {
const lastTextBlock = textBlocks.at(-1)
if (lastTextBlock && 'text' in lastTextBlock) {
@@ -1716,32 +1622,12 @@ async function* queryLoop(
// user prompts, even if someone stamps an agentId on one.
return cmd.mode === 'task-notification' && cmd.agentId === currentAgentId
})
const queuedAutonomyClaim = await claimConsumableQueuedAutonomyCommands(
queuedCommandsSnapshot,
)
if (queuedAutonomyClaim.staleCommands.length > 0) {
removeFromQueue(queuedAutonomyClaim.staleCommands)
}
const claimedConsumedCommands = queuedAutonomyClaim.claimedCommands.filter(
cmd => cmd.mode === 'prompt' || cmd.mode === 'task-notification',
)
if (claimedConsumedCommands.length > 0) {
consumedAutonomyCommands.push(...claimedConsumedCommands)
for (const cmd of claimedConsumedCommands) {
if (cmd.uuid) {
consumedCommandUuids.push(cmd.uuid)
notifyCommandLifecycle(cmd.uuid, 'started')
}
}
removeFromQueue(claimedConsumedCommands)
}
for await (const attachment of getAttachmentMessages(
null,
updatedToolUseContext,
null,
queuedAutonomyClaim.attachmentCommands,
queuedCommandsSnapshot,
[...messagesForQuery, ...assistantMessages, ...toolResults],
querySource,
)) {
@@ -1773,6 +1659,7 @@ async function* queryLoop(
pendingMemoryPrefetch.consumedOnIteration = turnCount - 1
}
// Inject prefetched skill discovery. collectSkillDiscoveryPrefetch emits
// hidden_by_main_turn — true when the prefetch resolved before this point
// (should be >98% at AKI@250ms / Haiku@573ms vs turn durations of 2-30s).
@@ -1788,11 +1675,8 @@ async function* queryLoop(
// Remove only commands that were actually consumed as attachments.
// Prompt and task-notification commands are converted to attachments above.
const claimedCommandSet = new Set(claimedConsumedCommands)
const consumedCommands = queuedAutonomyClaim.attachmentCommands.filter(
cmd =>
(cmd.mode === 'prompt' || cmd.mode === 'task-notification') &&
!claimedCommandSet.has(cmd),
const consumedCommands = queuedCommandsSnapshot.filter(
cmd => cmd.mode === 'prompt' || cmd.mode === 'task-notification',
)
if (consumedCommands.length > 0) {
for (const cmd of consumedCommands) {

View File

@@ -1,20 +1,3 @@
export type Terminal =
| { reason: 'completed' }
| { reason: 'blocking_limit' }
| { reason: 'image_error' }
| { reason: 'model_error'; error?: unknown }
| { reason: 'aborted_streaming' }
| { reason: 'aborted_tools' }
| { reason: 'prompt_too_long' }
| { reason: 'stop_hook_prevented' }
| { reason: 'hook_stopped' }
| { reason: 'max_turns'; turnCount: number }
export type Continue =
| { reason: 'collapse_drain_retry'; committed: number }
| { reason: 'reactive_compact_retry' }
| { reason: 'max_output_tokens_escalate' }
| { reason: 'max_output_tokens_recovery'; attempt: number }
| { reason: 'stop_hook_blocking' }
| { reason: 'token_budget_continuation' }
| { reason: 'next_turn' }
// Auto-generated stub — replace with real implementation
export type Terminal = any;
export type Continue = any;

View File

@@ -79,9 +79,10 @@ import { isEnvTruthy } from '../utils/envUtils.js';
import { formatTokens, truncateToWidth } from '../utils/format.js';
import { consumeEarlyInput } from '../utils/earlyInput.js';
import {
claimConsumableQueuedAutonomyCommands,
finalizeAutonomyCommandsForTurn,
} from '../utils/autonomyQueueLifecycle.js';
finalizeAutonomyRunCompleted,
finalizeAutonomyRunFailed,
markAutonomyRunRunning,
} from '../utils/autonomyRuns.js';
import { setMemberActive } from '../utils/swarm/teamHelpers.js';
import {
@@ -3053,19 +3054,18 @@ export function REPL({
setMessages(old => {
const postBoundary = getMessagesAfterCompactBoundary(old, {
includeSnipped: true,
});
})
// Hard cap: keep at most 500 messages in fullscreen scrollback
// to prevent unbounded memory growth in multi-day sessions.
// normalizeMessages/applyGrouping are O(n), and Ink fiber
// trees cost ~250KB RSS per message. Without this cap,
// scrollback after several compactions can reach thousands
// of messages (observed: 13k+, 1GB+ heap).
const MAX_FULLSCREEN_SCROLLBACK = 500;
const kept =
postBoundary.length > MAX_FULLSCREEN_SCROLLBACK
? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK)
: postBoundary;
return [...kept, newMessage];
const MAX_FULLSCREEN_SCROLLBACK = 500
const kept = postBoundary.length > MAX_FULLSCREEN_SCROLLBACK
? postBoundary.slice(-MAX_FULLSCREEN_SCROLLBACK)
: postBoundary
return [...kept, newMessage]
});
} else {
setMessages(() => [newMessage]);
@@ -3098,10 +3098,13 @@ export function REPL({
// so interleaved non-ephemeral messages caused duplicate progress
// entries to accumulate (observed 13k+ entries in sleep-heavy sessions).
for (let i = oldMessages.length - 1; i >= 0; i--) {
const m = oldMessages[i]!;
if (m.type !== 'progress') break;
const mData = m.data as Record<string, unknown> | undefined;
if (m.parentToolUseID === newMessage.parentToolUseID && mData?.type === newData.type) {
const m = oldMessages[i]!
if (m.type !== 'progress') break
const mData = m.data as Record<string, unknown> | undefined
if (
m.parentToolUseID === newMessage.parentToolUseID &&
mData?.type === newData.type
) {
const copy = oldMessages.slice();
copy[i] = newMessage;
return copy;
@@ -3474,7 +3477,7 @@ export function REPL({
onBeforeQueryCallback?: (input: string, newMessages: MessageType[]) => Promise<boolean>,
input?: string,
effort?: EffortValue,
): Promise<boolean> => {
): Promise<void> => {
// If this is a teammate, mark them as active when starting a turn
if (isAgentSwarmsEnabled()) {
const teamName = getTeamName();
@@ -3505,7 +3508,7 @@ export function REPL({
logEvent('tengu_concurrent_onquery_enqueued', {});
}
});
return false;
return;
}
try {
@@ -3538,7 +3541,7 @@ export function REPL({
if (onBeforeQueryCallback && input) {
const shouldProceed = await onBeforeQueryCallback(input, latestMessages);
if (!shouldProceed) {
return true;
return;
}
}
@@ -3687,7 +3690,6 @@ export function REPL({
}
}
}
return true;
},
[onQueryImpl, setAppState, resetLoadingState, queryGuard, mrOnBeforeQuery, mrOnTurnComplete],
);
@@ -4842,62 +4844,44 @@ export function REPL({
} satisfies QueuedCommand)
: input;
void (async () => {
const claim = await claimConsumableQueuedAutonomyCommands([queuedCommand]);
const command = claim.attachmentCommands[0];
if (!command) return;
const newAbortController = createAbortController();
setAbortController(newAbortController);
const newAbortController = createAbortController();
setAbortController(newAbortController);
// Create a user message with the formatted content (includes XML wrapper)
const userMessage = createUserMessage({
content: queuedCommand.value as string,
isMeta: queuedCommand.isMeta ? true : undefined,
origin: queuedCommand.origin,
});
// Create a user message with the formatted content (includes XML wrapper)
const userMessage = createUserMessage({
content: command.value,
isMeta: command.isMeta ? true : undefined,
origin: command.origin,
});
const autonomyRunId = queuedCommand.autonomy?.runId;
if (autonomyRunId) {
void markAutonomyRunRunning(autonomyRunId);
}
let executed = false;
try {
executed = (await onQuery([userMessage], newAbortController, true, [], mainLoopModel)) !== false;
} catch (error: unknown) {
try {
await finalizeAutonomyCommandsForTurn({
commands: claim.claimedCommands,
outcome: { type: 'failed', error },
void onQuery([userMessage], newAbortController, true, [], mainLoopModel)
.then(() => {
if (autonomyRunId) {
void finalizeAutonomyRunCompleted({
runId: autonomyRunId,
currentDir: getCwd(),
priority: 'later',
}).then(nextCommands => {
for (const command of nextCommands) {
enqueue(command);
}
});
}
})
.catch((error: unknown) => {
if (autonomyRunId) {
void finalizeAutonomyRunFailed({
runId: autonomyRunId,
error: String(error),
});
} catch (finalizeError: unknown) {
logError(toError(finalizeError));
}
logError(toError(error));
return;
}
// Only finalize as completed when onQuery actually executed the turn
// (it returns false from the concurrent-guard path without running).
// Keep this finalize in its own try/catch so a failure here does not
// trigger a second finalize as `failed` for the same commands.
if (!executed) {
return;
}
try {
const nextCommands = await finalizeAutonomyCommandsForTurn({
commands: claim.claimedCommands,
outcome: { type: 'completed' },
currentDir: getCwd(),
priority: 'later',
});
for (const nextCommand of nextCommands) {
enqueue(nextCommand);
}
} catch (finalizeError: unknown) {
logError(toError(finalizeError));
}
})().catch((error: unknown) => {
logError(toError(error));
});
});
return true;
},
[onQuery, mainLoopModel, store],

View File

@@ -5,9 +5,9 @@ import { getUserContext } from '../../context.js'
import { clearSpeculativeChecks } from '@claude-code-best/builtin-tools/tools/BashTool/bashPermissions.js'
import { clearClassifierApprovals } from '../../utils/classifierApprovals.js'
import { resetGetMemoryFilesCache } from '../../utils/claudemd.js'
import { logError } from '../../utils/log.js'
import { clearSessionMessagesCache } from '../../utils/sessionStorage.js'
import { clearBetaTracingState } from '../../utils/telemetry/betaSessionTracing.js'
import { getLspServerManager } from '../../services/lsp/manager.js'
import { resetMicrocompactState } from './microCompact.js'
/**
@@ -29,7 +29,7 @@ import { resetMicrocompactState } from './microCompact.js'
* pass querySource — undefined is only safe for callers that are
* genuinely main-thread-only (/compact, /clear).
*/
export function runPostCompactCleanup(querySource?: QuerySource): void {
export async function runPostCompactCleanup(querySource?: QuerySource): Promise<void> {
// Subagents (agent:*) run in the same process and share module-level
// state with the main thread. Only reset main-thread module-level state
// (context-collapse, memory file cache) for main-thread compacts.
@@ -70,22 +70,20 @@ export function runPostCompactCleanup(querySource?: QuerySource): void {
// cacheUtils resets. See compactConversation() for full rationale.
clearBetaTracingState()
if (feature('COMMIT_ATTRIBUTION')) {
// Intentionally fire-and-forget: the file-content cache sweep is a
// best-effort memory release whose completion no caller depends on.
// Keeping `runPostCompactCleanup` synchronous lets compaction call sites
// (REPL post-compact handler, /compact command, autoCompact) finish their
// own state transitions without an extra microtask round-trip — the sweep
// catches up on the next event-loop tick.
//
// The .catch is required even though the current attributionHooks.ts is a
// no-op stub: without it, a future restored sweepFileContentCache that
// throws would surface as an unhandled promise rejection from a function
// whose synchronous signature gives callers no way to observe it.
void import('../../utils/attributionHooks.js')
.then(m => m.sweepFileContentCache())
.catch(error => {
logError(error)
})
void import('../../utils/attributionHooks.js').then(m =>
m.sweepFileContentCache(),
)
}
clearSessionMessagesCache()
// Close all LSP-tracked files so servers release state for files no longer
// in the active context after compaction. Best-effort — LSP may not be
// initialized, and closeAllFiles catches per-file errors internally.
try {
const lspManager = getLspServerManager()
if (lspManager) {
await lspManager.closeAllFiles()
}
} catch {
// LSP module may not be available in all environments
}
}

View File

@@ -1,36 +1,12 @@
import { feature } from 'bun:bundle'
/**
* Build-time presence check: is the `/skill-learning` slash command
* compiled into this build? Used by the command registry's `isEnabled` so
* the command appears in the menu whenever it is buildable. Operators
* activate the subsystem itself via `/skill-learning start`, which flips
* `SKILL_LEARNING_ENABLED=1` and turns the runtime observers on (see
* `isSkillLearningEnabled`).
*/
export function isSkillLearningCompiledIn(): boolean {
if (feature('SKILL_LEARNING')) return true
return false
}
/**
* Runtime activation check: is the skill-learning subsystem actively
* running (toolEvent, runtime, session observers attached, persisting
* observations to disk)? Off by default — the operator must run
* `/skill-learning start` (which sets `SKILL_LEARNING_ENABLED=1`).
*
* Legacy `FEATURE_SKILL_LEARNING=1` is also accepted for backward
* compatibility with operators who set it before the slash-command UX
* landed.
*
* Build-flag gating is intentionally NOT performed here: the command
* registry already gates command compilation on the build flag, and this
* function is only reached from code paths that the build flag has
* already let through. Decoupling keeps the test surface clean (tests
* exercise the env-var contract without needing to mock `bun:bundle`).
*/
export function isSkillLearningEnabled(): boolean {
if (process.env.SKILL_LEARNING_ENABLED === '0') return false
if (process.env.SKILL_LEARNING_ENABLED === '1') return true
if (process.env.FEATURE_SKILL_LEARNING === '0') return false
if (process.env.FEATURE_SKILL_LEARNING === '1') return true
if (feature('SKILL_LEARNING')) {
return true
}
return false
}

View File

@@ -45,44 +45,15 @@ export function getProjectContextPath(projectId: string): string {
// in the tool.call hot path (one wrapper invocation per tool) that cost would
// accumulate into the hundreds-of-ms range per session. Cache keyed by the
// exact cwd string so different worktrees still get independent entries.
//
// Bounded with LRU eviction: long-lived processes that traverse many
// worktrees (e.g. multi-repo build orchestrators) would otherwise grow the
// cache without limit. Each entry holds a SkillLearningProjectContext
// (instinct + skill lists), so the cap ensures bounded memory regardless
// of cwd diversity. `defines.ts` originally flagged this as
// "无淘汰机制(非 GB 级主因)" — this fix closes that gap.
const PROJECT_CONTEXT_CACHE_MAX = 32
const PROJECT_CONTEXT_CACHE_TRIM_TO = 24
const contextCache = new Map<string, SkillLearningProjectContext>()
const PERSIST_INTERVAL_MS = 5 * 60 * 1000
let lastPersistAt = 0
function setProjectContextCache(
cwd: string,
ctx: SkillLearningProjectContext,
): void {
if (contextCache.has(cwd)) contextCache.delete(cwd)
contextCache.set(cwd, ctx)
if (contextCache.size > PROJECT_CONTEXT_CACHE_MAX) {
const toDrop = contextCache.size - PROJECT_CONTEXT_CACHE_TRIM_TO
const iter = contextCache.keys()
for (let i = 0; i < toDrop; i++) {
const next = iter.next()
if (next.done) break
contextCache.delete(next.value)
}
}
}
export function resolveProjectContext(
cwd = process.cwd(),
): SkillLearningProjectContext {
const cached = contextCache.get(cwd)
if (cached) {
// Refresh insertion order so frequently-accessed cwds survive eviction.
contextCache.delete(cwd)
contextCache.set(cwd, cached)
// Still touch the registry so long-lived processes keep `lastSeenAt`
// reasonably fresh, but throttle the write so it doesn't fire on every
// tool call.
@@ -94,7 +65,7 @@ export function resolveProjectContext(
return cached
}
const resolved = resolveContext(cwd)
setProjectContextCache(cwd, resolved)
contextCache.set(cwd, resolved)
persistProjectContext(resolved)
lastPersistAt = Date.now()
return resolved

View File

@@ -23,30 +23,8 @@ export type PromotionOptions = {
minConfidence?: number
}
/**
* Set bounded with FIFO eviction. # promotions per session is small in
* practice (single digits), but a long-lived sandbox/daemon could push
* this if it never restarts. The cap is defensive and the degraded
* behaviour — re-promote if we exceed N then forget the oldest — is
* benign because promotion is idempotent at the lifecycle layer.
*/
const SESSION_PROMOTED_IDS_MAX = 256
const SESSION_PROMOTED_IDS_TRIM_TO = 192
const sessionPromotedIds = new Set<string>()
function recordSessionPromoted(id: string): void {
sessionPromotedIds.add(id)
if (sessionPromotedIds.size > SESSION_PROMOTED_IDS_MAX) {
const toDrop = sessionPromotedIds.size - SESSION_PROMOTED_IDS_TRIM_TO
const iter = sessionPromotedIds.values()
for (let i = 0; i < toDrop; i++) {
const next = iter.next()
if (next.done) break
sessionPromotedIds.delete(next.value)
}
}
}
export function resetPromotionBookkeeping(): void {
sessionPromotedIds.clear()
}
@@ -125,7 +103,7 @@ export async function checkPromotion(
}
await saveInstinct(globalInstinct, globalOptions)
recordSessionPromoted(candidate.instinctId)
sessionPromotedIds.add(candidate.instinctId)
promoted.push(candidate)
}

View File

@@ -1,30 +1,10 @@
import { feature } from 'bun:bundle'
/**
* Build-time presence check: is the `/skill-search` slash command compiled
* into this build? Used by the command registry's `isEnabled` so the
* command appears in the menu whenever it is buildable. Operators activate
* the subsystem itself via `/skill-search start`, which flips
* `SKILL_SEARCH_ENABLED=1` and turns the runtime hot paths on (see
* `isSkillSearchEnabled`).
*/
export function isSkillSearchCompiledIn(): boolean {
if (feature('EXPERIMENTAL_SKILL_SEARCH')) return true
export function isSkillSearchEnabled(): boolean {
if (process.env.SKILL_SEARCH_ENABLED === '0') return false
if (process.env.SKILL_SEARCH_ENABLED === '1') return true
if (feature('EXPERIMENTAL_SKILL_SEARCH')) {
return true
}
return false
}
/**
* Runtime activation check: is the skill-search subsystem currently doing
* work (intentNormalize Haiku calls, prefetch hot path, telemetry)? Off by
* default — the operator must run `/skill-search start` (which sets
* `SKILL_SEARCH_ENABLED=1`). See docs/agent/sur-skill-overflow-bugs.md §5.
*
* Build-flag gating is intentionally NOT performed here: the command
* registry already gates command compilation on the build flag, and this
* function is only reached from code paths that the build flag has
* already let through. Decoupling keeps the test surface clean (tests
* exercise the env-var contract without needing to mock `bun:bundle`).
*/
export function isSkillSearchEnabled(): boolean {
return process.env.SKILL_SEARCH_ENABLED === '1'
}

View File

@@ -47,35 +47,10 @@ Output ONLY keywords. Nothing else.`
const DEFAULT_TIMEOUT_MS = 6_000
const MAX_QUERY_CHARS = 500
const MAX_KEYWORDS_CHARS = 120
/**
* Bound on the process-level query→keywords cache. Insertion-order LRU —
* Map iteration order is insertion order, so we evict from the front when
* size exceeds the cap. ~200 entries × ~600 bytes (query + keywords) ≈
* 120 KB worst case. Without this cap the cache grew monotonically with
* the diversity of Chinese queries in a long session.
*/
const CACHE_MAX_ENTRIES = 200
const CACHE_TRIM_TO = 150
/** Process-level cache. Keyed by the original (trimmed) query. */
const cache = new Map<string, string>()
function setCachedQueryIntent(key: string, value: string): void {
// Refresh insertion order on hit-then-write so frequently-used keys
// stay alive (delete + set is the canonical Map-LRU idiom).
if (cache.has(key)) cache.delete(key)
cache.set(key, value)
if (cache.size > CACHE_MAX_ENTRIES) {
const toDrop = cache.size - CACHE_TRIM_TO
const iter = cache.keys()
for (let i = 0; i < toDrop; i++) {
const next = iter.next()
if (next.done) break
cache.delete(next.value)
}
}
}
export function isIntentNormalizeEnabled(): boolean {
return process.env.SKILL_SEARCH_INTENT_ENABLED === '1'
}
@@ -99,17 +74,12 @@ export async function normalizeQueryIntent(query: string): Promise<string> {
if (!/[\u4e00-\u9fff]/.test(trimmed)) return trimmed
const cached = cache.get(trimmed)
if (cached !== undefined) {
// Refresh LRU position so frequently-queried strings survive eviction.
cache.delete(trimmed)
cache.set(trimmed, cached)
return cached
}
if (cached !== undefined) return cached
const capped = trimmed.slice(0, MAX_QUERY_CHARS)
const keywords = await callHaiku(capped)
const result = keywords ? `${trimmed} ${keywords}` : trimmed
setCachedQueryIntent(trimmed, result)
cache.set(trimmed, result)
logForDebugging(
`[skill-search] intent normalized: "${trimmed.slice(0, 40)}" -> "${keywords}"`,
)

View File

@@ -14,35 +14,9 @@ import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
/**
* Per-session memoization to avoid re-emitting the same skill discovery /
* gap signal twice. Each Set is bounded to keep long-running sessions from
* monotonically accumulating skill names and signal keys forever (which
* was the original session-scoped-but-unbounded design).
*
* FIFO eviction by insertion order — once the cap is hit, the oldest
* entries roll off and may be re-recorded if rediscovered, which is the
* correct degraded behaviour: at worst we re-emit a duplicate signal,
* never silently drop a real one.
*/
const SESSION_TRACKING_MAX = 1000
const SESSION_TRACKING_TRIM_TO = 750
const discoveredThisSession = new Set<string>()
const recordedGapSignals = new Set<string>()
function addBoundedSessionEntry(set: Set<string>, value: string): void {
set.add(value)
if (set.size > SESSION_TRACKING_MAX) {
const toDrop = set.size - SESSION_TRACKING_TRIM_TO
const iter = set.values()
for (let i = 0; i < toDrop; i++) {
const next = iter.next()
if (next.done) break
set.delete(next.value)
}
}
}
const AUTO_LOAD_MIN_SCORE = Number(
process.env.SKILL_SEARCH_AUTOLOAD_MIN_SCORE ?? '0.30',
)
@@ -211,7 +185,7 @@ async function maybeRecordSkillGap(
const gapSignalKey = `${trigger}:${queryText.trim().toLowerCase()}`
if (recordedGapSignals.has(gapSignalKey)) return undefined
addBoundedSessionEntry(recordedGapSignals, gapSignalKey)
recordedGapSignals.add(gapSignalKey)
try {
const [{ isSkillLearningEnabled }, { recordSkillGap }] = await Promise.all([
@@ -267,7 +241,7 @@ export async function startSkillDiscoveryPrefetch(
const newResults = results.filter(r => !discoveredThisSession.has(r.name))
if (newResults.length === 0) return []
for (const r of newResults) addBoundedSessionEntry(discoveredThisSession, r.name)
for (const r of newResults) discoveredThisSession.add(r.name)
const signal: DiscoverySignal = {
trigger: 'assistant_turn',
@@ -331,7 +305,7 @@ export async function getTurnZeroSkillDiscovery(
if (results.length === 0 && !gap) return null
for (const r of results) addBoundedSessionEntry(discoveredThisSession, r.name)
for (const r of results) discoveredThisSession.add(r.name)
const signal: DiscoverySignal = {
trigger: 'user_input',

View File

@@ -73,7 +73,6 @@ export function injectUserMessageToTeammate(
options:
| {
autonomyRunId?: string;
autonomyRootDir?: string;
origin?: MessageOrigin;
}
| undefined,
@@ -94,9 +93,6 @@ export function injectUserMessageToTeammate(
if (options?.autonomyRunId !== undefined) {
pendingMessage.autonomyRunId = options.autonomyRunId;
}
if (options?.autonomyRootDir !== undefined) {
pendingMessage.autonomyRootDir = options.autonomyRootDir;
}
if (options?.origin !== undefined) {
pendingMessage.origin = options.origin;
}

View File

@@ -22,7 +22,6 @@ export type TeammateIdentity = {
export type PendingTeammateUserMessage = {
message: string
autonomyRunId?: string
autonomyRootDir?: string
origin?: MessageOrigin
}

View File

@@ -361,7 +361,6 @@ export type QueuedCommand = {
*/
autonomy?: {
runId: string
rootDir?: string
trigger: 'scheduled-task' | 'proactive-tick' | 'managed-flow-step'
sourceId?: string
sourceLabel?: string

View File

@@ -5,7 +5,6 @@ import {
AUTONOMY_DIR,
buildAutonomyTurnPrompt,
loadAutonomyAuthority,
parseHeartbeatAuthorityTasks,
resetAutonomyAuthorityForTests,
} from '../autonomyAuthority'
import {
@@ -239,79 +238,4 @@ describe('autonomyAuthority', () => {
expect(prompt).not.toContain('- weekly-report (7d): Ship the weekly report')
expect(prompt).not.toContain('- gather (')
})
test('parseHeartbeatAuthorityTasks ignores tasks: literals inside markdown code fences', () => {
const content = [
'# HEARTBEAT.md',
'',
'```yaml',
'tasks:',
' - name: not-a-real-task',
' interval: 1m',
' prompt: "would-be-shadowed"',
'```',
'',
'tasks:',
' - name: real-task',
' interval: 30m',
' prompt: "Real prompt"',
].join('\n')
const parsed = parseHeartbeatAuthorityTasks(content)
expect(parsed).toHaveLength(1)
expect(parsed[0]).toMatchObject({
name: 'real-task',
interval: '30m',
prompt: 'Real prompt',
})
})
test('parseHeartbeatAuthorityTasks ignores tasks: literals inside tilde markdown code fences', () => {
const content = [
'# HEARTBEAT.md',
'',
'~~~yaml',
'tasks:',
' - name: not-a-real-task',
' interval: 1m',
' prompt: "would-be-shadowed"',
'~~~',
'',
'tasks:',
' - name: real-task',
' interval: 30m',
' prompt: "Real prompt"',
].join('\n')
const parsed = parseHeartbeatAuthorityTasks(content)
expect(parsed).toHaveLength(1)
expect(parsed[0]).toMatchObject({
name: 'real-task',
interval: '30m',
prompt: 'Real prompt',
})
})
test('parseHeartbeatAuthorityTasks parses real tasks even when documentation precedes them', () => {
const content = [
'# Heartbeat docs',
'',
'See `tasks:` below — the parser keys on the literal at column 0.',
'',
'tasks:',
' - name: weekly',
' interval: 7d',
' prompt: "Ship report"',
].join('\n')
const parsed = parseHeartbeatAuthorityTasks(content)
// Inline `tasks:` mention does NOT collide because it's not at column 0
// on its own line — the existing line.trim() === 'tasks:' guard handles
// that case. This test pins the behaviour.
expect(parsed).toHaveLength(1)
expect(parsed[0]?.name).toBe('weekly')
})
})

View File

@@ -126,14 +126,6 @@ describe('listAutonomyFlows', () => {
runCount: 0,
ownerKey: DEFAULT_AUTONOMY_OWNER_KEY,
currentDir: tempDir,
boundary: [
' src/utils/** ',
'/absolute/not-allowed',
'src\\windows',
'../outside',
'src/utils/**',
'docs/*.md',
],
stateJson: {
currentStepIndex: 0,
steps: [
@@ -155,7 +147,6 @@ describe('listAutonomyFlows', () => {
expect(flows).toHaveLength(1)
expect(flows[0]?.flowId).toBe('flow-1')
expect(flows[0]?.syncMode).toBe('managed')
expect(flows[0]?.boundary).toEqual(['src/utils/**', 'docs/*.md'])
expect(flows[0]?.stateJson?.steps).toHaveLength(1)
})
@@ -200,64 +191,6 @@ describe('listAutonomyFlows', () => {
const flows = await listAutonomyFlows(tempDir)
expect(flows).toEqual([])
})
test('persistence pruning keeps active flows ahead of recent terminal history', async () => {
const flows: AutonomyFlowRecord[] = [
{
flowId: 'old-active',
flowKey: 'managed:scheduled-task:old-active',
syncMode: 'managed',
ownerKey: DEFAULT_AUTONOMY_OWNER_KEY,
revision: 1,
trigger: 'scheduled-task',
status: 'queued',
goal: 'old active',
rootDir: tempDir,
currentDir: tempDir,
runCount: 0,
createdAt: 1,
updatedAt: 1,
},
...Array.from({ length: 100 }, (_, index) => ({
flowId: `history-${index}`,
flowKey: `managed:scheduled-task:history-${index}`,
syncMode: 'managed' as const,
ownerKey: DEFAULT_AUTONOMY_OWNER_KEY,
revision: 1,
trigger: 'scheduled-task' as const,
status: 'succeeded' as const,
goal: `history ${index}`,
rootDir: tempDir,
currentDir: tempDir,
runCount: 1,
createdAt: 1_000 + index,
updatedAt: 1_000 + index,
endedAt: 2_000 + index,
})),
]
const flowsPath = resolveAutonomyFlowsPath(tempDir)
await mkdir(join(tempDir, AUTONOMY_DIR), { recursive: true })
await writeFile(
flowsPath,
`${JSON.stringify({ flows }, null, 2)}\n`,
'utf-8',
)
await startManagedAutonomyFlow({
trigger: 'scheduled-task',
goal: 'fresh active',
steps: TWO_STEPS,
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'fresh-active',
nowMs: 9_999,
})
const persisted = await listAutonomyFlows(tempDir)
expect(persisted).toHaveLength(100)
expect(persisted.some(flow => flow.flowId === 'old-active')).toBe(true)
expect(persisted.some(flow => flow.flowId === 'history-0')).toBe(false)
})
})
describe('startManagedAutonomyFlow', () => {
@@ -292,49 +225,6 @@ describe('startManagedAutonomyFlow', () => {
expect(result!.nextStep!.step.name).toBe('gather')
})
test('normalizes and preserves boundary across completed flow restarts', async () => {
const first = await startManagedAutonomyFlow({
trigger: 'scheduled-task',
goal: 'Scoped flow',
steps: [{ name: 'only', prompt: 'Do it' }],
rootDir: tempDir,
sourceId: 'scoped-src',
boundary: [' src/utils/** ', 'src\\bad', '/absolute', 'docs/*.md'],
nowMs: 1000,
})
const flowId = first!.flow.flowId
expect(first!.flow.boundary).toEqual(['src/utils/**', 'docs/*.md'])
await queueManagedAutonomyFlowStepRun({
flowId,
stepId: first!.nextStep!.step.stepId,
stepIndex: 0,
runId: 'run-1',
rootDir: tempDir,
nowMs: 2000,
})
await markManagedAutonomyFlowStepCompleted({
flowId,
runId: 'run-1',
rootDir: tempDir,
nowMs: 3000,
})
const restarted = await startManagedAutonomyFlow({
trigger: 'scheduled-task',
goal: 'Scoped flow',
steps: [{ name: 'only', prompt: 'Do it again' }],
rootDir: tempDir,
sourceId: 'scoped-src',
nowMs: 4000,
})
expect(restarted!.started).toBe(true)
expect(restarted!.flow.flowId).toBe(flowId)
expect(restarted!.flow.boundary).toEqual(['src/utils/**', 'docs/*.md'])
})
test('sets status=waiting when first step has waitFor', async () => {
const result = await startManagedAutonomyFlow({
trigger: 'scheduled-task',

View File

@@ -54,25 +54,6 @@ describe('withAutonomyPersistenceLock', () => {
).rejects.toThrow('inner failure')
})
test('releases same-root lock bookkeeping after success and failure', async () => {
const {
getAutonomyPersistenceLockCountForTests,
withAutonomyPersistenceLock,
} = await import('../autonomyPersistence')
expect(getAutonomyPersistenceLockCountForTests()).toBe(0)
await withAutonomyPersistenceLock(tempDir, async () => 'ok')
expect(getAutonomyPersistenceLockCountForTests()).toBe(0)
await expect(
withAutonomyPersistenceLock(tempDir, async () => {
throw new Error('inner failure')
}),
).rejects.toThrow('inner failure')
expect(getAutonomyPersistenceLockCountForTests()).toBe(0)
})
test('serializes concurrent calls on the same rootDir', async () => {
const { withAutonomyPersistenceLock } = await import(
'../autonomyPersistence'

View File

@@ -1,279 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { createTempDir, cleanupTempDir } from '../../../tests/mocks/file-system'
import { getAttachmentMessages } from '../attachments'
import {
createAutonomyQueuedPrompt,
createProactiveAutonomyCommands,
getAutonomyRunById,
markAutonomyRunCancelled,
startManagedAutonomyFlowFromHeartbeatTask,
} from '../autonomyRuns'
import { getAutonomyFlowById, listAutonomyFlows } from '../autonomyFlows'
import {
cancelQueuedAutonomyCommands,
claimConsumableQueuedAutonomyCommands,
finalizeAutonomyCommandsForTurn,
partitionConsumableQueuedAutonomyCommands,
} from '../autonomyQueueLifecycle'
import {
enqueue,
getCommandsByMaxPriority,
remove as removeFromQueue,
resetCommandQueue,
} from '../messageQueueManager'
let tempDir = ''
let extraTempDirs: string[] = []
beforeEach(async () => {
tempDir = await createTempDir('autonomy-queue-lifecycle-')
extraTempDirs = []
resetCommandQueue()
})
afterEach(async () => {
resetCommandQueue()
if (tempDir) {
await cleanupTempDir(tempDir)
}
for (const extraTempDir of extraTempDirs) {
await cleanupTempDir(extraTempDir)
}
})
describe('autonomyQueueLifecycle', () => {
async function consumeQueuedAutonomyAttachmentTurn() {
const previousDisableAttachments =
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = '1'
try {
const snapshot = getCommandsByMaxPriority('later')
const claim = await claimConsumableQueuedAutonomyCommands(
snapshot,
tempDir,
)
removeFromQueue(claim.staleCommands)
removeFromQueue(claim.claimedCommands)
const attachments = []
for await (const attachment of getAttachmentMessages(
null,
{} as never,
null,
claim.attachmentCommands,
[],
)) {
attachments.push(attachment)
}
const consumedCommands = claim.attachmentCommands.filter(
command =>
(command.mode === 'prompt' || command.mode === 'task-notification') &&
!claim.claimedCommands.includes(command),
)
removeFromQueue(consumedCommands)
const nextCommands = await finalizeAutonomyCommandsForTurn({
commands: claim.claimedCommands,
outcome: { type: 'completed' },
currentDir: tempDir,
priority: 'later',
})
for (const command of nextCommands) {
enqueue(command)
}
return { attachments, runningRunIds: claim.claimedRunIds, nextCommands }
} finally {
if (previousDisableAttachments === undefined) {
delete process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS
} else {
process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS = previousDisableAttachments
}
}
}
test('filters stale autonomy commands before mid-turn attachment consumption', async () => {
const command = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
const initial = await partitionConsumableQueuedAutonomyCommands(
[command!],
tempDir,
)
expect(initial.attachmentCommands).toHaveLength(1)
expect(initial.staleCommands).toHaveLength(0)
await markAutonomyRunCancelled(command!.autonomy!.runId, tempDir)
const afterCancel = await partitionConsumableQueuedAutonomyCommands(
[command!],
tempDir,
)
expect(afterCancel.attachmentCommands).toHaveLength(0)
expect(afterCancel.staleCommands).toHaveLength(1)
})
test('cancels proactive commands that are created but dropped before enqueue', async () => {
const commands = await createProactiveAutonomyCommands({
basePrompt: '<tick>12:00:00</tick>',
rootDir: tempDir,
currentDir: tempDir,
})
expect(commands).toHaveLength(1)
const queuedRun = await getAutonomyRunById(
commands[0]!.autonomy!.runId,
tempDir,
)
expect(queuedRun!.status).toBe('queued')
await cancelQueuedAutonomyCommands({ commands, rootDir: tempDir })
const cancelledRun = await getAutonomyRunById(
commands[0]!.autonomy!.runId,
tempDir,
)
expect(cancelledRun!.status).toBe('cancelled')
})
test('uses command rootDir when claiming after project context changes', async () => {
const otherProjectDir = await createTempDir('autonomy-other-project-')
extraTempDirs.push(otherProjectDir)
const command = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
expect(command!.autonomy?.rootDir).toBe(tempDir)
const claim = await claimConsumableQueuedAutonomyCommands(
[command!],
otherProjectDir,
)
const originalRun = await getAutonomyRunById(
command!.autonomy!.runId,
tempDir,
)
const wrongProjectRun = await getAutonomyRunById(
command!.autonomy!.runId,
otherProjectDir,
)
expect(claim.claimedRunIds).toEqual([command!.autonomy!.runId])
expect(claim.attachmentCommands).toHaveLength(1)
expect(originalRun!.status).toBe('running')
expect(wrongProjectRun).toBeNull()
})
test('advances a managed flow consumed as a queued attachment', async () => {
const command = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{ name: 'gather', prompt: 'Gather weekly inputs' },
{ name: 'draft', prompt: 'Draft weekly report' },
],
},
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
const claim = await claimConsumableQueuedAutonomyCommands(
[command!],
tempDir,
)
const runningRunIds = claim.claimedRunIds
expect(runningRunIds).toEqual([command!.autonomy!.runId])
const nextCommands = await finalizeAutonomyCommandsForTurn({
commands: claim.claimedCommands,
outcome: { type: 'completed' },
currentDir: tempDir,
priority: 'later',
})
const [flow] = await listAutonomyFlows(tempDir)
const detail = await getAutonomyFlowById(flow!.flowId, tempDir)
const run = await getAutonomyRunById(command!.autonomy!.runId, tempDir)
expect(run!.status).toBe('completed')
expect(nextCommands).toHaveLength(1)
expect(nextCommands[0]!.autonomy?.flowId).toBe(flow!.flowId)
expect(detail!.stateJson!.steps.map(step => step.status)).toEqual([
'completed',
'queued',
])
})
test('keeps managed autonomy flow coherent across queued attachment turns', async () => {
const firstCommand = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{ name: 'gather', prompt: 'Gather weekly inputs' },
{ name: 'draft', prompt: 'Draft weekly report' },
],
},
rootDir: tempDir,
currentDir: tempDir,
})
expect(firstCommand).not.toBeNull()
enqueue(firstCommand!)
const firstTurn = await consumeQueuedAutonomyAttachmentTurn()
const queuedAfterFirstTurn = getCommandsByMaxPriority('later')
const [flowAfterFirstTurn] = await listAutonomyFlows(tempDir)
const firstRun = await getAutonomyRunById(
firstCommand!.autonomy!.runId,
tempDir,
)
expect(firstTurn.attachments).toHaveLength(1)
expect(firstTurn.attachments[0]!.attachment?.type).toBe('queued_command')
expect(firstTurn.runningRunIds).toEqual([firstCommand!.autonomy!.runId])
expect(firstTurn.nextCommands).toHaveLength(1)
expect(queuedAfterFirstTurn).toHaveLength(1)
expect(queuedAfterFirstTurn[0]!.autonomy?.flowId).toBe(
flowAfterFirstTurn!.flowId,
)
expect(firstRun!.status).toBe('completed')
expect(
flowAfterFirstTurn!.stateJson!.steps.map(step => step.status),
).toEqual(['completed', 'queued'])
const secondCommand = queuedAfterFirstTurn[0]!
const secondTurn = await consumeQueuedAutonomyAttachmentTurn()
const queuedAfterSecondTurn = getCommandsByMaxPriority('later')
const finalFlow = await getAutonomyFlowById(
flowAfterFirstTurn!.flowId,
tempDir,
)
const secondRun = await getAutonomyRunById(
secondCommand.autonomy!.runId,
tempDir,
)
expect(secondTurn.attachments).toHaveLength(1)
expect(secondTurn.runningRunIds).toEqual([secondCommand.autonomy!.runId])
expect(secondTurn.nextCommands).toHaveLength(0)
expect(queuedAfterSecondTurn).toHaveLength(0)
expect(secondRun!.status).toBe('completed')
expect(finalFlow!.status).toBe('succeeded')
expect(finalFlow!.stateJson!.steps.map(step => step.status)).toEqual([
'completed',
'completed',
])
})
})

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { join, resolve as resolvePath } from 'node:path'
import { mkdir, writeFile } from 'fs/promises'
import { join } from 'path'
import {
resetStateForTests,
setCwdState,
@@ -7,23 +8,17 @@ import {
setProjectRoot,
} from '../../bootstrap/state'
import {
createAutonomyRun,
formatAutonomyRunsList,
formatAutonomyRunsStatus,
listAutonomyRuns,
createAutonomyQueuedPrompt,
createAutonomyQueuedPromptIfNoActiveSource,
createProactiveAutonomyCommands,
finalizeAutonomyRunCompleted,
getAutonomyRunById,
hasActiveAutonomyRunForSource,
markAutonomyRunCompleted,
markAutonomyRunCancelled,
markAutonomyRunFailed,
markAutonomyRunRunning,
recoverManagedAutonomyFlowPrompt,
resolveAutonomyRunsPath,
STALE_ACTIVE_RUN_ERROR_PREFIX,
startManagedAutonomyFlowFromHeartbeatTask,
} from '../autonomyRuns'
import {
@@ -40,14 +35,11 @@ import {
cleanupTempDir,
createTempDir,
createTempSubdir,
readTempFile,
tempPathExists,
writeTempFile,
} from '../../../tests/mocks/file-system'
const AGENTS_REL = join(AUTONOMY_DIR, 'AGENTS.md')
const HEARTBEAT_REL = join(AUTONOMY_DIR, 'HEARTBEAT.md')
const RUNS_REL = join(AUTONOMY_DIR, 'runs.json')
let tempDir = ''
@@ -103,9 +95,7 @@ describe('autonomyRuns', () => {
ownerKey: 'main-thread',
sourceId: 'cron-1',
sourceLabel: 'nightly-report',
ownerProcessId: process.pid,
})
expect(runs[0]?.ownerSessionId).toBeString()
expect(flows).toHaveLength(0)
expect(resolveAutonomyRunsPath(tempDir)).toContain('.claude')
})
@@ -128,7 +118,7 @@ describe('autonomyRuns', () => {
expect(command!.value).toContain('nested authority')
})
test('markAutonomyRunRunning/completed update persisted lifecycle state for plain runs', async () => {
test('markAutonomyRunRunning/completed/failed update persisted lifecycle state for plain runs', async () => {
const command = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
@@ -144,9 +134,7 @@ describe('autonomyRuns', () => {
runId,
status: 'running',
startedAt: 100,
ownerProcessId: process.pid,
})
expect(runs[0]?.ownerSessionId).toBeString()
await markAutonomyRunCompleted(runId, tempDir, 200)
runs = await listAutonomyRuns(tempDir)
@@ -155,22 +143,9 @@ describe('autonomyRuns', () => {
status: 'completed',
endedAt: 200,
})
})
test('markAutonomyRunFailed updates a non-terminal run', async () => {
const command = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
const runId = command!.autonomy!.runId
await markAutonomyRunRunning(runId, tempDir, 100)
await markAutonomyRunFailed(runId, 'boom', tempDir, 300)
const runs = await listAutonomyRuns(tempDir)
runs = await listAutonomyRuns(tempDir)
expect(runs[0]).toMatchObject({
runId,
status: 'failed',
@@ -179,346 +154,6 @@ describe('autonomyRuns', () => {
})
})
test('terminal runs are not revived by stale lifecycle updates', async () => {
const command = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
const runId = command!.autonomy!.runId
await markAutonomyRunCancelled(runId, tempDir, 100)
const revived = await markAutonomyRunRunning(runId, tempDir, 200)
const completed = await markAutonomyRunCompleted(runId, tempDir, 300)
const failed = await markAutonomyRunFailed(
runId,
'late failure',
tempDir,
400,
)
const persisted = await getAutonomyRunById(runId, tempDir)
expect(revived).toBeNull()
expect(completed).toBeNull()
expect(failed).toBeNull()
expect(persisted).toMatchObject({
status: 'cancelled',
endedAt: 100,
})
expect(persisted!.error).toBeUndefined()
})
test('hasActiveAutonomyRunForSource only treats queued and running scheduled runs as active', async () => {
const command = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
sourceLabel: 'nightly',
})
expect(command).not.toBeNull()
const runId = command!.autonomy!.runId
await expect(
hasActiveAutonomyRunForSource({
trigger: 'scheduled-task',
sourceId: 'cron-1',
rootDir: tempDir,
}),
).resolves.toBe(true)
await markAutonomyRunRunning(runId, tempDir, 100)
await expect(
hasActiveAutonomyRunForSource({
trigger: 'scheduled-task',
sourceId: 'cron-1',
rootDir: tempDir,
}),
).resolves.toBe(true)
await expect(
hasActiveAutonomyRunForSource({
trigger: 'scheduled-task',
sourceId: 'cron-2',
rootDir: tempDir,
}),
).resolves.toBe(false)
await markAutonomyRunCompleted(runId, tempDir, 200)
await expect(
hasActiveAutonomyRunForSource({
trigger: 'scheduled-task',
sourceId: 'cron-1',
rootDir: tempDir,
}),
).resolves.toBe(false)
const failedCommand = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
})
expect(failedCommand).not.toBeNull()
await markAutonomyRunFailed(
failedCommand!.autonomy!.runId,
'boom',
tempDir,
300,
)
await expect(
hasActiveAutonomyRunForSource({
trigger: 'scheduled-task',
sourceId: 'cron-1',
rootDir: tempDir,
}),
).resolves.toBe(false)
})
test('createAutonomyQueuedPromptIfNoActiveSource atomically skips duplicate active scheduled sources', async () => {
const [first, second] = await Promise.all([
createAutonomyQueuedPromptIfNoActiveSource({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
}),
createAutonomyQueuedPromptIfNoActiveSource({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
}),
])
const created = [first, second].filter(command => command !== null)
const runs = await listAutonomyRuns(tempDir)
expect(created).toHaveLength(1)
expect(runs).toHaveLength(1)
expect(runs[0]).toMatchObject({
trigger: 'scheduled-task',
status: 'queued',
sourceId: 'cron-1',
})
})
test('createAutonomyQueuedPromptIfNoActiveSource scopes dedup by ownerKey', async () => {
const first = await createAutonomyQueuedPromptIfNoActiveSource({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
ownerKey: 'owner-a',
})
const second = await createAutonomyQueuedPromptIfNoActiveSource({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
ownerKey: 'owner-b',
})
const runs = await listAutonomyRuns(tempDir)
expect(first).not.toBeNull()
expect(second).not.toBeNull()
expect(runs).toHaveLength(2)
expect(new Set(runs.map(run => run.ownerKey))).toEqual(
new Set(['owner-a', 'owner-b']),
)
})
test('createAutonomyQueuedPromptIfNoActiveSource does not advance heartbeat last-run state on dedup skip (two-phase commit invariant)', async () => {
await writeTempFile(
tempDir,
HEARTBEAT_REL,
[
'tasks:',
' - name: inbox',
' interval: 30m',
' prompt: "Check inbox"',
].join('\n'),
)
// Seed an active queued run for cron-1 so the next dedup attempt skips.
await writeTempFile(
tempDir,
RUNS_REL,
`${JSON.stringify(
{
runs: [
{
runId: 'preexisting-active',
runtime: 'automatic',
trigger: 'scheduled-task',
status: 'queued',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
promptPreview: 'still queued',
createdAt: 100,
ownerProcessId: process.pid,
ownerSessionId: 'self',
},
],
},
null,
2,
)}\n`,
)
const skipped = await createAutonomyQueuedPromptIfNoActiveSource({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
})
expect(skipped).toBeNull()
// If the dedup skip wrongly advanced heartbeat state, the next
// proactive-tick prompt would NOT include the inbox task. Verify it
// still does.
const followUp = await createAutonomyQueuedPrompt({
basePrompt: '<tick>12:00:00</tick>',
trigger: 'proactive-tick',
rootDir: tempDir,
currentDir: tempDir,
})
expect(followUp).not.toBeNull()
expect(followUp!.value).toContain('Due HEARTBEAT.md tasks:')
expect(followUp!.value).toContain('- inbox (30m): Check inbox')
})
test('createAutonomyQueuedPromptIfNoActiveSource recovers stale active runs from dead owner processes', async () => {
await writeTempFile(
tempDir,
RUNS_REL,
`${JSON.stringify(
{
runs: [
{
runId: 'stale-run',
runtime: 'automatic',
trigger: 'scheduled-task',
status: 'running',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
sourceLabel: 'nightly',
promptPreview: 'stale scheduled prompt',
createdAt: 100,
startedAt: 100,
ownerProcessId: 2_147_483_647,
ownerSessionId: 'dead-session',
},
],
},
null,
2,
)}\n`,
)
await expect(
hasActiveAutonomyRunForSource({
trigger: 'scheduled-task',
sourceId: 'cron-1',
rootDir: tempDir,
}),
).resolves.toBe(false)
const command = await createAutonomyQueuedPromptIfNoActiveSource({
basePrompt: 'scheduled prompt',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
})
const runs = await listAutonomyRuns(tempDir)
expect(command).not.toBeNull()
expect(runs).toHaveLength(2)
expect(runs[0]).toMatchObject({
trigger: 'scheduled-task',
status: 'queued',
sourceId: 'cron-1',
ownerProcessId: process.pid,
})
expect(runs[1]).toMatchObject({
runId: 'stale-run',
status: 'failed',
endedAt: runs[0]?.createdAt,
error: expect.stringContaining('owner process 2147483647'),
})
})
test('stale managed-flow run recovery also marks the flow step failed', async () => {
const command = await startManagedAutonomyFlowFromHeartbeatTask({
task: {
name: 'weekly-report',
interval: '7d',
prompt: 'Ship the weekly report',
steps: [
{
name: 'gather',
prompt: 'Gather weekly inputs',
},
],
},
rootDir: tempDir,
currentDir: tempDir,
})
expect(command).not.toBeNull()
const runId = command!.autonomy!.runId
await markAutonomyRunRunning(runId, tempDir, 100)
const runsPath = resolveAutonomyRunsPath(tempDir)
const file = JSON.parse(await readTempFile(runsPath)) as {
runs: Array<Record<string, unknown>>
}
file.runs = file.runs.map(run =>
run.runId === runId
? { ...run, ownerProcessId: 2_147_483_647 }
: run,
)
await writeTempFile(tempDir, RUNS_REL, `${JSON.stringify(file, null, 2)}\n`)
const replacement = await createAutonomyQueuedPromptIfNoActiveSource({
basePrompt: 'replacement prompt',
trigger: 'managed-flow-step',
rootDir: tempDir,
currentDir: tempDir,
sourceId: command!.autonomy!.sourceId!,
ownerKey: 'main-thread',
})
const [flow] = await listAutonomyFlows(tempDir)
const runs = await listAutonomyRuns(tempDir)
expect(replacement).not.toBeNull()
expect(runs.find(run => run.runId === runId)).toMatchObject({
status: 'failed',
error: expect.stringContaining(STALE_ACTIVE_RUN_ERROR_PREFIX),
})
expect(flow).toMatchObject({
status: 'failed',
blockedRunId: runId,
})
expect(flow?.stateJson?.steps[0]).toMatchObject({
status: 'failed',
runId,
error: expect.stringContaining(STALE_ACTIVE_RUN_ERROR_PREFIX),
})
})
test('formatters produce readable status and run listings', async () => {
const first = await createAutonomyQueuedPrompt({
basePrompt: 'scheduled prompt',
@@ -588,56 +223,11 @@ describe('autonomyRuns', () => {
)
})
test('persistence pruning keeps active runs ahead of recent completed history', async () => {
const runs = [
{
runId: 'old-active',
runtime: 'automatic',
trigger: 'scheduled-task',
status: 'queued',
rootDir: tempDir,
currentDir: tempDir,
ownerKey: 'main-thread',
promptPreview: 'old active',
createdAt: 1,
},
...Array.from({ length: 200 }, (_, index) => ({
runId: `history-${index}`,
runtime: 'automatic',
trigger: 'scheduled-task',
status: 'completed',
rootDir: tempDir,
currentDir: tempDir,
ownerKey: 'main-thread',
promptPreview: `history ${index}`,
createdAt: 1_000 + index,
endedAt: 2_000 + index,
})),
]
await writeTempFile(
tempDir,
RUNS_REL,
`${JSON.stringify({ runs }, null, 2)}\n`,
)
await createAutonomyRun({
trigger: 'scheduled-task',
prompt: 'fresh active',
rootDir: tempDir,
currentDir: tempDir,
nowMs: 9_999,
})
const persisted = await listAutonomyRuns(tempDir)
expect(persisted).toHaveLength(200)
expect(persisted.some(run => run.runId === 'old-active')).toBe(true)
expect(persisted.some(run => run.runId === 'history-0')).toBe(false)
})
test('listAutonomyRuns keeps older persisted records by normalizing missing runtime and owner metadata', async () => {
await writeTempFile(
tempDir,
RUNS_REL,
const runsPath = resolveAutonomyRunsPath(tempDir)
await mkdir(join(tempDir, '.claude', 'autonomy'), { recursive: true })
await writeFile(
runsPath,
`${JSON.stringify(
{
runs: [
@@ -654,6 +244,7 @@ describe('autonomyRuns', () => {
null,
2,
)}\n`,
'utf-8',
)
const [legacy] = await listAutonomyRuns(tempDir)
@@ -827,27 +418,4 @@ describe('autonomyRuns', () => {
expect(recovered!.autonomy?.runId).toBe(command!.autonomy?.runId)
expect(recovered!.autonomy?.flowId).toBe(flow!.flowId)
})
test('STALE_ACTIVE_RUN_ERROR_PREFIX stays in sync with HEARTBEAT.md stale-recovery-health task', async () => {
// The HEARTBEAT.md stale-recovery-health task prompt embeds this prefix
// as a literal string. Changing the constant without updating the
// heartbeat prompt would silently break the monitor — this test fails
// first to force the simultaneous update.
const heartbeatPath = resolvePath(
import.meta.dir,
'..',
'..',
'..',
'.claude',
'autonomy',
'HEARTBEAT.md',
)
if (!(await tempPathExists(heartbeatPath))) {
// .claude/ may be absent in some checkout layouts (e.g., shallow clone
// for npm pack). Skip rather than fail in that case.
return
}
const content = await readTempFile(heartbeatPath)
expect(content).toContain(STALE_ACTIVE_RUN_ERROR_PREFIX)
})
})

View File

@@ -133,50 +133,11 @@ function mergeAgentsAuthority(files: AutonomyAuthorityFile[]): string | null {
.join('\n\n')
}
/**
* Replaces fenced code-block content (and the ``` / ~~~ fence delimiters
* themselves) with empty strings while preserving the index of every
* other line. Used by the heartbeat parser so that `tasks:` literals
* appearing inside Markdown code samples in HEARTBEAT.md docs do not
* collide with the real config block.
*/
function maskCodeFencedLines(lines: string[]): string[] {
const masked = lines.slice()
let activeFenceChar: '`' | '~' | null = null
let activeFenceLen = 0
for (let i = 0; i < masked.length; i++) {
const trimmed = masked[i]!.trim()
const fenceMatch = trimmed.match(/^([`~])\1{2,}/)
if (fenceMatch) {
const fenceChar = fenceMatch[1]! as '`' | '~'
const fenceLen = fenceMatch[0]!.length
const trailing = trimmed.slice(fenceLen)
if (activeFenceChar === null) {
activeFenceChar = fenceChar
activeFenceLen = fenceLen
} else if (
activeFenceChar === fenceChar &&
fenceLen >= activeFenceLen &&
trailing.trim() === ''
) {
activeFenceChar = null
activeFenceLen = 0
}
masked[i] = ''
continue
}
if (activeFenceChar !== null) {
masked[i] = ''
}
}
return masked
}
export function parseHeartbeatAuthorityTasks(
content: string,
): HeartbeatAuthorityTask[] {
const tasks: HeartbeatAuthorityTask[] = []
const lines = maskCodeFencedLines(content.split('\n'))
const lines = content.split('\n')
const getIndent = (line: string): number =>
line.length - line.trimStart().length
const parseScalar = (line: string, key: string): string =>

View File

@@ -3,10 +3,7 @@ import { mkdir, writeFile } from 'fs/promises'
import { dirname, join, resolve } from 'path'
import { getProjectRoot } from '../bootstrap/state.js'
import { AUTONOMY_DIR, type AutonomyTriggerKind } from './autonomyAuthority.js'
import {
retainActiveFirst,
withAutonomyPersistenceLock,
} from './autonomyPersistence.js'
import { withAutonomyPersistenceLock } from './autonomyPersistence.js'
import { getFsImplementation } from './fsOperations.js'
const AUTONOMY_FLOWS_MAX = 100
@@ -86,20 +83,6 @@ export type AutonomyFlowRecord = {
waitJson?: AutonomyFlowWaitState
cancelRequestedAt?: number
lastError?: string
/**
* Repo-relative POSIX glob patterns describing which paths this flow's
* `report`-step approval covers. The pre-tool-use hook
* `require-plan-for-risky-edit.mjs` consults this list to permit edits
* only when the target file matches at least one entry. Absent or empty
* means "no boundary declared" — during the pilot window the hook
* treats this as broad approval (v1 behaviour). Once all production
* flows declare boundaries, the hook will deny absent-boundary flows.
*
* Supported syntax: `*` matches one path segment, `**` matches any
* number including zero. Examples: `src/utils/autonomy*`,
* `src/services/api/**`, `src/Tool.ts`.
*/
boundary?: string[]
}
type AutonomyFlowsFile = {
@@ -155,7 +138,6 @@ function cloneWaitState(
function cloneFlowRecord(flow: AutonomyFlowRecord): AutonomyFlowRecord {
return {
...flow,
...(flow.boundary ? { boundary: [...flow.boundary] } : {}),
...(flow.stateJson ? { stateJson: cloneManagedState(flow.stateJson) } : {}),
...(flow.waitJson ? { waitJson: cloneWaitState(flow.waitJson) } : {}),
}
@@ -170,17 +152,6 @@ function isManagedFlowStatusActive(status: AutonomyFlowStatus): boolean {
)
}
function selectPersistedAutonomyFlows(
flows: AutonomyFlowRecord[],
): AutonomyFlowRecord[] {
return retainActiveFirst(
flows.map(cloneFlowRecord),
flow => isManagedFlowStatusActive(flow.status),
flow => flow.updatedAt,
AUTONOMY_FLOWS_MAX,
)
}
function defaultFlowSource(params: {
trigger: AutonomyTriggerKind
sourceId?: string
@@ -266,35 +237,6 @@ function normalizeWaitState(value: unknown): AutonomyFlowWaitState | undefined {
}
}
function isPosixBoundaryGlob(value: string): boolean {
if (!value || value.startsWith('/') || value.includes('\\')) {
return false
}
if (value.includes('\0')) {
return false
}
return !value.split('/').some(segment => segment === '..')
}
function normalizeBoundary(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined
}
const seen = new Set<string>()
const boundary = value
.filter((entry): entry is string => typeof entry === 'string')
.map(entry => entry.trim())
.filter(isPosixBoundaryGlob)
.filter(entry => {
if (seen.has(entry)) {
return false
}
seen.add(entry)
return true
})
return boundary.length > 0 ? boundary : undefined
}
function normalizeFlowRecord(flow: AutonomyFlowRecord): AutonomyFlowRecord {
const source = defaultFlowSource(flow)
return {
@@ -305,7 +247,6 @@ function normalizeFlowRecord(flow: AutonomyFlowRecord): AutonomyFlowRecord {
goal: flow.goal || flow.sourceLabel || flow.sourceId || flow.flowKey,
currentDir: flow.currentDir || flow.rootDir,
runCount: Math.max(flow.runCount ?? 0, 0),
boundary: normalizeBoundary(flow.boundary),
stateJson: normalizeManagedState(flow.stateJson),
waitJson: normalizeWaitState(flow.waitJson),
...(flow.sourceId
@@ -428,7 +369,11 @@ async function writeAutonomyFlows(
path,
`${JSON.stringify(
{
flows: selectPersistedAutonomyFlows(flows),
flows: flows
.slice()
.map(cloneFlowRecord)
.sort((left, right) => right.updatedAt - left.updatedAt)
.slice(0, AUTONOMY_FLOWS_MAX),
} satisfies AutonomyFlowsFile,
null,
2,
@@ -475,7 +420,6 @@ export async function startManagedAutonomyFlow(params: {
ownerKey?: string
sourceId?: string
sourceLabel?: string
boundary?: string[]
nowMs?: number
}): Promise<ManagedAutonomyFlowStartResult | null> {
if (params.steps.length === 0) {
@@ -506,8 +450,6 @@ export async function startManagedAutonomyFlow(params: {
const stateJson = buildManagedState(params.steps)
const firstStep = stateJson.steps[0]!
const boundary =
normalizeBoundary(params.boundary) ?? normalizeBoundary(current?.boundary)
const waiting =
firstStep.waitFor != null
? {
@@ -532,7 +474,6 @@ export async function startManagedAutonomyFlow(params: {
currentDir,
...(source.sourceId ? { sourceId: source.sourceId } : {}),
...(source.sourceLabel ? { sourceLabel: source.sourceLabel } : {}),
...(boundary ? { boundary } : {}),
latestRunId: undefined,
runCount: current?.runCount ?? 0,
createdAt: current?.createdAt ?? nowMs,

View File

@@ -4,42 +4,6 @@ import { lock } from './lockfile.js'
const persistenceLocks = new Map<string, Promise<void>>()
/**
* Two-phase persistence retention. Active records (queued/running, etc.) are
* always kept — capping them risks evicting in-flight work; that responsibility
* lives in caller-side leak detection. Inactive (terminal) records are ranked
* by `getTimestamp` desc and capped to fill the remaining budget below `max`.
*
* Returned list is sorted by `getTimestamp` desc regardless of activity, so
* the persisted file is plain reverse-chronological order — listings/UI can
* consume it directly without re-sorting.
*/
export function retainActiveFirst<T>(
records: readonly T[],
isActive: (record: T) => boolean,
getTimestamp: (record: T) => number,
max: number,
): T[] {
const sortDesc = (left: T, right: T) =>
getTimestamp(right) - getTimestamp(left)
const active = records.filter(isActive).slice().sort(sortDesc)
const history = records
.filter(record => !isActive(record))
.slice()
.sort(sortDesc)
.slice(0, Math.max(0, max - active.length))
return [...active, ...history].sort(sortDesc)
}
export function getAutonomyPersistenceLockCountForTests(): number {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
'getAutonomyPersistenceLockCountForTests can only be called in tests',
)
}
return persistenceLocks.size
}
export async function withAutonomyPersistenceLock<T>(
rootDir: string,
fn: () => Promise<T>,
@@ -52,8 +16,10 @@ export async function withAutonomyPersistenceLock<T>(
const current = new Promise<void>(resolve => {
release = resolve
})
const chained = previous.then(() => current)
persistenceLocks.set(key, chained)
persistenceLocks.set(
key,
previous.then(() => current),
)
await previous
try {
@@ -75,7 +41,7 @@ export async function withAutonomyPersistenceLock<T>(
}
} finally {
release()
if (persistenceLocks.get(key) === chained) {
if (persistenceLocks.get(key) === current) {
persistenceLocks.delete(key)
}
}

View File

@@ -1,261 +0,0 @@
import type { QueuedCommand } from '../types/textInputTypes.js'
import {
finalizeAutonomyRunCompleted,
finalizeAutonomyRunFailed,
listAutonomyRuns,
markAutonomyRunCancelled,
markAutonomyRunRunning,
} from './autonomyRuns.js'
export type AutonomyQueuePartition = {
attachmentCommands: QueuedCommand[]
staleCommands: QueuedCommand[]
}
export type AutonomyQueueClaim = AutonomyQueuePartition & {
claimedRunIds: string[]
claimedCommands: QueuedCommand[]
}
export type AutonomyTurnOutcome =
| { type: 'completed' }
| { type: 'cancelled' }
| { type: 'failed'; error?: unknown; message?: string }
type AutonomyRunRef = {
runId: string
rootDir?: string
}
function getCommandRootDir(
command: QueuedCommand,
fallbackRootDir?: string,
): string | undefined {
return command.autonomy?.rootDir ?? fallbackRootDir
}
function refKey(ref: AutonomyRunRef): string {
return `${ref.rootDir ?? ''}\0${ref.runId}`
}
function getAutonomyRunRefs(
commands: QueuedCommand[],
fallbackRootDir?: string,
): AutonomyRunRef[] {
const refs = new Map<string, AutonomyRunRef>()
for (const command of commands) {
const runId = command.autonomy?.runId
if (!runId) {
continue
}
const ref = {
runId,
rootDir: getCommandRootDir(command, fallbackRootDir),
}
refs.set(refKey(ref), ref)
}
return [...refs.values()]
}
function isInlineQueuedCommand(command: QueuedCommand): boolean {
return command.mode === 'prompt' || command.mode === 'task-notification'
}
function groupRefsByRootDir(
refs: AutonomyRunRef[],
): Map<string, AutonomyRunRef[]> {
const grouped = new Map<string, AutonomyRunRef[]>()
for (const ref of refs) {
const key = ref.rootDir ?? ''
const group = grouped.get(key)
if (group) {
group.push(ref)
} else {
grouped.set(key, [ref])
}
}
return grouped
}
/**
* Exclude queued autonomy commands whose persisted run is no longer queued.
* This prevents stale in-memory commands from reviving flows after cancellation
* or after another path has already consumed the run.
*/
export async function partitionConsumableQueuedAutonomyCommands(
commands: QueuedCommand[],
rootDir?: string,
): Promise<AutonomyQueuePartition> {
const attachmentCommands: QueuedCommand[] = []
const staleCommands: QueuedCommand[] = []
const refs = getAutonomyRunRefs(commands, rootDir)
const runsByRef = new Map<
string,
Awaited<ReturnType<typeof listAutonomyRuns>>[number]
>()
for (const [rootKey, group] of groupRefsByRootDir(refs)) {
const runs = await listAutonomyRuns(rootKey || undefined)
const wanted = new Set(group.map(ref => ref.runId))
for (const run of runs) {
if (wanted.has(run.runId)) {
runsByRef.set(
refKey({ runId: run.runId, rootDir: rootKey || undefined }),
run,
)
}
}
}
for (const command of commands) {
const runId = command.autonomy?.runId
if (!runId) {
attachmentCommands.push(command)
continue
}
const commandRootDir = getCommandRootDir(command, rootDir)
const run = runsByRef.get(refKey({ runId, rootDir: commandRootDir }))
if (run?.status === 'queued' && !run.startedAt && !run.endedAt) {
attachmentCommands.push(command)
} else {
staleCommands.push(command)
}
}
return { attachmentCommands, staleCommands }
}
export async function claimConsumableQueuedAutonomyCommands(
commands: QueuedCommand[],
rootDir?: string,
): Promise<AutonomyQueueClaim> {
const partition = await partitionConsumableQueuedAutonomyCommands(
commands,
rootDir,
)
const claimedRunIds: string[] = []
const claimedRunKeys: string[] = []
const staleRunKeys = new Set<string>()
const candidateRefs = getAutonomyRunRefs(
partition.attachmentCommands.filter(isInlineQueuedCommand),
rootDir,
)
for (const ref of candidateRefs) {
const updated = await markAutonomyRunRunning(ref.runId, ref.rootDir)
if (updated?.status === 'running') {
claimedRunIds.push(ref.runId)
claimedRunKeys.push(refKey(ref))
} else {
staleRunKeys.add(refKey(ref))
}
}
const claimedRunKeySet = new Set(claimedRunKeys)
const attachmentCommands: QueuedCommand[] = []
const claimedCommands: QueuedCommand[] = []
const staleCommands = [...partition.staleCommands]
for (const command of partition.attachmentCommands) {
const runId = command.autonomy?.runId
if (!runId) {
attachmentCommands.push(command)
continue
}
const key = refKey({
runId,
rootDir: getCommandRootDir(command, rootDir),
})
if (claimedRunKeySet.has(key)) {
attachmentCommands.push(command)
claimedCommands.push(command)
} else if (staleRunKeys.has(key)) {
staleCommands.push(command)
}
}
return {
attachmentCommands,
staleCommands,
claimedRunIds,
claimedCommands,
}
}
export async function cancelQueuedAutonomyCommands(params: {
commands: QueuedCommand[]
rootDir?: string
}): Promise<void> {
for (const ref of getAutonomyRunRefs(params.commands, params.rootDir)) {
await markAutonomyRunCancelled(ref.runId, ref.rootDir)
}
}
function stringifyAutonomyError(error: unknown): string {
if (typeof error === 'string') {
return error
}
if (error instanceof Error) {
return error.message
}
return String(error)
}
export function sanitizeAutonomyFailureForPersistence(
error: unknown,
fallback = 'query failed',
): string {
const message = stringifyAutonomyError(error)
const lower = message.toLowerCase()
if (
lower.includes('api_error') ||
lower.includes('provider') ||
lower.includes('openai') ||
lower.includes('gemini') ||
lower.includes('grok') ||
lower.includes('anthropic') ||
lower.includes('bedrock') ||
lower.includes('vertex')
) {
return 'provider api_error'
}
return fallback
}
export async function finalizeAutonomyCommandsForTurn(params: {
commands: QueuedCommand[]
outcome: AutonomyTurnOutcome
currentDir?: string
priority?: 'now' | 'next' | 'later'
workload?: string
}): Promise<QueuedCommand[]> {
const nextCommands: QueuedCommand[] = []
for (const command of params.commands) {
const autonomy = command.autonomy
if (!autonomy?.runId) {
continue
}
if (params.outcome.type === 'completed') {
nextCommands.push(
...(await finalizeAutonomyRunCompleted({
runId: autonomy.runId,
rootDir: autonomy.rootDir,
currentDir: params.currentDir,
priority: params.priority,
workload: command.workload ?? params.workload,
})),
)
} else if (params.outcome.type === 'cancelled') {
await markAutonomyRunCancelled(autonomy.runId, autonomy.rootDir)
} else {
await finalizeAutonomyRunFailed({
runId: autonomy.runId,
rootDir: autonomy.rootDir,
error:
params.outcome.message ??
sanitizeAutonomyFailureForPersistence(params.outcome.error),
})
}
}
return nextCommands
}

View File

@@ -1,7 +1,7 @@
import { randomUUID } from 'crypto'
import { mkdir, writeFile } from 'fs/promises'
import { dirname, join, resolve } from 'path'
import { getProjectRoot, getSessionId } from '../bootstrap/state.js'
import { getProjectRoot } from '../bootstrap/state.js'
import type { MessageOrigin } from '../types/message.js'
import type { QueuedCommand } from '../types/textInputTypes.js'
import {
@@ -27,34 +27,11 @@ import {
type AutonomyFlowSyncMode,
type ManagedAutonomyFlowStepDefinition,
} from './autonomyFlows.js'
import {
retainActiveFirst,
withAutonomyPersistenceLock,
} from './autonomyPersistence.js'
import { withAutonomyPersistenceLock } from './autonomyPersistence.js'
import { getFsImplementation } from './fsOperations.js'
import { isProcessRunning } from './genericProcessUtils.js'
import { logError } from './log.js'
const AUTONOMY_RUNS_MAX = 200
// Diagnostic threshold for active (queued/running) runs. Active records are
// deliberately exempt from AUTONOMY_RUNS_MAX so a leak in finalization cannot
// silently evict in-flight work; that exemption only makes sense if a leak is
// loud when it appears. Crossing this threshold warns once per process so
// operators see the divergence in logs before runs.json grows pathologically.
const AUTONOMY_ACTIVE_RUNS_WARN_THRESHOLD = 100
let warnedActiveRunsThresholdCrossed = false
const AUTONOMY_RUNS_RELATIVE_PATH = join(AUTONOMY_DIR, 'runs.json')
// Sentinel string surfaced to operators via runs.json error fields and
// referenced literally by the HEARTBEAT.md `stale-recovery-health` task.
// A unit test asserts the HEARTBEAT.md file contains this exact prefix —
// changing the value will fail the test, forcing the heartbeat prompt
// to be updated in the same change.
export const STALE_ACTIVE_RUN_ERROR_PREFIX =
'Recovered stale active autonomy run'
// Guards the legacy-block warning so it fires once per (process, runId) instead
// of every dedup tick while a no-owner record sits there.
const warnedLegacyBlockRunIds = new Set<string>()
export type AutonomyRunStatus =
| 'queued'
@@ -82,8 +59,6 @@ export type AutonomyRunRecord = {
flowStepName?: string
promptPreview: string
createdAt: number
ownerProcessId?: number
ownerSessionId?: string
startedAt?: number
endedAt?: number
error?: string
@@ -102,19 +77,6 @@ type AutonomyRunFlowRef = {
stepName: string
}
type CreateAutonomyRunParams = {
trigger: AutonomyTriggerKind
prompt: string
rootDir?: string
currentDir?: string
sourceId?: string
sourceLabel?: string
runtime?: AutonomyRunRuntime
ownerKey?: string
flow?: AutonomyRunFlowRef
nowMs?: number
}
function truncatePromptPreview(prompt: string): string {
const singleLine = prompt.replace(/\s+/g, ' ').trim()
return singleLine.length <= 240
@@ -133,34 +95,6 @@ function cloneRunRecord(run: AutonomyRunRecord): AutonomyRunRecord {
return { ...run }
}
function isAutonomyRunActive(run: AutonomyRunRecord): boolean {
return run.status === 'queued' || run.status === 'running'
}
function selectPersistedAutonomyRuns(
runs: AutonomyRunRecord[],
): AutonomyRunRecord[] {
const cloned = runs.map(cloneRunRecord)
const activeCount = cloned.filter(isAutonomyRunActive).length
if (
!warnedActiveRunsThresholdCrossed &&
activeCount >= AUTONOMY_ACTIVE_RUNS_WARN_THRESHOLD
) {
warnedActiveRunsThresholdCrossed = true
logError(
new Error(
`autonomy: ${activeCount} active runs exceed warn threshold ${AUTONOMY_ACTIVE_RUNS_WARN_THRESHOLD}; check for finalize leaks`,
),
)
}
return retainActiveFirst(
cloned,
isAutonomyRunActive,
run => run.createdAt,
AUTONOMY_RUNS_MAX,
)
}
function normalizePersistedRunRecord(
run: PersistedAutonomyRunRecord,
): AutonomyRunRecord {
@@ -223,7 +157,11 @@ async function writeAutonomyRuns(
path,
`${JSON.stringify(
{
runs: selectPersistedAutonomyRuns(runs),
runs: runs
.slice()
.map(cloneRunRecord)
.sort((left, right) => right.createdAt - left.createdAt)
.slice(0, AUTONOMY_RUNS_MAX),
} satisfies AutonomyRunsFile,
null,
2,
@@ -234,7 +172,7 @@ async function writeAutonomyRuns(
async function updateAutonomyRun(
runId: string,
updater: (current: AutonomyRunRecord) => AutonomyRunRecord | null,
updater: (current: AutonomyRunRecord) => AutonomyRunRecord,
rootDir: string = getProjectRoot(),
): Promise<AutonomyRunRecord | null> {
return withAutonomyPersistenceLock(rootDir, async () => {
@@ -243,11 +181,7 @@ async function updateAutonomyRun(
if (index === -1) {
return null
}
const next = updater(cloneRunRecord(runs[index]!))
if (!next) {
return null
}
const updated = cloneRunRecord(next)
const updated = cloneRunRecord(updater(cloneRunRecord(runs[index]!)))
runs[index] = updated
await writeAutonomyRuns(runs, rootDir)
return updated
@@ -262,112 +196,21 @@ export async function getAutonomyRunById(
return runs.find(run => run.runId === runId) ?? null
}
function isActiveAutonomyRunStatus(status: AutonomyRunStatus): boolean {
return status === 'queued' || status === 'running'
}
function isValidOwnerProcessId(pid: number | undefined): pid is number {
// Reject non-numeric, negative, zero (Linux: send-to-process-group), and
// non-integer values. A forged record with pid=0 or pid<0 used to be
// treated as live and could permanently block dedup; treating them as
// stale closes that availability hole.
return (
typeof pid === 'number' &&
Number.isInteger(pid) &&
pid > 0 &&
pid <= 4_194_304
)
}
function isStaleActiveAutonomyRun(run: AutonomyRunRecord): boolean {
if (!isActiveAutonomyRunStatus(run.status)) {
return false
}
if (run.ownerProcessId === undefined) {
return false
}
if (!isValidOwnerProcessId(run.ownerProcessId)) {
return true
}
return !isProcessRunning(run.ownerProcessId)
}
function staleActiveRunError(run: AutonomyRunRecord): string {
return `${STALE_ACTIVE_RUN_ERROR_PREFIX}: owner process ${run.ownerProcessId} is no longer running.`
}
function failAutonomyRunRecord(
run: AutonomyRunRecord,
error: string,
nowMs: number,
): AutonomyRunRecord {
return {
...run,
status: 'failed',
endedAt: nowMs,
error,
}
}
function recoverStaleActiveAutonomyRun(
run: AutonomyRunRecord,
nowMs: number,
): AutonomyRunRecord {
return failAutonomyRunRecord(run, staleActiveRunError(run), nowMs)
}
async function syncFailedManagedFlowForRun(
run: AutonomyRunRecord,
rootDir: string,
): Promise<void> {
if (run.parentFlowId && run.parentFlowSyncMode === 'managed') {
await markManagedAutonomyFlowStepFailed({
flowId: run.parentFlowId,
runId: run.runId,
error: run.error ?? 'Autonomy run failed.',
rootDir,
nowMs: run.endedAt,
})
}
}
function matchesActiveAutonomyRunSource(
run: AutonomyRunRecord,
params: {
trigger: AutonomyTriggerKind
sourceId: string
ownerKey?: string
},
): boolean {
return (
run.trigger === params.trigger &&
run.sourceId === params.sourceId &&
(params.ownerKey === undefined || run.ownerKey === params.ownerKey) &&
isActiveAutonomyRunStatus(run.status)
)
}
export async function hasActiveAutonomyRunForSource(params: {
export async function createAutonomyRun(params: {
trigger: AutonomyTriggerKind
sourceId: string
prompt: string
rootDir?: string
currentDir?: string
sourceId?: string
sourceLabel?: string
runtime?: AutonomyRunRuntime
ownerKey?: string
}): Promise<boolean> {
const runs = await listAutonomyRuns(params.rootDir)
return runs.some(
run =>
matchesActiveAutonomyRunSource(run, params) &&
!isStaleActiveAutonomyRun(run),
)
}
function buildAutonomyRunRecord(
params: CreateAutonomyRunParams,
rootDir: string,
currentDir: string,
): AutonomyRunRecord {
const createdAt = params.nowMs ?? Date.now()
return {
flow?: AutonomyRunFlowRef
nowMs?: number
}): Promise<AutonomyRunRecord> {
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const currentDir = resolve(params.currentDir ?? rootDir)
const record: AutonomyRunRecord = {
runId: randomUUID(),
runtime: params.runtime ?? (params.flow ? 'flow_step' : 'automatic'),
trigger: params.trigger,
@@ -388,77 +231,13 @@ function buildAutonomyRunRecord(
}
: {}),
promptPreview: truncatePromptPreview(params.prompt),
createdAt,
ownerProcessId: process.pid,
ownerSessionId: getSessionId(),
createdAt: params.nowMs ?? Date.now(),
}
}
async function persistAutonomyRunRecord(
record: AutonomyRunRecord,
rootDir: string,
skipWhenActiveSource: boolean,
): Promise<{
created: boolean
recoveredStaleRuns: AutonomyRunRecord[]
}> {
let created = false
const recoveredStaleRuns: AutonomyRunRecord[] = []
await withAutonomyPersistenceLock(rootDir, async () => {
const runs = await listAutonomyRuns(rootDir)
const sourceId = record.sourceId
if (skipWhenActiveSource && sourceId) {
let hasBlockingActiveRun = false
let staleRecoveriesApplied = false
for (let i = 0; i < runs.length; i++) {
const run = runs[i]!
if (
!matchesActiveAutonomyRunSource(run, {
trigger: record.trigger,
sourceId,
ownerKey: record.ownerKey,
})
) {
continue
}
if (isStaleActiveAutonomyRun(run)) {
const recovered = recoverStaleActiveAutonomyRun(run, record.createdAt)
runs[i] = recovered
recoveredStaleRuns.push(recovered)
staleRecoveriesApplied = true
continue
}
if (
run.ownerProcessId === undefined &&
!warnedLegacyBlockRunIds.has(run.runId)
) {
warnedLegacyBlockRunIds.add(run.runId)
logError(
new Error(
`[autonomyRuns] blocked by legacy un-owned active run ${run.runId} (createdAt=${run.createdAt}); cancel manually if this is a stale upgrade artifact`,
),
)
}
hasBlockingActiveRun = true
}
if (hasBlockingActiveRun) {
if (staleRecoveriesApplied) {
await writeAutonomyRuns(runs, rootDir)
}
return
}
}
runs.unshift(record)
await writeAutonomyRuns(runs, rootDir)
created = true
})
return { created, recoveredStaleRuns }
}
async function queueManagedFlowStepRunForRecord(
record: AutonomyRunRecord,
rootDir: string,
): Promise<void> {
if (
record.parentFlowId &&
record.flowStepId &&
@@ -479,47 +258,9 @@ async function queueManagedFlowStepRunForRecord(
nowMs: record.createdAt,
})
}
}
async function createAutonomyRunCore(
params: CreateAutonomyRunParams,
skipIfActiveSource: boolean,
): Promise<AutonomyRunRecord | null> {
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const currentDir = resolve(params.currentDir ?? rootDir)
const record = buildAutonomyRunRecord(params, rootDir, currentDir)
const { created, recoveredStaleRuns } = await persistAutonomyRunRecord(
record,
rootDir,
skipIfActiveSource,
)
for (const recovered of recoveredStaleRuns) {
await syncFailedManagedFlowForRun(recovered, rootDir)
}
if (!created) {
return null
}
await queueManagedFlowStepRunForRecord(record, rootDir)
return record
}
export async function createAutonomyRun(
params: CreateAutonomyRunParams,
): Promise<AutonomyRunRecord> {
const record = await createAutonomyRunCore(params, false)
if (!record) {
throw new Error('Autonomy run was unexpectedly skipped.')
}
return record
}
export async function createAutonomyRunIfNoActiveSource(
params: CreateAutonomyRunParams & { sourceId: string },
): Promise<AutonomyRunRecord | null> {
return createAutonomyRunCore(params, true)
}
function buildManagedFlowStepPrompt(
flow: AutonomyFlowRecord,
stepIndex: number,
@@ -595,7 +336,6 @@ async function createOrRecoverManagedFlowStepCommand(params: {
workload: params.workload,
autonomy: {
runId: run.runId,
rootDir: run.rootDir,
trigger: 'managed-flow-step',
sourceId: run.sourceId,
sourceLabel: run.sourceLabel,
@@ -686,16 +426,11 @@ export async function markAutonomyRunRunning(
): Promise<AutonomyRunRecord | null> {
const updated = await updateAutonomyRun(
runId,
current =>
current.status === 'queued'
? {
...current,
status: 'running',
startedAt: nowMs ?? Date.now(),
ownerProcessId: process.pid,
ownerSessionId: getSessionId(),
}
: null,
current => ({
...current,
status: 'running',
startedAt: nowMs ?? Date.now(),
}),
rootDir,
)
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
@@ -716,15 +451,12 @@ export async function markAutonomyRunCompleted(
): Promise<AutonomyRunRecord | null> {
const updated = await updateAutonomyRun(
runId,
current =>
current.status === 'queued' || current.status === 'running'
? {
...current,
status: 'completed',
endedAt: nowMs ?? Date.now(),
error: undefined,
}
: null,
current => ({
...current,
status: 'completed',
endedAt: nowMs ?? Date.now(),
error: undefined,
}),
rootDir,
)
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
@@ -744,17 +476,24 @@ export async function markAutonomyRunFailed(
rootDir?: string,
nowMs?: number,
): Promise<AutonomyRunRecord | null> {
const endedAt = nowMs ?? Date.now()
const updated = await updateAutonomyRun(
runId,
current =>
isActiveAutonomyRunStatus(current.status)
? failAutonomyRunRecord(current, error, endedAt)
: null,
current => ({
...current,
status: 'failed',
endedAt: nowMs ?? Date.now(),
error,
}),
rootDir,
)
if (updated) {
await syncFailedManagedFlowForRun(updated, rootDir ?? updated.rootDir)
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
await markManagedAutonomyFlowStepFailed({
flowId: updated.parentFlowId,
runId: updated.runId,
error,
rootDir,
nowMs: updated.endedAt,
})
}
return updated
}
@@ -766,15 +505,12 @@ export async function markAutonomyRunCancelled(
): Promise<AutonomyRunRecord | null> {
const updated = await updateAutonomyRun(
runId,
current =>
current.status === 'queued' || current.status === 'running'
? {
...current,
status: 'cancelled',
endedAt: nowMs ?? Date.now(),
error: undefined,
}
: null,
current => ({
...current,
status: 'cancelled',
endedAt: nowMs ?? Date.now(),
error: undefined,
}),
rootDir,
)
if (updated?.parentFlowId && updated.parentFlowSyncMode === 'managed') {
@@ -876,7 +612,6 @@ export async function createAutonomyQueuedPrompt(params: {
currentDir?: string
sourceId?: string
sourceLabel?: string
ownerKey?: string
workload?: string
priority?: 'now' | 'next' | 'later'
shouldCreate?: () => boolean
@@ -899,130 +634,39 @@ export async function createAutonomyQueuedPrompt(params: {
currentDir,
sourceId: params.sourceId,
sourceLabel: params.sourceLabel,
ownerKey: params.ownerKey,
workload: params.workload,
priority: params.priority,
flow: params.flow,
})
}
export async function createAutonomyQueuedPromptIfNoActiveSource(params: {
trigger: AutonomyTriggerKind
basePrompt: string
rootDir?: string
currentDir?: string
sourceId: string
sourceLabel?: string
ownerKey?: string
workload?: string
priority?: 'now' | 'next' | 'later'
shouldCreate?: () => boolean
}): Promise<QueuedCommand | null> {
const rootDir = resolve(params.rootDir ?? getProjectRoot())
const currentDir = resolve(params.currentDir ?? getCwd())
// Cheap optimistic pre-check: skip the AGENTS.md / HEARTBEAT.md disk
// reads + prompt assembly when an active run for this source already
// blocks dedup. The lock-side check inside persistAutonomyRunRecord
// remains authoritative; this only fast-paths the common storm case.
if (
await hasActiveAutonomyRunForSource({
trigger: params.trigger,
sourceId: params.sourceId,
rootDir,
ownerKey: params.ownerKey,
})
) {
return null
}
const prepared = await prepareAutonomyTurnPrompt({
basePrompt: params.basePrompt,
trigger: params.trigger,
rootDir,
currentDir,
})
if (params.shouldCreate && !params.shouldCreate()) {
return null
}
return commitAutonomyQueuedPromptIfNoActiveSource({
prepared,
rootDir,
currentDir,
sourceId: params.sourceId,
sourceLabel: params.sourceLabel,
ownerKey: params.ownerKey,
workload: params.workload,
priority: params.priority,
})
}
export async function commitAutonomyQueuedPrompt(params: {
prepared: Awaited<ReturnType<typeof prepareAutonomyTurnPrompt>>
rootDir?: string
currentDir?: string
sourceId?: string
sourceLabel?: string
ownerKey?: string
workload?: string
priority?: 'now' | 'next' | 'later'
flow?: AutonomyRunFlowRef
}): Promise<QueuedCommand> {
const command = await commitAutonomyQueuedPromptInternal(params, false)
if (!command) {
throw new Error('Autonomy queued prompt was unexpectedly skipped.')
}
return command
}
async function commitAutonomyQueuedPromptIfNoActiveSource(params: {
prepared: Awaited<ReturnType<typeof prepareAutonomyTurnPrompt>>
rootDir?: string
currentDir?: string
sourceId: string
sourceLabel?: string
ownerKey?: string
workload?: string
priority?: 'now' | 'next' | 'later'
}): Promise<QueuedCommand | null> {
return commitAutonomyQueuedPromptInternal(params, true)
}
async function commitAutonomyQueuedPromptInternal(
params: {
prepared: Awaited<ReturnType<typeof prepareAutonomyTurnPrompt>>
rootDir?: string
currentDir?: string
sourceId?: string
sourceLabel?: string
ownerKey?: string
workload?: string
priority?: 'now' | 'next' | 'later'
flow?: AutonomyRunFlowRef
},
skipWhenActiveSource: boolean,
): Promise<QueuedCommand | null> {
const rootDir = resolve(
params.rootDir ?? params.prepared.rootDir ?? getProjectRoot(),
)
const currentDir = resolve(
params.currentDir ?? params.prepared.currentDir ?? getCwd(),
)
commitPreparedAutonomyTurn(params.prepared)
const value = params.prepared.prompt
const runParams: CreateAutonomyRunParams = {
const run = await createAutonomyRun({
trigger: params.prepared.trigger,
prompt: value,
rootDir,
currentDir,
sourceId: params.sourceId,
sourceLabel: params.sourceLabel,
ownerKey: params.ownerKey,
flow: params.flow,
}
const useDedup = skipWhenActiveSource && Boolean(params.sourceId)
const run = await createAutonomyRunCore(runParams, useDedup)
if (!run) {
return null
}
commitPreparedAutonomyTurn(params.prepared)
})
const origin = {
kind: 'autonomy',
trigger: params.prepared.trigger,
@@ -1039,7 +683,6 @@ async function commitAutonomyQueuedPromptInternal(
workload: params.workload,
autonomy: {
runId: run.runId,
rootDir: run.rootDir,
trigger: params.prepared.trigger,
sourceId: params.sourceId,
sourceLabel: params.sourceLabel,

View File

@@ -19,20 +19,19 @@ import {
} from '../types/textInputTypes.js'
import { createAbortController } from './abortController.js'
import type { PastedContent } from './config.js'
import { getCwd } from './cwd.js'
import { logForDebugging } from './debug.js'
import type { EffortValue } from './effort.js'
import type { FileHistoryState } from './fileHistory.js'
import { fileHistoryEnabled, fileHistoryMakeSnapshot } from './fileHistory.js'
import { gracefulShutdownSync } from './gracefulShutdown.js'
import { toError } from './errors.js'
import { logError } from './log.js'
import { enqueue } from './messageQueueManager.js'
import { resolveSkillModelOverride } from './model/model.js'
import {
claimConsumableQueuedAutonomyCommands,
finalizeAutonomyCommandsForTurn,
} from './autonomyQueueLifecycle.js'
finalizeAutonomyRunCompleted,
finalizeAutonomyRunFailed,
markAutonomyRunFailed,
markAutonomyRunRunning,
} from './autonomyRuns.js'
import type { ProcessUserInputContext } from './processUserInput/processUserInput.js'
import { processUserInput } from './processUserInput/processUserInput.js'
import type { QueryGuard } from './QueryGuard.js'
@@ -76,7 +75,7 @@ type BaseExecutionParams = {
onBeforeQuery?: (input: string, newMessages: Message[]) => Promise<boolean>,
input?: string,
effort?: EffortValue,
) => Promise<boolean>
) => Promise<void>
setAppState: (updater: (prev: AppState) => AppState) => void
onBeforeQuery?: (input: string, newMessages: Message[]) => Promise<boolean>
canUseTool?: CanUseToolFn
@@ -460,18 +459,7 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
// Iterate all commands uniformly. First command gets attachments +
// ideSelection + pastedContents, rest skip attachments to avoid
// duplicating turn-level context (IDE selection, todos, diffs).
let commands = queuedCommands ?? []
const queuedAutonomyClaim =
await claimConsumableQueuedAutonomyCommands(commands)
commands = queuedAutonomyClaim.attachmentCommands
const claimedAutonomyCommands = queuedAutonomyClaim.claimedCommands
if (commands.length === 0) {
// Clear the abort controller published a few lines above so this turn's
// stale controller does not leak into the next turn when every claimed
// autonomy command was skipped as non-consumable.
setAbortController(null)
return
}
const commands = queuedCommands ?? []
// Compute the workload tag for this turn. queueProcessor can batch a
// cron prompt with a same-tick human prompt; only tag when EVERY
@@ -483,7 +471,7 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
commands.every(c => c.workload === firstWorkload)
? firstWorkload
: undefined
const deferredAutonomyRunIds = new Set<string>()
let autonomyRunIds: string[] | undefined
// Wrap the entire turn (processUserInput loop + onQuery) in an
// AsyncLocalStorage context. This is the ONLY way to correctly
@@ -493,13 +481,15 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
// context — isolated from the parent's continuation. A process-global
// mutable slot would be clobbered at the detached closure's first
// await by this function's synchronous return path. See state.ts.
let turnError: unknown
try {
await runWithWorkload(turnWorkload, async () => {
for (let i = 0; i < commands.length; i++) {
const cmd = commands[i]!
const isFirst = i === 0
const runId = cmd.autonomy?.runId
if (cmd.autonomy?.runId) {
;(autonomyRunIds ??= []).push(cmd.autonomy.runId)
await markAutonomyRunRunning(cmd.autonomy.runId)
}
const result = await processUserInput({
input: cmd.value,
preExpansionInput: cmd.preExpansionValue,
@@ -520,11 +510,7 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
bridgeOrigin: cmd.bridgeOrigin,
isMeta: cmd.isMeta,
skipAttachments: !isFirst,
autonomy: cmd.autonomy,
})
if (runId && result.deferAutonomyCompletion) {
deferredAutonomyRunIds.add(runId)
}
// Stamp origin here rather than threading another arg through
// processUserInput → processUserInputBase → processTextPrompt → createUserMessage.
// Derive origin from mode for task-notifications — mirrors the origin
@@ -625,52 +611,28 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
}
}
}) // end runWithWorkload — ALS context naturally scoped, no finally needed
} catch (error) {
turnError = error
}
// Finalize claimed autonomy commands as `completed` only if the turn
// body itself succeeded. Run the finalize call in its own try/catch so a
// failure there does not double-finalize the same commands as `failed`
// (which previously cancelled follow-up queue state after a successful
// turn).
if (claimedAutonomyCommands.length) {
const finalizableCommands = claimedAutonomyCommands.filter(command => {
const runId = command.autonomy?.runId
return !runId || !deferredAutonomyRunIds.has(runId)
})
if (turnError) {
try {
await finalizeAutonomyCommandsForTurn({
commands: finalizableCommands,
outcome: { type: 'failed', error: turnError },
currentDir: getCwd(),
priority: 'later',
workload: turnWorkload,
})
} catch (finalizeError) {
logError(toError(finalizeError))
}
} else {
try {
const nextCommands = await finalizeAutonomyCommandsForTurn({
commands: finalizableCommands,
outcome: { type: 'completed' },
currentDir: getCwd(),
if (autonomyRunIds?.length) {
for (const runId of autonomyRunIds) {
const nextCommands = await finalizeAutonomyRunCompleted({
runId,
priority: 'later',
workload: turnWorkload,
})
for (const nextCommand of nextCommands) {
enqueue(nextCommand)
}
} catch (finalizeError) {
logError(toError(finalizeError))
}
}
}
if (turnError) {
throw turnError
} catch (error) {
if (autonomyRunIds?.length) {
for (const runId of autonomyRunIds) {
await finalizeAutonomyRunFailed({
runId,
error: String(error),
})
}
}
throw error
}
} finally {
// Safety net: release the guard reservation if processUserInput threw

View File

@@ -1,162 +1,173 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
import { mock } from "bun:test";
const { getAPIProvider, isFirstPartyAnthropicBaseUrl } = await import(
'../providers'
)
let mockedModelType: "gemini" | undefined;
describe('getAPIProvider', () => {
mock.module("../../settings/settings.js", () => ({
getInitialSettings: () =>
mockedModelType ? { modelType: mockedModelType } : {},
}));
const { getAPIProvider, isFirstPartyAnthropicBaseUrl } =
await import("../providers");
describe("getAPIProvider", () => {
const envKeys = [
'CLAUDE_CODE_USE_GEMINI',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_OPENAI',
'CLAUDE_CODE_USE_GROK',
] as const
const savedEnv: Record<string, string | undefined> = {}
"CLAUDE_CODE_USE_GEMINI",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
"CLAUDE_CODE_USE_FOUNDRY",
"CLAUDE_CODE_USE_OPENAI",
] as const;
const savedEnv: Record<string, string | undefined> = {};
beforeEach(() => {
// Save and clear environment variables
mockedModelType = undefined;
for (const key of envKeys) {
savedEnv[key] = process.env[key]
delete process.env[key]
savedEnv[key] = process.env[key];
delete process.env[key];
}
})
});
afterEach(() => {
// Restore environment variables
mockedModelType = undefined;
for (const key of envKeys) {
if (savedEnv[key] !== undefined) {
process.env[key] = savedEnv[key]
process.env[key] = savedEnv[key];
} else {
delete process.env[key]
delete process.env[key];
}
}
})
});
test('returns "firstParty" by default', () => {
expect(getAPIProvider({})).toBe('firstParty')
})
expect(getAPIProvider()).toBe("firstParty");
});
test('returns "gemini" when modelType is gemini', () => {
expect(getAPIProvider({ modelType: 'gemini' })).toBe('gemini')
})
mockedModelType = "gemini";
expect(getAPIProvider()).toBe("gemini");
});
test('modelType takes precedence over environment variables', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
expect(getAPIProvider({ modelType: 'gemini' })).toBe('gemini')
})
test("modelType takes precedence over environment variables", () => {
mockedModelType = "gemini";
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
expect(getAPIProvider()).toBe("gemini");
});
test('returns "gemini" when CLAUDE_CODE_USE_GEMINI is set', () => {
process.env.CLAUDE_CODE_USE_GEMINI = '1'
expect(getAPIProvider({})).toBe('gemini')
})
process.env.CLAUDE_CODE_USE_GEMINI = "1";
expect(getAPIProvider()).toBe("gemini");
});
test('returns "bedrock" when CLAUDE_CODE_USE_BEDROCK is set', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
expect(getAPIProvider({})).toBe('bedrock')
})
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
expect(getAPIProvider()).toBe("bedrock");
});
test('returns "vertex" when CLAUDE_CODE_USE_VERTEX is set', () => {
process.env.CLAUDE_CODE_USE_VERTEX = '1'
expect(getAPIProvider({})).toBe('vertex')
})
process.env.CLAUDE_CODE_USE_VERTEX = "1";
expect(getAPIProvider()).toBe("vertex");
});
test('returns "foundry" when CLAUDE_CODE_USE_FOUNDRY is set', () => {
process.env.CLAUDE_CODE_USE_FOUNDRY = '1'
expect(getAPIProvider({})).toBe('foundry')
})
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
expect(getAPIProvider()).toBe("foundry");
});
test('bedrock takes precedence over gemini', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
process.env.CLAUDE_CODE_USE_GEMINI = '1'
expect(getAPIProvider({})).toBe('bedrock')
})
test("bedrock takes precedence over gemini", () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
process.env.CLAUDE_CODE_USE_GEMINI = "1";
expect(getAPIProvider()).toBe("bedrock");
});
test('bedrock takes precedence over vertex', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
process.env.CLAUDE_CODE_USE_VERTEX = '1'
expect(getAPIProvider({})).toBe('bedrock')
})
test("bedrock takes precedence over vertex", () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
process.env.CLAUDE_CODE_USE_VERTEX = "1";
expect(getAPIProvider()).toBe("bedrock");
});
test('bedrock wins when all three env vars are set', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = '1'
process.env.CLAUDE_CODE_USE_VERTEX = '1'
process.env.CLAUDE_CODE_USE_FOUNDRY = '1'
expect(getAPIProvider({})).toBe('bedrock')
})
test("bedrock wins when all three env vars are set", () => {
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
process.env.CLAUDE_CODE_USE_VERTEX = "1";
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
expect(getAPIProvider()).toBe("bedrock");
});
test('"true" is truthy', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = 'true'
expect(getAPIProvider({})).toBe('bedrock')
})
process.env.CLAUDE_CODE_USE_BEDROCK = "true";
expect(getAPIProvider()).toBe("bedrock");
});
test('"0" is not truthy', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = '0'
expect(getAPIProvider({})).toBe('firstParty')
})
process.env.CLAUDE_CODE_USE_BEDROCK = "0";
expect(getAPIProvider()).toBe("firstParty");
});
test('empty string is not truthy', () => {
process.env.CLAUDE_CODE_USE_BEDROCK = ''
expect(getAPIProvider({})).toBe('firstParty')
})
})
process.env.CLAUDE_CODE_USE_BEDROCK = "";
expect(getAPIProvider()).toBe("firstParty");
});
});
describe('isFirstPartyAnthropicBaseUrl', () => {
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL
const originalUserType = process.env.USER_TYPE
describe("isFirstPartyAnthropicBaseUrl", () => {
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
const originalUserType = process.env.USER_TYPE;
afterEach(() => {
if (originalBaseUrl !== undefined) {
process.env.ANTHROPIC_BASE_URL = originalBaseUrl
process.env.ANTHROPIC_BASE_URL = originalBaseUrl;
} else {
delete process.env.ANTHROPIC_BASE_URL
delete process.env.ANTHROPIC_BASE_URL;
}
if (originalUserType !== undefined) {
process.env.USER_TYPE = originalUserType
process.env.USER_TYPE = originalUserType;
} else {
delete process.env.USER_TYPE
delete process.env.USER_TYPE;
}
})
});
test('returns true when ANTHROPIC_BASE_URL is not set', () => {
delete process.env.ANTHROPIC_BASE_URL
expect(isFirstPartyAnthropicBaseUrl()).toBe(true)
})
test("returns true when ANTHROPIC_BASE_URL is not set", () => {
delete process.env.ANTHROPIC_BASE_URL;
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
});
test('returns true for api.anthropic.com', () => {
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com'
expect(isFirstPartyAnthropicBaseUrl()).toBe(true)
})
test("returns true for api.anthropic.com", () => {
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com";
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
});
test('returns false for custom URL', () => {
process.env.ANTHROPIC_BASE_URL = 'https://my-proxy.com'
expect(isFirstPartyAnthropicBaseUrl()).toBe(false)
})
test("returns false for custom URL", () => {
process.env.ANTHROPIC_BASE_URL = "https://my-proxy.com";
expect(isFirstPartyAnthropicBaseUrl()).toBe(false);
});
test('returns false for invalid URL', () => {
process.env.ANTHROPIC_BASE_URL = 'not-a-url'
expect(isFirstPartyAnthropicBaseUrl()).toBe(false)
})
test("returns false for invalid URL", () => {
process.env.ANTHROPIC_BASE_URL = "not-a-url";
expect(isFirstPartyAnthropicBaseUrl()).toBe(false);
});
test('returns true for staging URL when USER_TYPE is ant', () => {
process.env.ANTHROPIC_BASE_URL = 'https://api-staging.anthropic.com'
process.env.USER_TYPE = 'ant'
expect(isFirstPartyAnthropicBaseUrl()).toBe(true)
})
test("returns true for staging URL when USER_TYPE is ant", () => {
process.env.ANTHROPIC_BASE_URL = "https://api-staging.anthropic.com";
process.env.USER_TYPE = "ant";
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
});
test('returns true for URL with path', () => {
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1'
expect(isFirstPartyAnthropicBaseUrl()).toBe(true)
})
test("returns true for URL with path", () => {
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
});
test('returns true for trailing slash', () => {
process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com/'
expect(isFirstPartyAnthropicBaseUrl()).toBe(true)
})
test("returns true for trailing slash", () => {
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com/";
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
});
test('returns false for subdomain attack', () => {
process.env.ANTHROPIC_BASE_URL = 'https://evil-api.anthropic.com'
expect(isFirstPartyAnthropicBaseUrl()).toBe(false)
})
})
test("returns false for subdomain attack", () => {
process.env.ANTHROPIC_BASE_URL = "https://evil-api.anthropic.com";
expect(isFirstPartyAnthropicBaseUrl()).toBe(false);
});
});

View File

@@ -1,6 +1,5 @@
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.js'
import { getInitialSettings } from '../settings/settings.js'
import type { SettingsJson } from '../settings/types.js'
import { isEnvTruthy } from '../envUtils.js'
export type APIProvider =
@@ -12,10 +11,8 @@ export type APIProvider =
| 'gemini'
| 'grok'
export function getAPIProvider(
settings: Pick<SettingsJson, 'modelType'> = getInitialSettings(),
): APIProvider {
const modelType = settings.modelType
export function getAPIProvider(): APIProvider {
const modelType = getInitialSettings().modelType
if (modelType === 'openai') return 'openai'
if (modelType === 'gemini') return 'gemini'
if (modelType === 'grok') return 'grok'

View File

@@ -1,375 +0,0 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import type { QueuedCommand } from '../../../types/textInputTypes'
import {
resetStateForTests,
setCwdState,
setOriginalCwd,
setProjectRoot,
} from '../../../bootstrap/state'
import {
createAutonomyQueuedPrompt,
getAutonomyRunById,
listAutonomyRuns,
markAutonomyRunRunning,
} from '../../autonomyRuns'
import { resetAutonomyAuthorityForTests } from '../../autonomyAuthority'
import { createScheduledTaskQueuedCommand } from '../../../hooks/useScheduledTasks'
import {
cleanupTempDir,
createTempDir,
} from '../../../../tests/mocks/file-system'
let runAgentBlocker: Promise<void> | null = null
let releaseRunAgentBlocker: (() => void) | null = null
let runAgentStartCount = 0
let originalNodeEnv: string | undefined
let originalAnthropicApiKey: string | undefined
const commandQueue: QueuedCommand[] = []
function enqueue(command: QueuedCommand): void {
commandQueue.push({ ...command, priority: command.priority ?? 'next' })
}
function enqueuePendingNotification(command: QueuedCommand): void {
commandQueue.push({ ...command, priority: command.priority ?? 'later' })
}
function getCommandQueue(): QueuedCommand[] {
return [...commandQueue]
}
function hasCommandsInQueue(): boolean {
return commandQueue.length > 0
}
function resetCommandQueue(): void {
commandQueue.length = 0
}
function createMessageQueueManagerMock() {
return {
enqueue,
enqueuePendingNotification,
getCommandQueue,
hasCommandsInQueue,
resetCommandQueue,
}
}
function holdRunAgent(): void {
runAgentBlocker = new Promise(resolve => {
releaseRunAgentBlocker = resolve
})
}
function releaseRunAgent(): void {
releaseRunAgentBlocker?.()
runAgentBlocker = null
releaseRunAgentBlocker = null
}
mock.module('bun:bundle', () => ({
feature: (name: string) => name === 'KAIROS',
}))
mock.module(
'@claude-code-best/builtin-tools/tools/AgentTool/runAgent.js',
() => ({
runAgent: async function* () {
runAgentStartCount += 1
if (runAgentBlocker) {
await runAgentBlocker
}
yield {
type: 'assistant',
uuid: 'assistant-1',
timestamp: new Date().toISOString(),
message: {
id: 'msg_1',
type: 'message',
role: 'assistant',
model: 'test-model',
content: [{ type: 'text', text: 'forked command done' }],
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
},
},
}
},
}),
)
mock.module('@claude-code-best/builtin-tools/tools/AgentTool/UI.js', () => ({
AgentPromptDisplay: () => null,
AgentResponseDisplay: () => null,
extractLastToolInfo: () => null,
renderGroupedAgentToolUse: () => null,
renderToolResultMessage: () => null,
renderToolUseErrorMessage: () => null,
renderToolUseMessage: () => null,
renderToolUseProgressMessage: () => null,
renderToolUseRejectedMessage: () => null,
renderToolUseTag: () => null,
userFacingName: () => 'Agent',
userFacingNameBackgroundColor: () => 'gray',
}))
mock.module('../../messageQueueManager', createMessageQueueManagerMock)
mock.module('../../messageQueueManager.js', createMessageQueueManagerMock)
const { processSlashCommand } = await import('../processSlashCommand')
let tempDir = ''
function createScheduledTaskQueuedCommandForTest(task: {
id: string
prompt: string
}) {
return createScheduledTaskQueuedCommand(task, {
rootDir: tempDir,
currentDir: tempDir,
})
}
async function waitForRunStatus(
runId: string,
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled',
): Promise<void> {
for (let i = 0; i < 200; i++) {
const run = await getAutonomyRunById(runId, tempDir)
if (run?.status === status) {
return
}
await new Promise(resolve => setTimeout(resolve, 10))
}
const run = await getAutonomyRunById(runId, tempDir)
throw new Error(`Expected ${runId} to be ${status}, got ${run?.status}`)
}
async function waitForRunAgentStarts(expected: number): Promise<void> {
for (let i = 0; i < 200; i++) {
if (runAgentStartCount >= expected) {
return
}
await new Promise(resolve => setTimeout(resolve, 10))
}
throw new Error(
`Expected runAgent to start ${expected} time(s), got ${runAgentStartCount}`,
)
}
async function waitForCommandQueueLength(expected: number): Promise<void> {
for (let i = 0; i < 200; i++) {
if (getCommandQueue().length === expected) {
return
}
await new Promise(resolve => setTimeout(resolve, 10))
}
throw new Error(
`Expected command queue length ${expected}, got ${getCommandQueue().length}`,
)
}
beforeEach(async () => {
tempDir = await createTempDir('process-slash-command-')
originalNodeEnv = process.env.NODE_ENV
originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY
process.env.NODE_ENV = 'test'
process.env.ANTHROPIC_API_KEY = 'test-key'
runAgentBlocker = null
releaseRunAgentBlocker = null
runAgentStartCount = 0
resetStateForTests()
resetAutonomyAuthorityForTests()
resetCommandQueue()
setOriginalCwd(tempDir)
setProjectRoot(tempDir)
setCwdState(tempDir)
})
afterEach(async () => {
releaseRunAgent()
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = originalNodeEnv
}
if (originalAnthropicApiKey === undefined) {
delete process.env.ANTHROPIC_API_KEY
} else {
process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey
}
resetStateForTests()
resetAutonomyAuthorityForTests()
resetCommandQueue()
if (tempDir) {
await cleanupTempDir(tempDir)
}
mock.restore()
})
describe('processSlashCommand', () => {
const forkedCommand = {
type: 'prompt',
name: 'forked',
description: 'test forked command',
progressMessage: 'forking',
contentLength: 0,
source: 'builtin',
context: 'fork',
getPromptForCommand: async () => [
{ type: 'text', text: 'review from fork' },
],
} as const
function createContext() {
return {
getAppState: () => ({
kairosEnabled: true,
mcp: { clients: [] },
toolPermissionContext: {
mode: 'default',
alwaysAllowRules: {},
},
}),
options: {
commands: [forkedCommand],
allowBackgroundForkedSlashCommands: true,
tools: [],
refreshTools: () => [],
agentDefinitions: {
activeAgents: [{ agentType: 'general-purpose' }],
},
},
setResponseLength: mock((_updater: (length: number) => number) => {}),
} as any
}
test('defers autonomy completion until a KAIROS background forked command completes', async () => {
const queued = await createAutonomyQueuedPrompt({
basePrompt: '/forked review',
trigger: 'scheduled-task',
rootDir: tempDir,
currentDir: tempDir,
sourceId: 'cron-1',
})
expect(queued).not.toBeNull()
const runId = queued!.autonomy!.runId
await markAutonomyRunRunning(runId, tempDir, 100)
const result = await processSlashCommand(
'/forked review',
[],
[],
[],
createContext(),
mock(() => {}),
undefined,
false,
async () => ({ behavior: 'allow', updatedInput: {} }) as any,
queued!.autonomy,
)
expect(result).toMatchObject({
messages: [],
shouldQuery: false,
deferAutonomyCompletion: true,
})
await waitForRunStatus(runId, 'completed')
await waitForCommandQueueLength(1)
expect(getCommandQueue()).toEqual([
expect.objectContaining({
mode: 'prompt',
isMeta: true,
skipSlashCommands: true,
value: expect.stringContaining(
'<scheduled-task-result command="/forked">',
),
}),
])
})
test('keeps repeated /loop scheduled fires bounded while a background fork is running', async () => {
const task = {
id: 'cron-loop',
prompt: '/forked review',
}
const first = await createScheduledTaskQueuedCommandForTest(task)
expect(first?.autonomy?.runId).toBeDefined()
const runId = first!.autonomy!.runId
await markAutonomyRunRunning(runId, tempDir, 100)
holdRunAgent()
const result = await processSlashCommand(
'/forked review',
[],
[],
[],
createContext(),
mock(() => {}),
undefined,
false,
async () => ({ behavior: 'allow', updatedInput: {} }) as any,
first!.autonomy,
)
expect(result.deferAutonomyCompletion).toBe(true)
await waitForRunAgentStarts(1)
const repeatedFires = await Promise.all(
Array.from({ length: 200 }, () =>
createScheduledTaskQueuedCommandForTest(task),
),
)
expect(repeatedFires.every(command => command === null)).toBe(true)
expect(
(await listAutonomyRuns(tempDir)).filter(
run => run.sourceId === 'cron-loop',
),
).toHaveLength(1)
expect(getCommandQueue()).toHaveLength(0)
releaseRunAgent()
await waitForRunStatus(runId, 'completed')
await waitForCommandQueueLength(1)
expect(getCommandQueue()).toHaveLength(1)
const next = await createScheduledTaskQueuedCommandForTest(task)
expect(next?.autonomy?.runId).toBeDefined()
expect(
(await listAutonomyRuns(tempDir)).filter(
run => run.sourceId === 'cron-loop',
),
).toHaveLength(2)
})
test('rejects the background fork test override outside test runtime', async () => {
process.env.NODE_ENV = 'production'
const result = await processSlashCommand(
'/forked review',
[],
[],
[],
createContext(),
mock(() => {}),
undefined,
false,
async () => ({ behavior: 'allow', updatedInput: {} }) as any,
)
expect(result.shouldQuery).toBe(false)
expect(
result.messages.some(message =>
JSON.stringify(message).includes(
'allowBackgroundForkedSlashCommands is test-only',
),
),
).toBe(true)
expect(runAgentStartCount).toBe(0)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,6 @@ import type {
import type { PermissionMode } from '../../types/permissions.js'
import {
isValidImagePaste,
type QueuedCommand,
type PromptInputMode,
} from '../../types/textInputTypes.js'
import {
@@ -81,9 +80,6 @@ export type ProcessUserInputBaseResult = {
// Used by /discover to chain into the selected feature's command
nextInput?: string
submitNextInput?: boolean
// When true, the command started detached work that will finalize its
// autonomy run after the background work completes.
deferAutonomyCompletion?: boolean
}
export async function processUserInput({
@@ -104,7 +100,6 @@ export async function processUserInput({
bridgeOrigin,
isMeta,
skipAttachments,
autonomy,
}: {
input: string | Array<ContentBlockParam>
/**
@@ -142,7 +137,6 @@ export async function processUserInput({
*/
isMeta?: boolean
skipAttachments?: boolean
autonomy?: QueuedCommand['autonomy']
}): Promise<ProcessUserInputBaseResult> {
const inputString = typeof input === 'string' ? input : null
// Immediately show the user input prompt while we are still processing the input.
@@ -174,7 +168,6 @@ export async function processUserInput({
isMeta,
skipAttachments,
preExpansionInput,
autonomy,
)
queryCheckpoint('query_process_user_input_base_end')
@@ -303,7 +296,6 @@ async function processUserInputBase(
isMeta?: boolean,
skipAttachments?: boolean,
preExpansionInput?: string,
autonomy?: QueuedCommand['autonomy'],
): Promise<ProcessUserInputBaseResult> {
let inputString: string | null = null
let precedingInputBlocks: ContentBlockParam[] = []
@@ -499,7 +491,6 @@ async function processUserInputBase(
uuid,
isAlreadyProcessing,
canUseTool,
autonomy,
)
return addImageMetadataMessage(slashResult, imageMetadataTexts)
}
@@ -558,7 +549,6 @@ async function processUserInputBase(
uuid,
isAlreadyProcessing,
canUseTool,
autonomy,
)
return addImageMetadataMessage(slashResult, imageMetadataTexts)
}

View File

@@ -424,7 +424,8 @@ function createInProcessCanUseTool(
feedback: parsed.error,
})
}
return // Callback already resolves the promise
cleanup()
return
}
}
}
@@ -674,7 +675,6 @@ type WaitResult =
type: 'new_message'
message: string
autonomyRunId?: string
autonomyRootDir?: string
from: string
color?: string
summary?: string
@@ -739,16 +739,12 @@ async function waitForNextPromptOrShutdown(
`[inProcessRunner] ${identity.agentName} found pending user message (poll #${pollCount})`,
)
if (pending.autonomyRunId) {
await markAutonomyRunRunning(
pending.autonomyRunId,
pending.autonomyRootDir,
)
await markAutonomyRunRunning(pending.autonomyRunId)
}
return {
type: 'new_message',
message: pending.message,
autonomyRunId: pending.autonomyRunId,
autonomyRootDir: pending.autonomyRootDir,
from: 'user',
}
}
@@ -1026,7 +1022,6 @@ export async function runInProcessTeammate(
)
let currentPrompt = wrappedInitialPrompt
let currentAutonomyRunId: string | undefined
let currentAutonomyRootDir: string | undefined
let shouldExit = false
// Try to claim an available task immediately so the UI can show activity
@@ -1324,21 +1319,12 @@ export async function runInProcessTeammate(
setAppState,
)
if (currentAutonomyRunId) {
await markAutonomyRunFailed(
currentAutonomyRunId,
ERROR_MESSAGE_USER_ABORT,
currentAutonomyRootDir,
)
await markAutonomyRunFailed(currentAutonomyRunId, ERROR_MESSAGE_USER_ABORT)
currentAutonomyRunId = undefined
currentAutonomyRootDir = undefined
}
} else if (currentAutonomyRunId) {
await markAutonomyRunCompleted(
currentAutonomyRunId,
currentAutonomyRootDir,
)
await markAutonomyRunCompleted(currentAutonomyRunId)
currentAutonomyRunId = undefined
currentAutonomyRootDir = undefined
}
// Check if already idle before updating (to skip duplicate notification)
@@ -1412,7 +1398,6 @@ export async function runInProcessTeammate(
setAppState,
)
currentAutonomyRunId = undefined
currentAutonomyRootDir = undefined
break
case 'new_message':
@@ -1425,7 +1410,6 @@ export async function runInProcessTeammate(
if (waitResult.from === 'user') {
currentPrompt = waitResult.message
currentAutonomyRunId = waitResult.autonomyRunId
currentAutonomyRootDir = waitResult.autonomyRootDir
} else {
currentPrompt = formatAsTeammateMessage(
waitResult.from,
@@ -1442,7 +1426,6 @@ export async function runInProcessTeammate(
setAppState,
)
currentAutonomyRunId = undefined
currentAutonomyRootDir = undefined
}
break
@@ -1550,11 +1533,7 @@ export async function runInProcessTeammate(
})
}
if (currentAutonomyRunId) {
await markAutonomyRunFailed(
currentAutonomyRunId,
errorMessage,
currentAutonomyRootDir,
)
await markAutonomyRunFailed(currentAutonomyRunId, errorMessage)
}
// Send idle notification with failure via file-based mailbox

View File

@@ -234,7 +234,7 @@ export function killInProcessTeammate(
let agentId: string | null = null
let toolUseId: string | undefined
let description: string | undefined
let pendingAutonomyRuns: Array<{ runId: string; rootDir?: string }> = []
let pendingAutonomyRunIds: string[] = []
setAppState((prev: AppState) => {
const task = prev.tasks[taskId]
@@ -255,18 +255,9 @@ export function killInProcessTeammate(
description = teammateTask.description
// Capture pending autonomy run IDs before clearing them
pendingAutonomyRuns = teammateTask.pendingUserMessages.flatMap(message =>
message.autonomyRunId
? [
{
runId: message.autonomyRunId,
...(message.autonomyRootDir
? { rootDir: message.autonomyRootDir }
: {}),
},
]
: [],
)
pendingAutonomyRunIds = teammateTask.pendingUserMessages
.map(message => message.autonomyRunId)
.filter((runId): runId is string => runId !== undefined)
// Abort the controller to stop execution
teammateTask.abortController?.abort()
@@ -320,11 +311,10 @@ export function killInProcessTeammate(
}
if (killed) {
for (const run of pendingAutonomyRuns) {
for (const runId of pendingAutonomyRunIds) {
void markAutonomyRunFailed(
run.runId,
runId,
`Teammate ${agentId ?? taskId} was stopped before it could consume the queued autonomy prompt.`,
run.rootDir,
)
}
void evictTaskOutput(taskId)

View File

@@ -1,148 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import {
resetStateForTests,
setOriginalCwd,
setProjectRoot,
} from '../../src/bootstrap/state'
import {
listAutonomyRuns,
startManagedAutonomyFlowFromHeartbeatTask,
} from '../../src/utils/autonomyRuns'
import { listAutonomyFlows } from '../../src/utils/autonomyFlows'
const CLI_ENTRYPOINT = resolve(import.meta.dir, '../../src/entrypoints/cli.tsx')
let tempDir = ''
let configDir = ''
let previousConfigDir: string | undefined
async function runAutonomyCli(args: string[]): Promise<string> {
const proc = Bun.spawn({
cmd: [process.execPath, CLI_ENTRYPOINT, 'autonomy', ...args],
cwd: tempDir,
env: {
...process.env,
CLAUDE_CONFIG_DIR: configDir,
CI: 'true',
GITHUB_ACTIONS: 'true',
NODE_ENV: 'development',
NO_COLOR: '1',
},
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
])
expect(stderr, `unexpected stderr output:\n${stderr}`).toBe('')
expect(exitCode, `non-zero exit ${exitCode}; stderr:\n${stderr}`).toBe(0)
return stdout
}
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'autonomy-user-flow-'))
configDir = join(tempDir, 'config')
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = configDir
resetStateForTests()
setOriginalCwd(tempDir)
setProjectRoot(tempDir)
})
afterEach(() => {
resetStateForTests()
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
}
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true })
}
})
describe('autonomy lifecycle user-equivalent CLI flow', () => {
test('status --deep works from a clean project without creating autonomy state', async () => {
const output = await runAutonomyCli(['status', '--deep'])
expect(output).toContain('# Autonomy Deep Status')
expect(output).toContain('Autonomy runs: 0')
expect(output).toContain('Autonomy flows: 0')
expect(existsSync(join(tempDir, '.claude', 'autonomy', 'runs.json'))).toBe(
false,
)
expect(existsSync(join(tempDir, '.claude', 'autonomy', 'flows.json'))).toBe(
false,
)
})
test('real CLI can inspect, resume, and cancel a persisted managed flow', async () => {
await startManagedAutonomyFlowFromHeartbeatTask({
rootDir: tempDir,
currentDir: tempDir,
task: {
name: 'manual-user-flow',
interval: '1h',
prompt: 'Manual lifecycle acceptance',
steps: [
{
name: 'approve',
prompt: 'Wait for manual approval',
waitFor: 'manual',
},
{
name: 'execute',
prompt: 'Execute approved work',
},
],
},
})
const [waitingFlow] = await listAutonomyFlows(tempDir)
expect(waitingFlow?.status).toBe('waiting')
const status = await runAutonomyCli(['status', '--deep'])
expect(status).toContain('Autonomy flows: 1')
expect(status).toContain('Waiting: 1')
const flows = await runAutonomyCli(['flows', '5'])
expect(flows).toContain(waitingFlow!.flowId)
expect(flows).toContain('waiting')
const detailBefore = await runAutonomyCli(['flow', waitingFlow!.flowId])
expect(detailBefore).toContain('Status: waiting')
expect(detailBefore).toContain('Current step: approve')
const resume = await runAutonomyCli(['flow', 'resume', waitingFlow!.flowId])
expect(resume).toContain('Prepared the next managed step')
expect(resume).toContain('Prompt:')
const detailAfterResume = await runAutonomyCli([
'flow',
waitingFlow!.flowId,
])
expect(detailAfterResume).toContain('Status: queued')
expect(detailAfterResume).toContain('Latest run:')
const cancel = await runAutonomyCli(['flow', 'cancel', waitingFlow!.flowId])
expect(cancel).toContain('Cancelled flow')
const [cancelledRun] = await listAutonomyRuns(tempDir)
const [cancelledFlow] = await listAutonomyFlows(tempDir)
expect(cancelledRun?.status).toBe('cancelled')
expect(cancelledFlow?.status).toBe('cancelled')
const detailAfterCancel = await runAutonomyCli([
'flow',
waitingFlow!.flowId,
])
expect(detailAfterCancel).toContain('Status: cancelled')
}, 30000)
})

View File

@@ -2,42 +2,13 @@ import { describe, expect, test } from 'bun:test'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
const repoRoot = resolve(import.meta.dir, '..', '..')
const uuidV4Pattern =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
async function findPackageJson(
startPath: string,
expectedName: string,
): Promise<string> {
let current = dirname(startPath)
for (let depth = 0; depth < 10; depth++) {
const candidate = join(current, 'package.json')
const file = Bun.file(candidate)
if (await file.exists()) {
try {
const parsed = JSON.parse(await file.text()) as { name?: unknown }
if (parsed.name === expectedName) {
return candidate
}
} catch {
// ignore parse errors and keep walking up
}
}
const parent = dirname(current)
if (parent === current) {
break
}
current = parent
}
throw new Error(
`package.json with name "${expectedName}" not found above ${startPath}`,
)
}
describe('dependency security overrides', () => {
test('mcpb can load patched inquirer prompts from its package context', async () => {
const mcpbRequire = createRequire(import.meta.resolve('@anthropic-ai/mcpb'))
@@ -57,7 +28,10 @@ describe('dependency security overrides', () => {
)
const gaxios = vertexRequire('gaxios') as {
request(options: {
adapter(options: { headers: Headers; url: string }): Promise<{
adapter(options: {
headers: Headers
url: string
}): Promise<{
config: unknown
data: string
headers: Record<string, string>
@@ -65,7 +39,7 @@ describe('dependency security overrides', () => {
status: number
statusText: string
}>
multipart: Array<{ body: string; headers: Record<string, string> }>
multipart: Array<{ body: string; headers: Record<string, string> }>
url: string
}): Promise<{ status: number }>
}
@@ -73,10 +47,8 @@ describe('dependency security overrides', () => {
const response = await gaxios.request({
url: 'https://example.com/upload',
multipart: [
{ body: 'payload', headers: { 'Content-Type': 'text/plain' } },
],
adapter: async options => {
multipart: [{ body: 'payload', headers: { 'Content-Type': 'text/plain' } }],
adapter: async (options) => {
contentType = options.headers.get('content-type') ?? undefined
return {
config: options,
@@ -90,14 +62,14 @@ describe('dependency security overrides', () => {
})
expect(response.status).toBe(200)
expect(contentType).toMatch(/^multipart\/related; boundary=[0-9a-f-]{36}$/)
expect(contentType).toMatch(
/^multipart\/related; boundary=[0-9a-f-]{36}$/,
)
expect(contentType?.split('boundary=')[1]).toMatch(uuidV4Pattern)
})
test('azure identity msal guid generation works through its package context', () => {
const identityRequire = createRequire(
import.meta.resolve('@azure/identity'),
)
const identityRequire = createRequire(import.meta.resolve('@azure/identity'))
const msal = identityRequire('@azure/msal-node') as {
CryptoProvider: new () => { createNewGuid(): string }
}
@@ -106,7 +78,7 @@ describe('dependency security overrides', () => {
expect(cryptoProvider.createNewGuid()).toMatch(uuidV4Pattern)
})
test('remote control markdown renderer resolves streamdown and mermaid', async () => {
test('remote control markdown renderer loads streamdown and mermaid', async () => {
const rcsRequire = createRequire(
join(repoRoot, 'packages/remote-control-server/package.json'),
)
@@ -118,26 +90,13 @@ describe('dependency security overrides', () => {
const uuid = (await import(
pathToFileURL(streamdownRequire.resolve('uuid')).href
)) as { v4(): string }
const mermaidPath = streamdownRequire.resolve('mermaid')
// mermaid does not export ./package.json in its exports map, so resolving
// 'mermaid/package.json' throws ERR_PACKAGE_PATH_NOT_EXPORTED in runtimes
// that honor exports semantics. Walk up from the resolved entry until a
// package.json with name === 'mermaid' is found.
const mermaidPackagePath = await findPackageJson(mermaidPath, 'mermaid')
const mermaidPackage = JSON.parse(
await Bun.file(mermaidPackagePath).text(),
) as {
name?: unknown
exports?: { '.'?: { import?: unknown } }
}
const mermaid = (await import(
pathToFileURL(streamdownRequire.resolve('mermaid')).href
)) as { default?: { initialize?: unknown } }
expect(streamdown.Streamdown).toBeDefined()
expect(uuid.v4()).toMatch(uuidV4Pattern)
expect(mermaidPackage.name).toBe('mermaid')
expect(mermaidPath).toContain('mermaid.core.mjs')
expect(mermaidPackage.exports?.['.']?.import).toBe(
'./dist/mermaid.core.mjs',
)
expect(typeof mermaid.default?.initialize).toBe('function')
})
test('grpc proto-loader keeps its protobuf 7 parser path working', () => {

View File

@@ -1,31 +0,0 @@
/**
* Shared mock for `src/utils/auth.js`. Use it via:
*
* import { authMock } from '../../tests/mocks/auth'
* mock.module('src/utils/auth.js', authMock)
*
* Tests that need different return values can override the helper used by
* the suite (e.g. by extending this object and re-registering with mock.module).
* Always extend here rather than inlining a different shape per test, so the
* surface stays consistent when `auth.ts` exports change.
*/
export const authMock = () => ({
// Mirrors the production contract: src/utils/auth.ts returns
// Promise<boolean> ("did the access token change") and a token object that
// carries scopes, subscriptionType, expiresAt, etc. Tests that branch on
// these values must see the full shape so they can not silently drift away
// from production.
checkAndRefreshOAuthTokenIfNeeded: async () => false,
getClaudeAIOAuthTokens: () => ({
accessToken: 'token',
refreshToken: null,
expiresAt: null,
scopes: ['user:inference'],
subscriptionType: null,
rateLimitTier: null,
}),
isClaudeAISubscriber: () => true,
isProSubscriber: () => false,
isMaxSubscriber: () => false,
isTeamSubscriber: () => false,
})

View File

@@ -30,21 +30,3 @@ export async function createTempSubdir(
await mkdir(path, { recursive: true })
return path
}
/**
* Read a file under the test temp dir as utf-8 text. Mirrors the node:fs
* `readFileSync(path, 'utf-8')` ergonomics but uses Bun's native file API so
* tests stay on the Bun-only runtime contract.
*/
export async function readTempFile(path: string): Promise<string> {
return Bun.file(path).text()
}
/**
* Best-effort existence check for a path under the test temp dir. Uses Bun's
* native file API (works for files; directories return true via Bun.file().exists()
* iff the path resolves — reads directly from the filesystem).
*/
export async function tempPathExists(path: string): Promise<boolean> {
return Bun.file(path).exists()
}