Compare commits

...

11 Commits

Author SHA1 Message Date
c65daba5dd docs(changelog): v2.6.2-delete-guard-and-sse
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:24:25 +00:00
c9e302da37 fix(coder): no-upstream branch alone no longer flags a session at-risk
Session worktree branches (session-<id>) never get an upstream, so the original atRisk rule (unpushed !== 0) flagged every worktree-backed session as at-risk on delete — even pristine ones — forcing a Stash/Force confirm on each. Gate the unpushed arm behind hasUpstream (unpushed !== -1) so the no-upstream sentinel can't trigger it: atRisk = dirty || unmerged > 0 || (hasUpstream && unpushed > 0). No protection is lost — any genuinely unsafe local commit also shows as unmerged > 0 — and the unpushed > 0 arm stays correct for P1.5's pushable worktree branches. unpushed is still reported (-1 = local-only) as informational. Follow-up to 3a26563.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:19:53 +00:00
f69ea5f494 feat(coder): per-session SSE subscriptions (P1.5-a concurrency prereq)
Replace the single global SSE loop (scoped to the most-recently-used worktree directory) with one subscription per live opencode session, each scoped to that session's worktree dir. Two sessions in different worktrees now stream concurrently instead of the second silently dropping the first's events. Each session owns an AbortController (SessionState.sseAbort) wired into subscribe(..., {signal}); the loop reconnects, reconciles (per-session), and is torn down on closeSession/dispose by aborting the signal — which also fixes a latent Phase-1 bug where switching directories left the old runEventLoop parked forever in its for-await (zombie loops). A sessionID demux guard (eventSessionId) drops events that aren't this loop's own, so two sessions sharing a worktree (possible after P1.5-b) don't double-process each other's deltas. Removed sseRunning/sseDirectory/startEventLoop/runEventLoop/reconcileInFlight and the 'SSE directory changed' collision warning. dispatchEvent/handleUpdatedPart (translation, dedup, dcp-strip) and the watchdog are unchanged — only the subscription topology changed. SDK confirmed: @opencode-ai/sdk Event.subscribe opens an independent SSE connection per call, so N concurrent dir-scoped streams are supported. No schema/dispatcher/frontend changes; runExternalAgent untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:15:55 +00:00
3a26563be2 feat(coder): guard session delete against worktree work loss
Deleting a BooChat session CASCADE-wipes its session_worktrees row, which would silently orphan uncommitted/unpushed/unmerged work in the worktree. Add a pre-DELETE gate: the server reads session_worktrees from the shared DB first (no row = chat-only session = delete immediately, zero round-trip), and for worktree-backed sessions calls a new BooCoder endpoint that runs git on the host (only the host systemd service can see /tmp/booworktrees). checkWorktreeWorkAtRisk reports dirty/unpushed/unmerged via the audited hostExec+shellEscape path; default branch is detected from refs/remotes/origin/HEAD (not the worktree's own branch), never hardcoded. Any at-risk worktree returns 409 with per-worktree RiskReport[]; force=true bypasses the check entirely. Fail-closed: coder unreachable/errored also blocks (force still escapes). The sidebar renders a block dialog distinguishing work-at-risk (Commit/Stash/Force) from couldn't-verify (Cancel/Force only); stash uses -u and re-blocks on remaining commits with an explanatory message. Commit never auto-commits — it routes the user to the session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:01:25 +00:00
937920df06 docs(changelog): v2.6.1-phase1-opencode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:42:39 +00:00
e05469c6ae docs(claude): v2.6 Phase 1 opencode learnings — SSE, model resolution, resume
- opencode is now a warm HTTP server (was "planned, unshipped").
- SSE: session.next.* event types + subscribe({directory}) requirement.
- Model strings need llama-swap/ prefix + presence in opencode.json.
- config_hash excludes ephemeral port; session FKs are ON DELETE CASCADE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:40:16 +00:00
0e026be5f8 fix(coder): CASCADE delete on session_worktrees + agent_sessions FKs
Deleting a session with linked session_worktrees or agent_sessions rows
threw a FK violation (500 on DELETE /api/sessions/:id). Both FKs now
ON DELETE CASCADE. Idempotent migration: drops the old constraint and
re-adds with CASCADE only if confdeltype != 'c'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:26:28 +00:00
315cdd23e2 feat: strip dcp-message-id tags from opencode output + reopen closed panes
Two independent fixes:

- opencode-server.ts: stripDcpTags() removes <dcp-message-id>…</dcp-message-id>
  tags from text deltas before they reach the frame/DB. Applied to all three
  text paths (session.next.text.delta, message.part.delta text field,
  handleUpdatedPart text type). Reasoning/tool paths untouched.
- useWorkspacePanes.ts: module-level closedPaneStack (capped at 10) captures
  pane kind + chatIds on removePane and removeTab auto-remove. reopenPane()
  pops the stack and re-attaches a new pane to the existing chat ids (chats
  survive pane close server-side). hasClosedPanes drives conditional render.
- ChatTabBar.tsx: [+] is now instant new-tab (no dropdown); split-pane
  dropdown (Columns2 icon) opens Chat/Term/Code in a new pane; reopen button
  (RotateCcw icon) appears when closed panes exist.
- Workspace.tsx: pass reopenPane + hasClosedPanes through to ChatTabBar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:26:07 +00:00
6d24726c3a feat: add systematic-debugging slash command for BooChat + BooCoder
/data/skills/boocode/systematic-debugging/SKILL.md — guided root-cause
debugging methodology (investigate before fixing). Available as
/systematic-debugging in both BooChat and BooCoder slash menus via the
shared /api/skills endpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:37:51 +00:00
1bbeaf95c7 fix: auto-name uses session model + pane auto-remove on last tab close
Two independent UI/UX fixes:

- auto_name.ts: pass the session's own model as fallbackModel to
  taskModelCompletion, so chat rename uses whatever model is already
  loaded on llama-swap instead of forcing a swap to DEFAULT_MODEL
  (which times out at 10s when a different model is active).
- useWorkspacePanes.ts: when the last tab in a pane is closed and
  other panes exist, remove the pane entirely instead of leaving an
  orphaned empty panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:37:38 +00:00
e30a9e8b23 feat(coder): v2.6 Phase 1 — OpenCode warm server backend
Persistent multi-turn opencode backend: one `opencode serve` HTTP server per
BooCoder process, one opencode session per BooCode session (resumed on
switch-back), single SSE read loop demuxed by session id.

- backends/opencode-server.ts: AgentBackend implementation — spawn with
  waitForReady, session.next.* SSE event translation (text/reasoning/tool
  deltas), Paseo-ported reasoning dedup (streamedPartKeys), promptAsync
  fire-and-forget settled by session.idle, per-turn inactivity watchdog
  (180s) + reconnect reconciliation via session.messages, stale-session
  guard (crashed-not-resumed + config_hash fingerprint on model).
- dispatcher.ts: opencode routes to pool backend (ensureSession→prompt);
  per-session concurrency Map replaces global running boolean (1.9);
  model coalesce (empty→DEFAULT_MODEL) + llama-swap/ prefix for opencode;
  diff-supersede (DELETE+INSERT pending_changes by session, stamp agent).
- worktrees.ts: ensureSessionWorktree (session-keyed, captures base_commit,
  persists to session_worktrees); diffWorktree gains optional baseRef.
- agent-probe.ts: mergeLlamaSwap branch fetches /v1/models, prefixes with
  llama-swap/, populates opencode's available_agents.models (was 0).
- provider-snapshot.ts: export fetchLlamaSwapModels for probe reuse.
- schema.sql: session_worktrees + agent_sessions tables (Phase 0) +
  config_hash column on agent_sessions, pending_changes.agent column.
- package.json: @opencode-ai/sdk ~1.15.0 (resolved 1.15.12).

Known Phase 1 limitation: single SSE stream scoped to most-recent session's
directory; concurrent opencode sessions in different worktrees collide
(warning logged, watchdog prevents hang). Phase 2 moves to per-session SSE.

Smoke 1 verified: two turns in one session, both produce real tokens, same
agent_session_id reused, same server port, turn 2 is 9x faster (no spawn).
goose/qwen/claude paths untouched (runExternalAgent md5 identical).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:37:11 +00:00
22 changed files with 1854 additions and 69 deletions

View File

@@ -2,6 +2,14 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.6.2-delete-guard-and-sse — 2026-05-30
Two coder-side batches under one tag. **Session-delete work-loss guard:** deleting a BooChat session CASCADE-wipes its `session_worktrees` row, which would silently orphan uncommitted/unpushed/unmerged work — so the server's `DELETE /api/sessions/:id` now gates before the delete. It reads `session_worktrees` from the shared DB first (no row → chat-only session → delete immediately, zero round-trip), and for worktree-backed sessions calls a new BooCoder endpoint (`/worktree-risk`) that runs git on the host, since the container can't see `/tmp/booworktrees` — only the host systemd service can. `checkWorktreeWorkAtRisk` reports dirty/unpushed/unmerged via the audited `hostExec`+`shellEscape` path, default branch detected from `refs/remotes/origin/HEAD` (never the worktree's own branch, never hardcoded); any at-risk worktree returns 409 with per-worktree `RiskReport[]`, `force=true` bypasses, and the check is fail-closed (BooCoder unreachable also blocks — force still escapes). The sidebar renders a block dialog distinguishing work-at-risk (Commit/Stash/Force; stash uses `-u` and re-blocks on remaining commits) from couldn't-verify (Cancel/Force), and Commit never auto-commits. A follow-up fix gates the `unpushed` arm behind an actual upstream (`atRisk = dirty || unmerged > 0 || (hasUpstream && unpushed > 0)`) so the no-upstream `session-<id>` branches stop flagging every pristine worktree-backed session — no protection lost, since real local work always also surfaces as `unmerged > 0`. **Per-session SSE (P1.5-a):** replaces the single global SSE loop scoped to the most-recent worktree directory — the known limit flagged in `v2.6.1-phase1-opencode` — with one `event.subscribe({directory})` per live opencode session, so sessions in different worktrees stream concurrently instead of the second silently dropping the first's events. Each session owns an `AbortController` wired into `subscribe(…, {signal})`, which also fixes a latent Phase-1 bug where switching directories left the old loop parked forever in its `for await` (zombie loops); a `sessionID` demux guard drops cross-session events so two sessions sharing a worktree (possible after P1.5-b) don't double-process deltas. The opencode SDK was confirmed to open an independent SSE connection per `subscribe()` call, so N concurrent dir-scoped streams are supported.
## v2.6.1-phase1-opencode — 2026-05-30
v2.6 Phase 1: opencode runs as a warm HTTP server (`apps/coder/src/services/backends/opencode-server.ts`) — one `opencode serve` per BooCoder process, one opencode session per BooCode session resumed across turns via the new `agent_sessions` table, with a single SSE read loop, reasoning dedup ported from Paseo, an inactivity watchdog, and a stale-session guard (crashed-not-resumed + a `config_hash` fingerprint over `opencode_server|<model>`, deliberately excluding the ephemeral server port so cross-restart resume survives). Builds on the `v2.6.0-phase0-foundations` schema/interface scaffold. The batch's hard-won fixes: opencode streams `session.next.*` events (not `message.part.*`), and `event.subscribe()` must pass the session's worktree `directory` or events route to the server CWD and turns come back empty; model strings must be `llama-swap/`-prefixed and present in opencode's own config, with `agent-probe` now populating `available_agents.models` via `mergeLlamaSwap` so the frontend stops sending an empty model; `session_worktrees`/`agent_sessions` FKs are `ON DELETE CASCADE` so session deletion no longer 500s. Also bundled: dcp-message-id tag stripping from opencode text output, a reopen-closed-pane control, the `[+]`/split-pane button separation, auto-name using the session's loaded model, and a `systematic-debugging` slash command. Smoke 1 verified end-to-end (two turns, session reuse, turn 2 ~9x faster). Known Phase 1 limit: one SSE stream scoped to the most-recent session's directory — concurrent opencode sessions in different worktrees collide (warns; per-session SSE is Phase 2).
## v2.5.15-acp-path-guard — 2026-05-29
Security fix + repo hygiene. Fixes a path-traversal in the ACP filesystem bridge (`acp-client-fs.ts`, flagged by the automated push security review): the worktree guard used an unbounded `startsWith(resolve(worktreePath))`, so a sibling path sharing the worktree as a string prefix (`<worktree>-evil/…`) escaped the scope — and `writeWorktreeTextFile` writes to disk directly (no `pending_changes` gate), so a confused/buggy ACP agent could write outside its worktree. Now uses a separator-bounded check matching `write_guard.ts` (`resolve()` + `startsWith(root + sep)` / `=== root`) via a shared `resolveInWorktree`, with a regression test covering `../` traversal and the sibling-prefix bug. Symlink-swap/`O_NOFOLLOW` hardening was intentionally skipped — consistent with `write_guard`'s no-realpath stance, and the agent already runs with host FS access so this is a containment guard, not a trust boundary. Separately, stops tracking the live `data/coder-providers.json` (it's runtime config the UI reads *and writes* on provider toggles, which churned `git status`) — it's now gitignored with a tracked `data/coder-providers.example.json` reference; the loader falls back to built-ins-only when the live file is absent. The provider-type duplication (coder ↔ web) stays guarded by the existing text-identity `provider-types-parity.test.ts` — a shared package was considered and declined (drift is already prevented; not worth the Docker/build-order risk at solo scale).

View File

@@ -89,7 +89,10 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
- **Provider snapshot lifecycle** (`apps/coder/src/services/`): `provider-config.ts` (Zod config, never-throws on bad input) → `provider-config-registry.ts` (`buildResolvedRegistry`, singleton) → `provider-snapshot.ts` (two-tier probe: tier-1 fast presence, tier-2 cold ACP probe skipped unless force / stale `PROVIDER_PROBE_TTL_MS` 24h / dbEmpty; cached). Verify live: `curl http://100.114.205.53:9502/api/providers/snapshot` — returns providers + models + commands, the exact shape `AgentComposerBar` renders.
- `PATCH /api/providers/config` replaces a provider id's override object **wholesale** (per-id shallow merge) — to flip one field send `{...existing, enabled}`, or a custom ACP entry's `command`/`label` is wiped and it drops out of the resolved registry. `data/coder-providers.json` is **gitignored** (it's live runtime config — the coder reads AND writes it on UI toggles); the tracked reference is `data/coder-providers.example.json`. The loader falls back to `{providers:{}}` (built-ins only) when the live file is absent, so a fresh checkout needs no copy.
- External agents dispatch **one-shot** (`opencode acp` / `goose acp` / `qwen --acp`) and report no context-window/token usage; only native `boocode` (llama-swap engine) tracks ctx. OpenCode-as-HTTP-server (warm process + `@opencode-ai/sdk`, the source of a real context bar) is the **planned, unshipped** `openspec/changes/v2-6-persistent-agent-sessions` batch; Paseo's per-provider native clients (design §12) were deliberately not ported.
- **opencode** runs as a warm HTTP server (v2.6 Phase 1, `services/backends/opencode-server.ts``opencode serve` per BooCoder process, one opencode session per BooCode session, resumed via `agent_sessions`). goose/qwen/claude still dispatch **one-shot** ACP/PTY with no ctx/token usage; only native `boocode` (llama-swap engine) tracks ctx. Paseo's per-provider native clients (design §12) deliberately not ported.
- **opencode SSE** (`opencode-server.ts`): live streaming arrives as `session.next.text.delta` / `session.next.reasoning.delta` / `session.next.tool.{called,success,failed}` — NOT `message.part.*` (those are terminal/post-hoc). `client.event.subscribe({ directory })` MUST pass the session's worktree directory; omit it and opencode scopes events to the server's `process.cwd()` → zero session events (empty turns, 180s watchdog timeout). One SSE stream at a time scoped to the last session's dir — concurrent opencode sessions in different worktrees collide (known Phase 1 limit, warns). Turn completes on `session.idle`; `promptAsync` is fire-and-forget (204).
- **opencode model strings** must be provider-prefixed (`llama-swap/<model>`) AND exist in `~/.config/opencode/opencode.json` `provider.llama-swap.models` — not merely loadable by llama-swap. `parseModel` infers `llama-swap/` for a bare id; the dispatcher coalesces empty→DEFAULT_MODEL then prefixes. `agent-probe` populates opencode's `available_agents.models` via `mergeLlamaSwap` (fetches `/v1/models`); empty model list → frontend sends `''` → no inference (`input:0`, empty turn).
- **agent_sessions resume**: `config_hash = sha256('opencode_server|<model>')` — must NOT include the server port (random per boot; including it breaks cross-restart resume). `session_worktrees` + `agent_sessions` FKs to `sessions(id)` are `ON DELETE CASCADE` (else DELETE /api/sessions/:id 500s on FK violation). The `@opencode-ai/sdk` v2 client takes flattened params (`{sessionID, directory, parts, model:{providerID,modelID}}`), imports `createOpencodeClient` from `@opencode-ai/sdk/v2/client`.
### Frontend (`apps/web/src/`)

View File

@@ -16,6 +16,7 @@
"@agentclientprotocol/sdk": "^0.22.1",
"@boocode/server": "workspace:*",
"@fastify/static": "^7.0.4",
"@opencode-ai/sdk": "~1.15.0",
"@fastify/websocket": "^10.0.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"fastify": "^4.28.1",

View File

@@ -30,6 +30,7 @@ import { registerInboxRoutes } from './routes/inbox.js';
import { registerStatsRoutes } from './routes/stats.js';
import { registerArenaRoutes } from './routes/arena.js';
import { registerProviderRoutes } from './routes/providers.js';
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
import { registerWebSocket } from './routes/ws.js';
// Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js';
@@ -195,6 +196,7 @@ async function main() {
registerStatsRoutes(app, sql);
registerArenaRoutes(app, sql);
registerProviderRoutes(app, sql, config);
registerWorktreeSafetyRoutes(app, sql);
registerWebSocket(app, sql, broker);
// Serve static frontend (built web app). In production, the dist/ is

View File

@@ -0,0 +1,45 @@
/**
* Session-delete work-loss guard (coder side).
*
* Session delete itself lives in apps/server (Docker), which CANNOT see the
* host worktree dirs (/tmp/booworktrees) or run git on them. Only BooCoder
* (host systemd) can. So the server's DELETE route calls these endpoints
* pre-delete to learn whether a session's worktree holds work at risk, and to
* stash it. The server owns the gate; coder owns the git truth.
*/
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import { checkWorktreeWorkAtRisk, stashWorktree } from '../services/worktrees.js';
export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): void {
// GET risk for a session's worktree(s). One row per session today (PK on
// session_id); the loop already handles the Phase-1.5 multi-worktree case.
app.get<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/worktree-risk',
async (req) => {
const rows = await sql<{ worktree_path: string }[]>`
SELECT worktree_path FROM session_worktrees WHERE session_id = ${req.params.sessionId}
`;
const reports = [];
for (const row of rows) {
reports.push(await checkWorktreeWorkAtRisk(row.worktree_path));
}
return { reports };
},
);
// Stash a session's worktree(s) — clears the dirty risk; recoverable.
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/worktree-stash',
async (req) => {
const rows = await sql<{ worktree_path: string }[]>`
SELECT worktree_path FROM session_worktrees WHERE session_id = ${req.params.sessionId}
`;
const results = [];
for (const row of rows) {
results.push({ worktreePath: row.worktree_path, ...(await stashWorktree(row.worktree_path)) });
}
return { results };
},
);
}

View File

@@ -78,15 +78,27 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
-- v2.6: one shared worktree per session (all agents/panes in the session operate in it).
CREATE TABLE IF NOT EXISTS session_worktrees (
session_id UUID PRIMARY KEY REFERENCES sessions(id),
session_id UUID PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
worktree_path TEXT NOT NULL,
base_commit TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
-- Migrate existing FK to CASCADE (idempotent: drops the old constraint if present).
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'session_worktrees_session_id_fkey'
AND confdeltype <> 'c'
) THEN
ALTER TABLE session_worktrees DROP CONSTRAINT session_worktrees_session_id_fkey;
ALTER TABLE session_worktrees ADD CONSTRAINT session_worktrees_session_id_fkey
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
END IF;
END $$;
-- v2.6: one backend session per (session, agent); resumed on switch-back.
CREATE TABLE IF NOT EXISTS agent_sessions (
session_id UUID NOT NULL REFERENCES sessions(id),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
agent TEXT NOT NULL,
backend TEXT NOT NULL,
agent_session_id TEXT,
@@ -99,6 +111,22 @@ CREATE TABLE IF NOT EXISTS agent_sessions (
CONSTRAINT agent_sessions_status_chk CHECK (status IN ('idle', 'active', 'crashed', 'closed'))
);
-- Migrate existing agent_sessions FK to CASCADE.
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'agent_sessions_session_id_fkey'
AND confdeltype <> 'c'
) THEN
ALTER TABLE agent_sessions DROP CONSTRAINT agent_sessions_session_id_fkey;
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
END IF;
END $$;
-- v2.6: config fingerprint for stale-session detection (auto-recover on model change).
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT;
-- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this).
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;

View File

@@ -4,7 +4,7 @@ import { exec as execCb, execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { PROVIDERS_BY_NAME } from './provider-registry.js';
import { resolveAcpProbeBinaries } from './acp-spawn.js';
import { clearProviderSnapshotCache } from './provider-snapshot.js';
import { clearProviderSnapshotCache, fetchLlamaSwapModels, prefixLlamaSwapModels } from './provider-snapshot.js';
import { readQwenSettingsModels } from './qwen-settings.js';
import { loadConfig } from '../config.js';
import { loadProviderConfig } from './provider-config-registry.js';
@@ -117,6 +117,15 @@ export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<voi
if (agentName === 'qwen') {
models = await readQwenSettingsModels();
}
if (providerDef?.mergeLlamaSwap) {
try {
const config = loadConfig();
const llamaModels = prefixLlamaSwapModels(await fetchLlamaSwapModels(config));
models = [...models, ...llamaModels];
} catch (err) {
log.warn({ agent: agentName, err: err instanceof Error ? err.message : String(err) }, 'agent-probe: llama-swap model fetch failed (non-fatal)');
}
}
}
const label = resolved.configLabel ?? resolved.label;

View File

@@ -0,0 +1,777 @@
/**
* v2.6 Phase 1 — OpenCodeServerBackend.
*
* Warm, multi-turn backend for the `opencode` agent. One `opencode serve` HTTP
* server per BooCoder process; one opencode session per BooCode session (resumed
* on switch-back); one SSE read loop PER session, each scoped to that session's
* worktree directory so sessions in different directories stream concurrently
* (P1.5-a — replaced the Phase-1 single-stream-last-directory model).
*
* Implements the Phase 0 `AgentBackend` interface. Emits transport-agnostic
* `AgentEvent`s — the dispatcher (Phase 1.7, NOT wired in this batch) maps them
* to WS frames. No dispatcher/route references this file yet.
*
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2 / §2a.
* SDK shapes verified by direct read of @opencode-ai/sdk@1.15.12 dist .d.ts:
* - client methods take FLATTENED params (sessionID/directory/body all inline),
* not {path,query,body}. create→{directory}, promptAsync→{sessionID,directory,
* parts,model}, abort→{sessionID,directory}. model is {providerID,modelID}.
* - client.event() resolves to { stream: AsyncGenerator<GlobalEvent> }; the
* real event is chunk.payload (discriminate on chunk.payload.type).
* - promptAsync is fire-and-forget (204); the turn completes via a
* 'session.idle' event for that opencode session id.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { createHash } from 'node:crypto';
import { createServer } from 'node:net';
import type { FastifyBaseLogger } from 'fastify';
import {
createOpencodeClient,
type OpencodeClient,
type Event,
type Part,
type ToolPart,
type ToolState,
type AssistantMessage,
} from '@opencode-ai/sdk/v2/client';
import type { ToolCallStatus } from '@agentclientprotocol/sdk';
import type { Sql } from '../../db.js';
import type { AcpToolSnapshot } from '../acp-tool-snapshot.js';
import type {
AgentBackend,
AgentEvent,
AgentSessionHandle,
EnsureSessionOpts,
PromptCtx,
TurnResult,
} from '../agent-backend.js';
const READY_TIMEOUT_MS = 30_000;
const SSE_RECONNECT_DELAY_MS = 1_000;
/**
* No-activity backstop for an in-flight turn. opencode streams reasoning/text/tool
* deltas continuously while working, so "zero events for this long" means the turn
* is wedged or its terminal event (session.idle) was lost (see the reconnect race
* below). Generous so a legitimately slow turn never trips it.
*/
const TURN_INACTIVITY_MS = 180_000;
/** One in-flight turn's emitter + completion settler. */
interface TurnState {
onEvent: (e: AgentEvent) => void;
settle: (r: TurnResult) => void;
}
/** Per-(opencode session) demux state. dedup sets scoped here, cleared per turn. */
interface SessionState {
boocodeSessionId: string;
agentSessionId: string;
/** Worktree directory for SDK `directory` routing; refreshed each turn from ctx. */
worktreePath: string;
/** dedup gate: `${type}:${id}` added on delta, deleted-and-tested on updated. Cleared at turn end. */
streamedPartKeys: Set<string>;
/** partID → 'text' | 'reasoning', so a delta with a non-'reasoning' field is still classed right. Cleared at turn end. */
partTypeById: Map<string, string>;
activeTurn: TurnState | null;
/** Inactivity backstop timer for the active turn; null when no turn in flight. */
watchdog: ReturnType<typeof setTimeout> | null;
/** Per-session SSE subscription handle. Non-null while the loop is running;
* aborting it tears down the underlying fetch and exits the loop. */
sseAbort: AbortController | null;
}
export interface OpenCodeServerBackendDeps {
sql: Sql;
log: FastifyBaseLogger;
/** Absolute path to the opencode binary (resolved from available_agents at wiring time, Phase 1.7). */
opencodeBinary: string;
}
export class OpenCodeServerBackend implements AgentBackend {
readonly backend = 'opencode_server' as const;
private readonly sql: Sql;
private readonly log: FastifyBaseLogger;
private readonly opencodeBinary: string;
private child: ChildProcess | null = null;
private client: OpencodeClient | null = null;
private port: number | null = null;
private up = false;
private serverStarting: Promise<void> | null = null;
/** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */
private readonly byOpencodeId = new Map<string, SessionState>();
constructor(deps: OpenCodeServerBackendDeps) {
this.sql = deps.sql;
this.log = deps.log;
this.opencodeBinary = deps.opencodeBinary;
}
/** §2: liveness for the health endpoint + dispatcher fallback decision. */
health(): 'up' | 'down' {
return this.up ? 'up' : 'down';
}
// ─── Server lifecycle (1.2: spawn once + client + ready) ─────────────────────
/** Lazy: start the single server on first use. Idempotent — one server per backend. */
private ensureServer(): Promise<void> {
if (!this.serverStarting) this.serverStarting = this.startServer();
return this.serverStarting;
}
private async startServer(): Promise<void> {
const port = await freePort();
// Phase 1: run unsecured on loopback (opencode's documented default — serve.ts
// only WARNS when OPENCODE_SERVER_PASSWORD is unset). The real boundary is the
// 127.0.0.1 bind. Defense-in-depth basic-auth is deferred: the hey-api client's
// auth wiring + opencode's exact scheme must be confirmed against a live server
// first, else every request 401s. Recon explicitly said "do NOT block on it".
const child = spawn(this.opencodeBinary, ['serve', '--hostname', '127.0.0.1', '--port', String(port)], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env },
});
this.child = child;
this.port = port;
// Child lifetime is the backend's (the pool's), NOT a request's. We never tie
// it to a per-turn abort signal. On unexpected exit we mark down + log; crash
// recovery is Phase 3.
child.on('exit', (code, signal) => {
this.up = false;
this.log.warn({ code, signal, port }, 'opencode-server: child exited (recovery is Phase 3)');
});
await waitForReady(child, READY_TIMEOUT_MS);
this.client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` });
this.up = true;
this.log.info({ port }, 'opencode-server: ready');
}
// ─── SSE read loop + demux + translate (1.3) + dedup (1.4) ───────────────────
/** Per-session SSE subscription, scoped to the session's worktree directory.
* opencode scopes events by the `directory` query param (defaults to the
* server's cwd if omitted), so two sessions in different worktrees each get
* their own dir-scoped stream and never drop each other's events. Idempotent:
* a no-op if this session's loop is already running. Started from ensureSession
* (and defensively from prompt) once worktreePath is known. */
private startSessionEventLoop(state: SessionState): void {
if (state.sseAbort) return; // already running
const abort = new AbortController();
state.sseAbort = abort;
void this.runSessionEventLoop(state, abort).finally(() => {
// Only clear if this controller is still the live one (a later restart may
// have already installed a new one).
if (state.sseAbort === abort) state.sseAbort = null;
});
}
private async runSessionEventLoop(state: SessionState, abort: AbortController): Promise<void> {
const signal = abort.signal;
while (this.up && this.client && !signal.aborted) {
try {
// Re-read worktreePath each (re)subscribe so a directory refresh is picked
// up on reconnect. Passing `signal` lets close/dispose tear down a stream
// that's parked in `for await` between events.
const sub = await this.client.event.subscribe(
{ directory: state.worktreePath },
{ signal },
);
for await (const ev of sub.stream) {
if (signal.aborted) break;
// Dir-scoped streams should only carry this session's events, but two
// sessions sharing a worktree (possible post-P1.5-b) each receive BOTH
// sessions' events — so drop anything that isn't ours, else the other
// session's deltas get processed twice (once per loop).
const sid = eventSessionId(ev);
if (sid != null && sid !== state.agentSessionId) continue;
this.dispatchEvent(ev);
}
if (this.up && !signal.aborted) {
await this.reconcile(state); // recover an idle/error lost during the gap
await sleep(SSE_RECONNECT_DELAY_MS);
}
} catch (err) {
if (!this.up || signal.aborted) break;
this.log.warn(
{ err: errMsg(err), agentSessionId: state.agentSessionId },
'opencode-server: session event loop error; reconnecting',
);
await this.reconcile(state);
await sleep(SSE_RECONNECT_DELAY_MS);
}
}
}
/** Demux one event to the owning session's active turn. Unknown/between-turns → drop. */
private dispatchEvent(ev: Event): void {
switch (ev.type) {
// ─── session.next.* — live streaming events (the primary path) ─────────
case 'session.next.text.delta': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
const cleaned = stripDcpTags(p.delta);
if (cleaned) st.activeTurn.onEvent({ type: 'text', text: cleaned });
return;
}
case 'session.next.reasoning.delta': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
st.activeTurn.onEvent({ type: 'reasoning', text: p.delta });
return;
}
case 'session.next.tool.called': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
const snap: AcpToolSnapshot = {
toolCallId: p.callID,
title: p.tool,
kind: null,
status: 'in_progress',
rawInput: p.input,
rawOutput: undefined,
};
st.activeTurn.onEvent({ type: 'tool_call', toolCall: snap });
return;
}
case 'session.next.tool.success': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
const output = p.content?.map((c) => ('text' in c ? (c as { text: string }).text : '')).join('') ?? '';
const snap: AcpToolSnapshot = {
toolCallId: p.callID,
title: p.callID,
kind: null,
status: 'completed',
rawInput: undefined,
rawOutput: output,
};
st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap });
return;
}
case 'session.next.tool.failed': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
const snap: AcpToolSnapshot = {
toolCallId: p.callID,
title: p.callID,
kind: null,
status: 'failed',
rawInput: undefined,
rawOutput: errToString(p.error),
};
st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap });
return;
}
// ─── message.part.* — terminal/post-hoc events (dedup gate) ────────────
case 'message.part.delta': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
const isReasoning = p.field === 'reasoning' || st.partTypeById.get(p.partID) === 'reasoning';
if (isReasoning) {
st.streamedPartKeys.add(`reasoning:${p.partID}`);
st.activeTurn.onEvent({ type: 'reasoning', text: p.delta });
} else if (p.field === 'text') {
st.streamedPartKeys.add(`text:${p.partID}`);
const cleaned = stripDcpTags(p.delta);
if (cleaned) st.activeTurn.onEvent({ type: 'text', text: cleaned });
}
return;
}
case 'message.part.updated': {
const part = ev.properties.part;
const st = this.byOpencodeId.get(part.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
this.handleUpdatedPart(part, st);
return;
}
// ─── lifecycle ─────────────────────────────────────────────────────────
case 'session.idle': {
this.byOpencodeId.get(ev.properties.sessionID)?.activeTurn?.settle({ ok: true });
return;
}
case 'session.error': {
const sid = ev.properties.sessionID;
if (!sid) return;
this.byOpencodeId.get(sid)?.activeTurn?.settle({ ok: false, error: errToString(ev.properties.error) });
return;
}
default:
return;
}
}
/** Terminal part: dedup gate for text/reasoning; tool parts → tool_call/tool_update. */
private handleUpdatedPart(part: Part, st: SessionState): void {
const turn = st.activeTurn;
if (!turn) return;
if (part.type === 'text' || part.type === 'reasoning') {
st.partTypeById.set(part.id, part.type);
const key = resolvePartDedupeKey(part, part.type);
if (key && st.streamedPartKeys.delete(key)) return; // already streamed via delta
const raw = part.text ?? '';
const text = part.type === 'text' ? stripDcpTags(raw) : raw;
if (text && part.time?.end != null) {
turn.onEvent({ type: part.type, text });
}
return;
}
if (part.type === 'tool') {
const snap = toolPartToSnapshot(part);
const status = part.state?.status;
// tool_call on start (pending/running), tool_update on terminal (completed/error).
// The current ACP path merges both into one frame; the contract keeps them
// distinct because opencode's SSE distinguishes start from result.
const event: AgentEvent =
status === 'completed' || status === 'error'
? { type: 'tool_update', toolCall: snap }
: { type: 'tool_call', toolCall: snap };
turn.onEvent(event);
return;
}
// NOTE: opencode's SSE payload union carries no available-commands event, so the
// AgentEvent 'commands' arm is intentionally never emitted here (1.3).
}
// ─── turn-completion resilience (watchdog + reconnect reconcile) ─────────────
/** Reset the inactivity backstop on any event routed to a session's active turn. */
private bumpActivity(st: SessionState): void {
if (!st.activeTurn) return;
if (st.watchdog) clearTimeout(st.watchdog);
st.watchdog = setTimeout(() => {
void this.onTurnStall(st);
}, TURN_INACTIVITY_MS);
st.watchdog.unref?.();
}
/** Watchdog fired: reconcile once; if the server says still-running we can't tell, so fail closed.
* Also mark the agent_sessions row crashed so a stale session isn't resumed next turn. */
private async onTurnStall(st: SessionState): Promise<void> {
const settled = await this.reconcile(st);
if (!settled) {
this.log.warn({ agentSessionId: st.agentSessionId }, 'opencode-server: turn stalled (no activity), failing + marking crashed');
await this.sql`
UPDATE agent_sessions SET status = 'crashed'
WHERE agent_session_id = ${st.agentSessionId}
`.catch(() => {});
st.activeTurn?.settle({ ok: false, error: 'turn timed out (no activity)' });
}
}
/**
* Ask the server whether this session's turn already finished — recovers a
* session.idle/error lost during an SSE gap. Returns true if it settled the turn.
* Inconclusive (still running / call failed) → false; the watchdog covers that.
*/
private async reconcile(st: SessionState): Promise<boolean> {
const turn = st.activeTurn;
if (!turn || !this.client) return false;
try {
const res = await this.client.session.messages({
sessionID: st.agentSessionId,
directory: st.worktreePath,
});
if (res.error || !res.data) return false;
let lastAssistant: AssistantMessage | undefined;
for (let i = res.data.length - 1; i >= 0; i--) {
const info = res.data[i]!.info;
if (info.role === 'assistant') {
lastAssistant = info;
break;
}
}
if (!lastAssistant) return false;
if (lastAssistant.error != null) {
turn.settle({ ok: false, error: errToString(lastAssistant.error) });
return true;
}
if (lastAssistant.time.completed != null) {
turn.settle({ ok: true });
return true;
}
return false; // still running — the live stream will deliver session.idle
} catch {
return false; // inconclusive — watchdog backstop covers it
}
}
// ─── ensureSession: create-or-resume against agent_sessions (1.5) ────────────
async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> {
await this.ensureServer();
if (!this.client) throw new Error('opencode-server: client not ready after ensureServer');
const configHash = sessionConfigHash(opts.model);
const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>`
SELECT agent_session_id, status, config_hash FROM agent_sessions
WHERE session_id = ${sessionId} AND agent = ${opts.agent}
`;
let agentSessionId = row?.agent_session_id ?? null;
// Don't resume crashed sessions or sessions whose config drifted (model change).
const shouldResume = agentSessionId
&& row!.status !== 'crashed'
&& (row!.config_hash == null || row!.config_hash === configHash);
if (!shouldResume) {
if (agentSessionId) {
this.log.info({ sessionId, oldStatus: row!.status, hashMatch: row!.config_hash === configHash },
'opencode-server: not resuming stale session, creating fresh');
this.byOpencodeId.delete(agentSessionId);
}
const created = await this.client.session.create({ directory: opts.worktreePath });
if (created.error || !created.data) {
throw new Error(`opencode-server: session.create failed: ${errToString(created.error)}`);
}
agentSessionId = created.data.id;
await this.sql`
INSERT INTO agent_sessions
(session_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
VALUES
(${sessionId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
ON CONFLICT (session_id, agent) DO UPDATE SET
backend = 'opencode_server',
agent_session_id = EXCLUDED.agent_session_id,
server_port = EXCLUDED.server_port,
status = 'active',
last_active_at = clock_timestamp(),
config_hash = EXCLUDED.config_hash
`;
} else {
await this.sql`
UPDATE agent_sessions
SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash}
WHERE session_id = ${sessionId} AND agent = ${opts.agent}
`;
}
// Both branches above guarantee agentSessionId is non-null.
const ocSessionId = agentSessionId!;
// Register / refresh the demux entry the SSE loop keys on. Preserve an existing
// entry (and any in-flight turn) — just refresh the routing fields.
let state = this.byOpencodeId.get(ocSessionId);
if (state) {
state.boocodeSessionId = sessionId;
state.worktreePath = opts.worktreePath;
} else {
state = {
boocodeSessionId: sessionId,
agentSessionId: ocSessionId,
worktreePath: opts.worktreePath,
streamedPartKeys: new Set(),
partTypeById: new Map(),
activeTurn: null,
watchdog: null,
sseAbort: null,
};
this.byOpencodeId.set(ocSessionId, state);
}
// Start this session's own SSE loop, scoped to its worktree directory. Both
// fresh-create and resume reach here; idempotent, so a re-ensure (e.g. a
// second turn) won't spawn a duplicate loop.
this.startSessionEventLoop(state);
return {
sessionId,
agent: opts.agent,
backend: 'opencode_server',
agentSessionId: ocSessionId,
serverPort: this.port,
};
}
// ─── prompt: send one turn (1.6) ─────────────────────────────────────────────
async prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult> {
if (!this.client) throw new Error('opencode-server: client not ready');
const oc = handle.agentSessionId;
if (!oc) throw new Error('opencode-server: handle has no agentSessionId');
let state = this.byOpencodeId.get(oc);
if (!state) {
state = {
boocodeSessionId: handle.sessionId,
agentSessionId: oc,
worktreePath: ctx.worktreePath,
streamedPartKeys: new Set(),
partTypeById: new Map(),
activeTurn: null,
watchdog: null,
sseAbort: null,
};
this.byOpencodeId.set(oc, state);
}
const session = state;
// Authoritative per-turn directory for SDK routing + reconcile.
session.worktreePath = ctx.worktreePath;
// Defensive: ensureSession normally starts the loop, but if prompt is reached
// with a freshly-created state (no loop yet), start it so the turn streams.
// Idempotent when ensureSession already started one.
this.startSessionEventLoop(session);
const client = this.client;
return await new Promise<TurnResult>((resolve) => {
let settled = false;
const cleanup = () => {
session.activeTurn = null;
if (session.watchdog) {
clearTimeout(session.watchdog);
session.watchdog = null;
}
session.streamedPartKeys.clear();
session.partTypeById.clear();
ctx.signal.removeEventListener('abort', onAbort);
};
const settle = (r: TurnResult) => {
if (settled) return;
settled = true;
cleanup();
resolve(r);
};
const onAbort = () => {
// Abort the turn only — never the server.
client.session.abort({ sessionID: oc, directory: ctx.worktreePath }).catch(() => {});
settle({ ok: false, error: 'aborted' });
};
session.activeTurn = { onEvent: ctx.onEvent, settle };
this.bumpActivity(session); // arm the inactivity backstop
if (ctx.signal.aborted) {
onAbort();
return;
}
ctx.signal.addEventListener('abort', onAbort, { once: true });
const model = parseModel(ctx.model);
client.session
.promptAsync({
sessionID: oc,
directory: ctx.worktreePath,
parts: [{ type: 'text', text: input }],
...(model ? { model } : {}),
})
.then((res) => {
// promptAsync is fire-and-forget (204); the turn completes via session.idle.
// Only a submission error settles here.
if (res.error) settle({ ok: false, error: errToString(res.error) });
})
.catch((err) => settle({ ok: false, error: errMsg(err) }));
});
}
// ─── teardown ────────────────────────────────────────────────────────────────
async closeSession(handle: AgentSessionHandle): Promise<void> {
if (handle.agentSessionId) {
// Stop this session's SSE loop before dropping its demux entry.
this.byOpencodeId.get(handle.agentSessionId)?.sseAbort?.abort();
this.byOpencodeId.delete(handle.agentSessionId);
}
await this.sql`
UPDATE agent_sessions SET status = 'closed'
WHERE session_id = ${handle.sessionId} AND agent = ${handle.agent}
`.catch(() => {});
}
async dispose(): Promise<void> {
this.up = false;
// Abort every per-session SSE loop so none survive the teardown.
for (const st of this.byOpencodeId.values()) st.sseAbort?.abort();
const child = this.child;
this.child = null;
this.client = null;
this.byOpencodeId.clear();
if (child && !child.killed) {
child.kill('SIGTERM');
const t = setTimeout(() => {
if (!child.killed) child.kill('SIGKILL');
}, 5_000);
t.unref();
}
}
}
// ─── helpers ──────────────────────────────────────────────────────────────────
/** Extract the opencode sessionID an event belongs to, across event shapes.
* Most carry `properties.sessionID`; `message.part.updated` nests it under
* `properties.part.sessionID`. Returns null when the event has no session
* (the per-session loop then leaves it to dispatchEvent, which drops it). */
function eventSessionId(ev: Event): string | null {
const props = (ev as { properties?: unknown }).properties;
if (!props || typeof props !== 'object') return null;
if (ev.type === 'message.part.updated') {
const part = (props as { part?: { sessionID?: string } }).part;
return part?.sessionID ?? null;
}
return (props as { sessionID?: string }).sessionID ?? null;
}
/** BooCoder model string "provider/model" → opencode's structured {providerID, modelID}. */
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
if (!model || !model.trim()) return undefined;
const trimmed = model.trim();
const idx = trimmed.indexOf('/');
if (idx > 0 && idx < trimmed.length - 1) {
return { providerID: trimmed.slice(0, idx), modelID: trimmed.slice(idx + 1) };
}
// No slash but non-empty → infer llama-swap (the only configured provider).
// Guard against bare '/' or trailing/leading slash.
if (idx < 0 && trimmed.length > 0) {
return { providerID: 'llama-swap', modelID: trimmed };
}
return undefined;
}
/** Ported verbatim from Paseo opencode-agent.ts: id → message-id fallback → null. */
function resolvePartDedupeKey(part: { id: string; messageID: string }, type: string): string | null {
if (part.id.trim().length > 0) return `${type}:${part.id}`;
if (part.messageID.trim().length > 0) return `${type}:message:${part.messageID}`;
return null;
}
/** opencode ToolPart → ACP-shaped snapshot (reuses the existing persist/render path). */
function toolPartToSnapshot(part: ToolPart): AcpToolSnapshot {
const state = part.state;
let rawInput: unknown;
let rawOutput: unknown;
let title: string | undefined;
if (state) {
if ('input' in state) rawInput = (state as { input?: unknown }).input;
if ('output' in state) rawOutput = (state as { output?: unknown }).output;
else if ('error' in state) rawOutput = (state as { error?: unknown }).error;
if ('title' in state) title = (state as { title?: string }).title;
}
return {
toolCallId: part.callID,
title: title ?? part.tool,
kind: null,
status: mapToolStatus(state?.status),
rawInput,
rawOutput,
};
}
function mapToolStatus(s: ToolState['status'] | undefined): ToolCallStatus | null {
switch (s) {
case 'pending':
return 'pending';
case 'running':
return 'in_progress';
case 'completed':
return 'completed';
case 'error':
return 'failed';
default:
return null;
}
}
/** Bind-probe an ephemeral port on loopback. */
function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = createServer();
srv.unref();
srv.on('error', reject);
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address();
if (addr && typeof addr === 'object') {
const { port } = addr;
srv.close(() => resolve(port));
} else {
srv.close(() => reject(new Error('opencode-server: could not determine a free port')));
}
});
});
}
/** Resolve when the child prints the ready line; reject on timeout or early exit. */
function waitForReady(child: ChildProcess, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
let done = false;
let stderrBuf = '';
const finish = (err?: Error) => {
if (done) return;
done = true;
clearTimeout(timer);
child.stdout?.off('data', onOut);
child.stderr?.off('data', onErr);
child.off('exit', onExit);
if (err) reject(err);
else resolve();
};
const onOut = (buf: Buffer) => {
if (buf.toString().includes('opencode server listening on')) finish();
};
const onErr = (buf: Buffer) => {
stderrBuf += buf.toString();
};
const onExit = (code: number | null) =>
finish(new Error(`opencode serve exited before ready (code ${code}); stderr: ${stderrBuf.slice(-2000)}`));
const timer = setTimeout(
() => finish(new Error(`opencode serve not ready in ${timeoutMs}ms; stderr: ${stderrBuf.slice(-2000)}`)),
timeoutMs,
);
child.stdout?.on('data', onOut);
child.stderr?.on('data', onErr);
child.on('exit', onExit);
});
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
/** Strip opencode-dcp plugin tags that render as literal text in the UI. */
function stripDcpTags(s: string): string {
return s.replace(/<dcp-message-id>[^<]*<\/dcp-message-id>/g, '');
}
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
function errToString(e: unknown): string {
if (e == null) return 'unknown error';
if (typeof e === 'string') return e;
if (e instanceof Error) return e.message;
try {
return JSON.stringify(e);
} catch {
return String(e);
}
}
/** Hash of stable config — detects model changes across sessions without
* invalidating on ephemeral state like the random server port (which changes
* every BooCoder restart). */
function sessionConfigHash(model: string): string {
return createHash('sha256').update(`opencode_server|${model}`).digest('hex').slice(0, 16);
}

View File

@@ -3,13 +3,17 @@ import type { FastifyBaseLogger } from 'fastify';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
import { dispatchViaAcp } from './acp-dispatch.js';
import { getResolvedRegistry } from './provider-config-registry.js';
import { dispatchViaPty } from './pty-dispatch.js';
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
import { getManifestCommands } from './provider-commands.js';
import { persistExternalAgentTurn } from './agent-turn-persist.js';
import { snapshotToWireToolCall, type AcpToolSnapshot } from './acp-tool-snapshot.js';
import { agentPool } from './agent-pool.js';
import { OpenCodeServerBackend } from './backends/opencode-server.js';
import type { AgentBackend, AgentEvent } from './agent-backend.js';
interface InferenceRunner {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
@@ -35,47 +39,65 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
const { sql, inference, broker, log, config } = deps;
let timer: ReturnType<typeof setInterval> | null = null;
let listener: { unlisten: () => Promise<void> } | null = null;
let running = false;
let polling = false;
let stopping = false;
let inflightPromise: Promise<void> | null = null;
// v2.6 (1.9): per-session in-flight registry replaces the global `running`
// boolean. Key = session_id (or `task:<id>` for sessionless tasks). Sessions
// without an in-flight turn run concurrently; within a session, strictly one
// turn at a time.
const inflight = new Map<string, Promise<void>>();
// Shared entry point for both the poll timer and the NOTIFY listener. poll()'s
// `running`/`stopping` guard makes this safe to call concurrently — a notify
// arriving mid-task returns immediately and never double-dispatches.
// `polling`/`stopping` guard makes this safe to call concurrently — a notify
// arriving mid-poll returns immediately and never double-dispatches.
function triggerPoll(reason: string): void {
poll().catch((err) => {
log.error({ err, reason }, 'dispatcher: poll error');
});
}
function concurrencyKey(task: { id: string; session_id: string | null }): string {
return task.session_id ?? `task:${task.id}`;
}
async function poll(): Promise<void> {
if (running || stopping) return;
// Grab one pending task
const rows = await sql<{
id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
}[]>`
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
FROM tasks
WHERE state = 'pending'
ORDER BY created_at
LIMIT 1
`;
if (rows.length === 0) return;
const task = rows[0]!;
running = true;
inflightPromise = runTask(task).finally(() => {
running = false;
inflightPromise = null;
});
// `polling` serializes poll() execution itself (timer + NOTIFY can fire
// concurrently) so we never double-select a task. It does NOT serialize task
// execution — that's what `inflight` (keyed per session) governs.
if (polling || stopping) return;
polling = true;
try {
// Oldest-first; start every pending task whose session isn't already busy.
const rows = await sql<{
id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
}[]>`
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
FROM tasks
WHERE state = 'pending'
ORDER BY created_at
LIMIT 50
`;
for (const task of rows) {
if (stopping) break;
const key = concurrencyKey(task);
if (inflight.has(key)) continue; // this session already has an in-flight turn
// Register synchronously (before any await) so a later row in this pass
// with the same key is skipped and a concurrent poll can't re-pick it.
const p = runTask(task).finally(() => {
inflight.delete(key);
});
inflight.set(key, p);
}
} finally {
polling = false;
}
}
async function runTask(task: {
@@ -96,7 +118,13 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent}
`;
if (agentRow) {
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
// v2.6 (1.7): opencode routes to the warm pool backend; every other
// external agent keeps the existing one-shot ACP/PTY path untouched.
if (task.agent === 'opencode') {
await runOpenCodeServerTask(task, agentRow.install_path);
} else {
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
}
return;
}
// Agent specified but not available — fall through to Path A with a warning
@@ -456,6 +484,274 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
}
}
// ─── Path B (opencode): warm OpenCode server backend (v2.6 1.7 + 1.10) ───────
// OpenCode runs ONE server per BooCoder process, shared across all sessions
// (the backend multiplexes sessions internally), so it's pooled under a fixed
// key rather than per-session. Warm ACP backends (Phase 2) will be per-session.
const OPENCODE_POOL_KEY = '__opencode_server__';
function getOpenCodeBackend(installPath: string | null): AgentBackend {
let backend = agentPool.get(OPENCODE_POOL_KEY, 'opencode');
if (!backend) {
backend = new OpenCodeServerBackend({ sql, log, opencodeBinary: installPath ?? 'opencode' });
agentPool.register(OPENCODE_POOL_KEY, 'opencode', backend);
}
return backend;
}
async function runOpenCodeServerTask(
task: {
id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
},
installPath: string | null,
): Promise<void> {
const taskId = task.id;
const agent = 'opencode';
log.info({ taskId, agent }, 'dispatcher: starting task (path B — opencode server)');
const [project] = await sql<{ path: string | null }[]>`
SELECT path FROM projects WHERE id = ${task.project_id}
`;
const projectPath = project?.path;
if (!projectPath) {
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no path — cannot create worktree'
WHERE id = ${taskId}
`;
return;
}
const ac = new AbortController();
try {
// execution_path = 'acp' — the schema CHECK has no 'opencode_server' value
// (schema is frozen at Phase 0); the warm-vs-one-shot distinction lives in
// agent_sessions.backend. Reuse the closest existing value.
await sql`
UPDATE tasks
SET state = 'running', started_at = clock_timestamp(), execution_path = 'acp'
WHERE id = ${taskId}
`;
// Resolve session + chat (mirrors runExternalAgent).
let sessionId: string;
let chatId: string;
if (task.session_id) {
sessionId = task.session_id;
const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
`;
if (chats.length === 0) {
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'External agent execution', 'open')
RETURNING id
`;
chatId = chat!.id;
} else {
chatId = chats[0]!.id;
}
} else {
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`;
const [session] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, status)
VALUES (${task.project_id}, ${sessionName}, ${task.model ?? config.DEFAULT_MODEL}, 'open')
RETURNING id
`;
sessionId = session!.id;
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'External agent execution', 'open')
RETURNING id
`;
chatId = chat!.id;
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
}
if (!task.session_id) {
await sql`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
`;
}
// Persistent, session-keyed worktree (shared across turns; NOT torn down
// per turn — Phase 3 reaps it). Captures base_commit for a stable diff.
const { worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
signal: ac.signal,
});
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
const assistantId = assistantMsg!.id;
broker.publishFrame(sessionId, {
type: 'message_started',
message_id: assistantId,
chat_id: chatId,
role: 'assistant',
} as WsFrame);
const manifestCommands = getManifestCommands(agent);
if (manifestCommands.length > 0) {
setTaskCommands(taskId, manifestCommands);
broker.publishFrame(sessionId, {
type: 'agent_commands',
task_id: taskId,
session_id: sessionId,
commands: manifestCommands,
} as WsFrame);
}
// Accumulate the turn's stream for persistence + the final message content.
const textChunks: string[] = [];
const reasoningChunks: string[] = [];
const toolSnaps = new Map<string, AcpToolSnapshot>();
// Map transport-agnostic AgentEvents → the SAME WS frames the ACP path emits.
// This boundary is where message_id/chat_id get attached (the backend never
// owns them).
const onEvent = (e: AgentEvent): void => {
switch (e.type) {
case 'text':
textChunks.push(e.text);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: assistantId,
chat_id: chatId,
content: e.text,
} as WsFrame);
break;
case 'reasoning':
reasoningChunks.push(e.text);
broker.publishFrame(sessionId, {
type: 'reasoning_delta',
message_id: assistantId,
chat_id: chatId,
content: e.text,
} as WsFrame);
break;
case 'tool_call':
case 'tool_update':
toolSnaps.set(e.toolCall.toolCallId, e.toolCall);
broker.publishFrame(sessionId, {
type: 'tool_call',
message_id: assistantId,
chat_id: chatId,
tool_call: snapshotToWireToolCall(e.toolCall),
} as WsFrame);
break;
case 'commands':
// opencode-server doesn't emit these today; ignore if it ever does.
break;
}
};
// opencode expects provider-prefixed model ids (e.g. 'llama-swap/qwen3.6-35b…').
// DEFAULT_MODEL is bare (no prefix) because native inference uses it directly
// against llama-swap. Coalesce empty string (frontend sends '' when no models
// listed) and prefix bare ids so parseModel always succeeds.
const rawModel = (task.model && task.model.trim()) || config.DEFAULT_MODEL;
const model = rawModel.includes('/') ? rawModel : `llama-swap/${rawModel}`;
const backend = getOpenCodeBackend(installPath);
const handle = await backend.ensureSession(sessionId, {
agent,
model,
worktreePath,
projectId: task.project_id,
});
const result = await backend.prompt(handle, task.input, {
worktreePath,
model,
signal: ac.signal,
onEvent,
});
const assistantContent = textChunks.join('').slice(0, 50_000);
const reasoningText = reasoningChunks.join('').slice(0, 200_000);
const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'opencode turn failed').slice(0, 500);
await persistExternalAgentTurn(sql, assistantId, [...toolSnaps.values()], reasoningText);
await sql`
UPDATE messages
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
WHERE id = ${assistantId}
`;
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: assistantId,
chat_id: chatId,
} as WsFrame);
if (stopping) {
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
return; // worktree persists (no cleanup); backend stays warm
}
// 1.10: diff the persistent worktree against its captured baseline and
// SUPERSEDE the session's prior pending row (latest-wins, one accumulating
// diff) instead of stacking. Stamp agent for DiffPanel attribution.
const diff = await diffWorktree(worktreePath, projectPath, {
signal: ac.signal,
baseRef: baseCommit ?? 'HEAD',
});
if (diff) {
await sql`
DELETE FROM pending_changes WHERE session_id = ${sessionId} AND status = 'pending'
`;
await sql`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${projectPath}, 'edit', ${diff}, ${agent})
`;
log.info({ taskId, diffLength: diff.length }, 'dispatcher: diff superseded prior pending change');
} else {
log.info({ taskId }, 'dispatcher: no changes detected in session worktree');
}
// NO worktree cleanup — it's persistent (Phase 3 reaps it). Backend stays warm.
const [extCostRow] = await sql<{ total: number | null }[]>`
SELECT SUM(tokens_used)::int AS total
FROM messages
WHERE session_id = ${sessionId} AND tokens_used IS NOT NULL
`;
const extCostTokens = extCostRow?.total ?? null;
const finalState = result.ok ? 'completed' : 'failed';
await sql`
UPDATE tasks
SET state = ${finalState}, ended_at = clock_timestamp(), output_summary = ${outputSummary}, cost_tokens = ${extCostTokens}
WHERE id = ${taskId}
`;
log.info({ taskId, agent, finalState, costTokens: extCostTokens }, 'dispatcher: task finished (opencode server)');
clearTaskCommands(taskId);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log.error({ taskId, agent, err: errMsg }, 'dispatcher: opencode server error');
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId}
`.catch(() => {});
clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn.
}
}
// ─── Helpers ────────────────────────────────────────────────────────────────
async function waitForCompletion(assistantId: string): Promise<string> {
@@ -514,9 +810,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
});
listener = null;
}
if (inflightPromise) {
log.info('dispatcher: waiting for in-flight task');
await inflightPromise;
if (inflight.size > 0) {
log.info({ count: inflight.size }, 'dispatcher: waiting for in-flight tasks');
await Promise.allSettled([...inflight.values()]);
}
log.info('dispatcher: stopped');
},

View File

@@ -29,7 +29,7 @@ interface AgentRow {
last_probed_at: string | Date | null;
}
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
export async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
try {
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
if (!res.ok) return [];

View File

@@ -6,6 +6,7 @@
* After the agent completes, we diff the worktree against HEAD and
* queue the diff into pending_changes.
*/
import type { Sql } from '../db.js';
import { hostExec } from './host-exec.js';
const WORKTREE_BASE = '/tmp/booworktrees';
@@ -45,7 +46,7 @@ export async function createWorktree(
export async function diffWorktree(
worktreePath: string,
projectPath: string,
opts?: { signal?: AbortSignal },
opts?: { signal?: AbortSignal; baseRef?: string },
): Promise<string> {
// First, commit any uncommitted changes in the worktree so we can diff branches
// Stage all changes
@@ -74,9 +75,13 @@ export async function diffWorktree(
{ signal: opts?.signal, timeoutMs: 15_000 },
);
// Diff the worktree branch against the parent commit (HEAD of main tree)
// Diff the worktree branch against the baseline. Per-task callers default to the
// main tree's current HEAD; the session-worktree (opencode) path passes the
// captured base_commit so the accumulated diff is stable across turns even if
// project HEAD advances.
const baseRef = opts?.baseRef ?? 'HEAD';
const diffResult = await hostExec(
`git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
`git -C ${shellEscape(projectPath)} diff ${shellEscape(baseRef)}...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
{ signal: opts?.signal, timeoutMs: 60_000 },
);
@@ -111,6 +116,231 @@ export async function cleanupWorktree(
).catch(() => {});
}
// ─── v2.6: session-keyed persistent worktree ────────────────────────────────
export interface SessionWorktree {
worktreePath: string;
baseCommit: string | null;
}
/**
* v2.6: create-or-reuse ONE worktree per BooCode session (shared across all
* agents/turns in the session), recorded in `session_worktrees`. Unlike the
* per-task `createWorktree`, this persists — it is NOT torn down per turn
* (cleanup is Phase 3). Captures the project's current HEAD as `base_commit`
* so the accumulating diff has a stable baseline across turns.
*
* Distinct path namespace (`session-<id>` branch, `/sess-<id>` dir) so it never
* collides with the per-task worktrees that arena/new_task/MCP still use.
*/
export async function ensureSessionWorktree(
sql: Sql,
projectPath: string,
sessionId: string,
opts?: { signal?: AbortSignal },
): Promise<SessionWorktree> {
const [existing] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
`;
if (existing) {
return { worktreePath: existing.worktree_path, baseCommit: existing.base_commit };
}
const worktreePath = `${WORKTREE_BASE}/sess-${sessionId}`;
const branchName = `session-${sessionId}`;
await hostExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal });
// Capture the baseline commit BEFORE branching, so the diff is stable even if
// project HEAD later advances.
const headResult = await hostExec(
`git -C ${shellEscape(projectPath)} rev-parse HEAD`,
{ signal: opts?.signal, timeoutMs: 10_000 },
);
const baseCommit = headResult.exitCode === 0 ? headResult.stdout.trim() || null : null;
const result = await hostExec(
`git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`,
{ signal: opts?.signal, timeoutMs: 30_000 },
);
if (result.exitCode !== 0) {
throw new Error(`Failed to create session worktree: ${result.stderr.trim() || result.stdout.trim()}`);
}
// Persist. ON CONFLICT keeps the first writer's row if two turns race the create.
await sql`
INSERT INTO session_worktrees (session_id, worktree_path, base_commit)
VALUES (${sessionId}, ${worktreePath}, ${baseCommit})
ON CONFLICT (session_id) DO NOTHING
`;
const [row] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
`;
return {
worktreePath: row?.worktree_path ?? worktreePath,
baseCommit: row?.base_commit ?? baseCommit,
};
}
// ─── Session-delete work-loss guard ─────────────────────────────────────────
/**
* Risk report for a single worktree, returned by checkWorktreeWorkAtRisk.
* `atRisk` is the gate the server reads before allowing a session delete.
* A git error never silently passes — it forces `atRisk` true and surfaces
* the message in `error` (fail-closed).
*/
export interface RiskReport {
worktreePath: string;
branch: string;
dirty: boolean; // uncommitted working-tree changes (incl. untracked)
unpushed: number; // commits ahead of upstream, or -1 if no upstream is set
unmerged: number; // commits on this branch not in the project default branch
atRisk: boolean; // dirty || unmerged > 0 || (upstream && unpushed > 0) || git error
error?: string; // populated on a git failure; presence forces atRisk
}
/**
* Resolve the project's default branch as a git-usable ref (e.g. "origin/main").
*
* `refs/remotes/origin/HEAD` lives in the repo's COMMON git dir and is shared
* across every linked worktree, so reading it from the session worktree returns
* the REMOTE's default branch — never this worktree's own `session-<id>` branch
* (that would be `symbolic-ref HEAD`, a different ref). Falls back to probing
* common defaults by verified existence when origin/HEAD isn't set (e.g. a repo
* that never ran `git remote set-head`). Returns null if none resolve, in which
* case the unmerged check is skipped (dirty + unpushed still protect the work).
*/
async function detectDefaultBranchRef(
worktreePath: string,
opts?: { signal?: AbortSignal },
): Promise<string | null> {
const head = await hostExec(
`git -C ${shellEscape(worktreePath)} symbolic-ref --short refs/remotes/origin/HEAD`,
{ signal: opts?.signal, timeoutMs: 10_000 },
);
if (head.exitCode === 0) {
const ref = head.stdout.trim(); // e.g. "origin/main"
if (ref) {
const verify = await hostExec(
`git -C ${shellEscape(worktreePath)} rev-parse --verify --quiet ${shellEscape(ref + '^{commit}')}`,
{ signal: opts?.signal, timeoutMs: 10_000 },
);
if (verify.exitCode === 0 && verify.stdout.trim()) return ref;
}
}
// origin/HEAD unset or unresolvable — probe common defaults. Prefer the
// remote-tracking ref (always resolvable in a fresh worktree) over the local
// head, which may not exist if the default branch lives only in the main tree.
for (const cand of ['origin/main', 'origin/master', 'main', 'master']) {
const verify = await hostExec(
`git -C ${shellEscape(worktreePath)} rev-parse --verify --quiet ${shellEscape(cand + '^{commit}')}`,
{ signal: opts?.signal, timeoutMs: 10_000 },
);
if (verify.exitCode === 0 && verify.stdout.trim()) return cand;
}
return null;
}
/**
* Inspect a worktree for work that would be lost if its session were deleted.
* Three checks, all via the audited hostExec + shellEscape path (every
* interpolated value — paths, refs — is single-quote-escaped; no bare
* interpolation). Any unexpected git failure is treated as at-risk, never a
* silent pass.
*/
export async function checkWorktreeWorkAtRisk(
worktreePath: string,
opts?: { signal?: AbortSignal },
): Promise<RiskReport> {
// Branch name — also doubles as the "is this still a git worktree?" probe.
const br = await hostExec(
`git -C ${shellEscape(worktreePath)} rev-parse --abbrev-ref HEAD`,
{ signal: opts?.signal, timeoutMs: 10_000 },
);
if (br.exitCode !== 0) {
return {
worktreePath,
branch: '',
dirty: false,
unpushed: 0,
unmerged: 0,
atRisk: true,
error: `git rev-parse failed: ${br.stderr.trim() || 'not a git worktree'}`,
};
}
const branch = br.stdout.trim();
// (a) Uncommitted (dirty working tree, including untracked files).
const st = await hostExec(
`git -C ${shellEscape(worktreePath)} status --porcelain`,
{ signal: opts?.signal, timeoutMs: 15_000 },
);
if (st.exitCode !== 0) {
return {
worktreePath,
branch,
dirty: false,
unpushed: 0,
unmerged: 0,
atRisk: true,
error: `git status failed: ${st.stderr.trim()}`,
};
}
const dirty = st.stdout.trim().length > 0;
// (b) Unpushed commits. No upstream configured => work exists only locally;
// treat as unpushed-by-definition (-1) rather than an error.
const up = await hostExec(
`git -C ${shellEscape(worktreePath)} rev-list --count ${shellEscape('@{u}..HEAD')}`,
{ signal: opts?.signal, timeoutMs: 15_000 },
);
const unpushed = up.exitCode === 0 ? (parseInt(up.stdout.trim() || '0', 10) || 0) : -1;
// (c) Unmerged commits — on this branch but not in the project default branch.
const defaultRef = await detectDefaultBranchRef(worktreePath, opts);
let unmerged = 0;
if (defaultRef) {
const rl = await hostExec(
`git -C ${shellEscape(worktreePath)} rev-list --count ${shellEscape(defaultRef + '..HEAD')}`,
{ signal: opts?.signal, timeoutMs: 15_000 },
);
if (rl.exitCode === 0) unmerged = parseInt(rl.stdout.trim() || '0', 10) || 0;
}
// unpushed only contributes when an upstream actually exists. Session branches
// (session-<id>) never have one (unpushed === -1), and any real local-only work
// there already surfaces as unmerged > 0 — so the no-upstream case adds no
// protection, only friction (it flagged every pristine worktree-backed session).
// The unpushed > 0 arm stays forward-compatible with P1.5 pushable branches.
const hasUpstream = unpushed !== -1;
const atRisk = dirty || unmerged > 0 || (hasUpstream && unpushed > 0);
return { worktreePath, branch, dirty, unpushed, unmerged, atRisk };
}
/**
* Stash a worktree's uncommitted changes (including untracked, via -u) so the
* working tree is clean. Stash entries live in the repo's common git dir, so
* they survive worktree-dir removal — this is the recoverable, safe-by-default
* escape. Note it only clears the *dirty* risk; unpushed/unmerged commits
* remain on the branch, so a re-attempted delete may still block on those.
*/
export async function stashWorktree(
worktreePath: string,
opts?: { signal?: AbortSignal },
): Promise<{ stashed: boolean; error?: string }> {
const r = await hostExec(
`git -C ${shellEscape(worktreePath)} stash push -u -m ${shellEscape('boocode: pre-delete stash')}`,
{ signal: opts?.signal, timeoutMs: 30_000 },
);
if (r.exitCode !== 0) {
return { stashed: false, error: r.stderr.trim() || r.stdout.trim() };
}
// "No local changes to save" => exit 0, nothing stashed — not an error.
const stashed = !/no local changes to save/i.test(r.stdout);
return { stashed };
}
/** Minimal shell escape for paths (single-quote wrapping). */
function shellEscape(s: string): string {
// Replace single quotes with escaped version, wrap in single quotes

View File

@@ -3,7 +3,7 @@ import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import type { Broker } from '../services/broker.js';
import type { Session } from '../types/api.js';
import type { Session, WorktreeRiskReport } from '../types/api.js';
import { getSetting } from './settings.js';
const CreateBody = z.object({
@@ -426,10 +426,53 @@ export function registerSessionRoutes(
}
);
app.delete<{ Params: { id: string } }>(
app.delete<{ Params: { id: string }; Querystring: { force?: string } }>(
'/api/sessions/:id',
async (req, reply) => {
const id = req.params.id;
const force = req.query.force === 'true' || req.query.force === '1';
// Session-delete work-loss guard. CASCADE on session_worktrees means the
// DELETE below auto-wipes the worktree row, so the safety check MUST run
// BEFORE it (paths read while the row still exists, pre-CASCADE).
//
// Optimization: read session_worktrees from our own (shared) DB first.
// No row => chat-only session => nothing on disk => delete immediately,
// zero round-trip. Only worktree-backed sessions pay the host git check.
if (!force) {
const worktrees = await sql<{ worktree_path: string }[]>`
SELECT worktree_path FROM session_worktrees WHERE session_id = ${id}
`;
if (worktrees.length > 0) {
// Worktree dirs live on the host; only BooCoder can run git on them.
const origin = process.env.BOOCODER_URL ?? 'http://boocoder:3000';
let reports: WorktreeRiskReport[];
try {
const res = await fetch(`${origin}/api/sessions/${id}/worktree-risk`);
if (!res.ok) {
// Fail-closed: can't verify => don't risk silent loss. Force escapes.
reply.code(409);
return {
error: 'could not verify worktree safety (BooCoder check failed). Use force to delete anyway.',
reports: [] as WorktreeRiskReport[],
};
}
reports = ((await res.json()) as { reports?: WorktreeRiskReport[] }).reports ?? [];
} catch {
// Fail-closed: BooCoder unreachable. Force bypasses this path entirely.
reply.code(409);
return {
error: 'BooCoder unreachable; cannot verify worktree safety. Use force to delete anyway.',
reports: [] as WorktreeRiskReport[],
};
}
if (reports.some((r) => r.atRisk)) {
reply.code(409);
return { error: 'This session has work at risk in its worktree.', reports };
}
}
}
const deleted = await sql<{ project_id: string }[]>`
DELETE FROM sessions WHERE id = ${id} RETURNING project_id
`;

View File

@@ -37,9 +37,11 @@ export async function maybeAutoNameChat(
if ((counts[0]?.n ?? 0) < 1) return;
const chatRows = await ctx.sql<
{ id: string; name: string | null; session_id: string }[]
{ id: string; name: string | null; session_id: string; model: string | null }[]
>`
SELECT id, name, session_id FROM chats WHERE id = ${chatId}
SELECT c.id, c.name, c.session_id, s.model
FROM chats c JOIN sessions s ON s.id = c.session_id
WHERE c.id = ${chatId}
`;
const chat = chatRows[0];
if (!chat) return;
@@ -67,6 +69,7 @@ export async function maybeAutoNameChat(
user: namingInput,
maxTokens: 30,
temperature: 0.3,
fallbackModel: chat.model ?? undefined,
});
const name = cleanTitle(raw);
if (!name) {

View File

@@ -25,6 +25,20 @@ export interface AvailableProject {
export type SessionStatus = 'open' | 'archived';
// Session-delete work-loss guard. Returned (as `reports`) in the 409 body when
// a delete is blocked because the session's worktree holds work at risk. The
// shape is produced by BooCoder's checkWorktreeWorkAtRisk and passed through
// verbatim; mirrored byte-for-byte in apps/web/src/api/types.ts for the dialog.
export interface WorktreeRiskReport {
worktreePath: string;
branch: string;
dirty: boolean;
unpushed: number; // commits ahead of upstream, or -1 if no upstream
unmerged: number; // commits not in the project default branch
atRisk: boolean;
error?: string;
}
export interface Session {
id: string;
project_id: string;

View File

@@ -151,8 +151,17 @@ export const api = {
method: 'PATCH',
body: JSON.stringify(body),
}),
remove: (id: string) =>
request<void>(`/api/sessions/${id}`, { method: 'DELETE' }),
// force=true bypasses the server-side worktree work-loss guard. A blocked
// delete throws ApiError(409) whose body carries { error, reports }.
remove: (id: string, force = false) =>
request<void>(`/api/sessions/${id}${force ? '?force=true' : ''}`, { method: 'DELETE' }),
// Stash the session's worktree (uncommitted changes) on the host, via the
// BooCoder proxy. Recoverable escape from the work-at-risk dialog.
worktreeStash: (id: string) =>
request<{ results: { worktreePath: string; stashed: boolean; error?: string }[] }>(
`/api/coder/sessions/${id}/worktree-stash`,
{ method: 'POST' },
),
archive: (id: string) =>
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) =>

View File

@@ -34,6 +34,19 @@ export interface AvailableProject {
export type SessionStatus = 'open' | 'archived';
// Session-delete work-loss guard. Mirror of WorktreeRiskReport in
// apps/server/src/types/api.ts — edit both copies together. Arrives as the
// `reports` field of the 409 body when a delete is blocked.
export interface WorktreeRiskReport {
worktreePath: string;
branch: string;
dirty: boolean;
unpushed: number; // commits ahead of upstream, or -1 if no upstream
unmerged: number; // commits not in the project default branch
atRisk: boolean;
error?: string;
}
export interface Session {
id: string;
project_id: string;

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Code, History, MessageSquare, Plus, Terminal, X } from 'lucide-react';
import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Terminal, X } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import { StatusDot } from '@/components/StatusDot';
import {
@@ -26,7 +26,9 @@ interface Props {
onCloseOthers: (chatId: string) => void;
onCloseToRight: (chatId: string) => void;
onCloseAll: () => void;
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onNewTab: () => void;
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onReopenPane?: () => void;
onShowHistory: () => void;
onRename: (chatId: string, name: string) => Promise<void>;
onRemovePane?: () => void;
@@ -40,7 +42,9 @@ export function ChatTabBar({
onCloseOthers,
onCloseToRight,
onCloseAll,
onAddPane,
onNewTab,
onSplitPane,
onReopenPane,
onShowHistory,
onRename,
onRemovePane,
@@ -131,7 +135,7 @@ export function ChatTabBar({
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onAddPane('chat')}>
<ContextMenuItem onSelect={onNewTab}>
New chat
</ContextMenuItem>
<ContextMenuSeparator />
@@ -170,29 +174,49 @@ export function ChatTabBar({
)}
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
<button
type="button"
onClick={onNewTab}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New tab"
title="New tab"
>
<Plus size={12} />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New pane"
title="New pane"
aria-label="Split pane"
title="Split pane"
>
<Plus size={12} />
<Columns2 size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<DropdownMenuItem onSelect={() => onSplitPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onReopenPane && (
<button
type="button"
onClick={onReopenPane}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Reopen closed pane"
title="Reopen closed pane"
>
<RotateCcw size={12} />
</button>
)}
<button
type="button"
onClick={onShowHistory}

View File

@@ -19,12 +19,12 @@ import {
DialogDescription,
} from '@/components/ui/dialog';
import { AddProjectModal } from './AddProjectModal';
import { api } from '@/api/client';
import { api, ApiError } from '@/api/client';
import { useSidebar } from '@/hooks/useSidebar';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useViewport } from '@/hooks/useViewport';
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import type { SidebarProject } from '@/api/types';
import type { SidebarProject, WorktreeRiskReport } from '@/api/types';
import { giteaUrlFor } from '@/lib/projectUrls';
import { isCoderSessionName } from '@/lib/coder-session';
import { cn } from '@/lib/utils';
@@ -110,6 +110,16 @@ export function ProjectSidebar() {
const [renamingProject, setRenamingProject] = useState<string | null>(null);
const [renameProjectValue, setRenameProjectValue] = useState('');
const [archiveProjectConfirm, setArchiveProjectConfirm] = useState<{ id: string; name: string } | null>(null);
// Work-at-risk dialog: shown when a delete is blocked (409) because the
// session's worktree holds uncommitted/unpushed/unmerged work.
const [riskState, setRiskState] = useState<{
sessionId: string;
projectId: string;
name: string;
message: string;
reports: WorktreeRiskReport[];
} | null>(null);
const [riskBusy, setRiskBusy] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const lastToastedError = useRef<string | null>(null);
@@ -174,16 +184,81 @@ export function ProjectSidebar() {
}
}
async function handleDeleteSession(sessionId: string, projectId: string) {
async function handleDeleteSession(
sessionId: string,
projectId: string,
name: string,
force = false,
) {
try {
await api.sessions.remove(sessionId);
await api.sessions.remove(sessionId, force);
// Server publishes session_deleted via WS; useUserEvents delivers it.
setRiskState(null);
if (activeSession === sessionId) navigate(`/project/${projectId}`);
} catch (err) {
// 409 => the server's work-loss guard blocked the delete. Open the
// work-at-risk dialog with the per-worktree reports instead of toasting.
if (
err instanceof ApiError &&
err.status === 409 &&
err.body && typeof err.body === 'object' && 'reports' in err.body
) {
const body = err.body as { error?: string; reports?: WorktreeRiskReport[] };
setRiskState({
sessionId,
projectId,
name,
message: body.error ?? 'This session has work at risk.',
reports: body.reports ?? [],
});
return;
}
toast.error(err instanceof Error ? err.message : 'failed to delete session');
}
}
// Stash the worktree's uncommitted changes (recoverable), then re-attempt the
// delete. If unpushed/unmerged commits remain, the retry 409s again and the
// dialog re-renders with the narrowed risk.
async function handleStashAndRetry() {
if (!riskState || riskBusy) return;
setRiskBusy(true);
try {
const { results } = await api.sessions.worktreeStash(riskState.sessionId);
const failed = results.find((r) => r.error);
if (failed) {
toast.error(`stash failed: ${failed.error}`);
return;
}
await handleDeleteSession(riskState.sessionId, riskState.projectId, riskState.name, false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'stash failed');
} finally {
setRiskBusy(false);
}
}
// Explicit, destructive override — deletes despite work at risk.
async function handleForceDelete() {
if (!riskState || riskBusy) return;
setRiskBusy(true);
try {
await handleDeleteSession(riskState.sessionId, riskState.projectId, riskState.name, true);
} finally {
setRiskBusy(false);
}
}
// Route the user to commit it themselves — never auto-commit. Opens the
// session workspace where they can use a terminal or agent pane.
function handleGoCommit() {
if (!riskState) return;
const sessionId = riskState.sessionId;
setRiskState(null);
navigate(`/session/${sessionId}`);
toast.info('Open a terminal or agent in this session, commit and push your work, then delete again.');
}
async function handleRenameSession(sessionId: string) {
const trimmed = renameValue.trim();
setRenamingSession(null);
@@ -216,6 +291,20 @@ export function ProjectSidebar() {
)
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen';
// Work-at-risk dialog framing. The server returns 409 in two distinct
// situations: (1) work genuinely at risk (reports has ≥1 atRisk entry), or
// (2) it couldn't verify (BooCoder down/errored → reports is empty). These
// are different user stories — "your work is in danger" vs "the checker is
// offline" — so the dialog must not show one generic message for both.
const atRiskReports = riskState?.reports.filter((r) => r.atRisk) ?? [];
const verifyFailed = riskState !== null && atRiskReports.length === 0;
const anyDirty = atRiskReports.some((r) => r.dirty);
// Commit-based risk (unpushed/unmerged) that stash can NOT clear. When this is
// all that remains (e.g. after a stash cleared the dirty changes), the dialog
// explains why it re-blocked and hides the Stash button so it doesn't look
// like stash "didn't work".
const anyCommits = atRiskReports.some((r) => r.unpushed !== 0 || r.unmerged > 0);
return (
<aside className={asideCls}>
<div className="px-4 py-3 border-b flex items-center justify-between">
@@ -499,7 +588,7 @@ export function ProjectSidebar() {
const projectId = projects.find((p) =>
p.recent_sessions.some((s) => s.id === deleteConfirm.id)
)?.id;
if (projectId) void handleDeleteSession(deleteConfirm.id, projectId);
if (projectId) void handleDeleteSession(deleteConfirm.id, projectId, deleteConfirm.name);
}
setDeleteConfirm(null);
}}
@@ -509,6 +598,77 @@ export function ProjectSidebar() {
</div>
</DialogContent>
</Dialog>
<Dialog open={riskState !== null} onOpenChange={(open) => { if (!open && !riskBusy) setRiskState(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{verifyFailed ? 'Could not verify worktree safety' : 'This session has work at risk'}
</DialogTitle>
<DialogDescription>
{verifyFailed ? (
<>
{riskState?.message ?? 'The worktree safety check is unavailable.'} Your work may be
fine, but it couldn&apos;t be checked only force-delete if you&apos;re sure.
</>
) : anyDirty && anyCommits ? (
<>
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan uncommitted
changes <em>and</em> commits that aren&apos;t pushed or merged. Stash clears the
changes (recoverable), but the commits will still block push them or force-delete.
</>
) : anyDirty ? (
<>
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan uncommitted
changes in its worktree. Stash them (recoverable), commit them, or force-delete.
</>
) : (
<>
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan commits that
aren&apos;t pushed or merged. Stashing won&apos;t recover these push them, or
force-delete.
</>
)}
</DialogDescription>
</DialogHeader>
{!verifyFailed && (
<div className="flex flex-col gap-2 py-1 text-sm">
{atRiskReports.map((r) => (
<div key={r.worktreePath} className="rounded border border-border/60 px-3 py-2">
<div className="font-mono text-xs text-muted-foreground truncate" title={r.worktreePath}>
{r.branch || r.worktreePath}
</div>
<ul className="mt-1 list-disc pl-5 text-foreground/90">
{r.error && <li className="text-destructive">git error: {r.error}</li>}
{r.dirty && <li>uncommitted changes</li>}
{r.unpushed === -1 && <li>local-only branch (no upstream)</li>}
{r.unpushed > 0 && <li>{r.unpushed} unpushed commit{r.unpushed === 1 ? '' : 's'}</li>}
{r.unmerged > 0 && <li>{r.unmerged} unmerged commit{r.unmerged === 1 ? '' : 's'}</li>}
</ul>
</div>
))}
</div>
)}
<div className="flex flex-wrap gap-2 justify-end pt-2">
<Button variant="outline" disabled={riskBusy} onClick={() => setRiskState(null)}>
Cancel
</Button>
{!verifyFailed && (
<Button variant="outline" disabled={riskBusy} onClick={handleGoCommit}>
Commit&hellip;
</Button>
)}
{!verifyFailed && anyDirty && (
<Button variant="outline" disabled={riskBusy} onClick={() => void handleStashAndRetry()}>
Stash &amp; delete
</Button>
)}
<Button variant="destructive" disabled={riskBusy} onClick={() => void handleForceDelete()}>
Force delete
</Button>
</div>
</DialogContent>
</Dialog>
</aside>
);
}

View File

@@ -65,6 +65,8 @@ export function Workspace({
showLandingPage,
addSplitPane,
removePane,
reopenPane,
hasClosedPanes,
isPaneChatPending,
handlePaneDragStart,
handlePaneDragOver,
@@ -207,10 +209,9 @@ export function Workspace({
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
onCloseAll={() => closeAllTabs(idx)}
onAddPane={(kind) => {
if (kind === 'chat') void createChat(idx);
else onAddPane(kind);
}}
onNewTab={() => void createChat(idx)}
onSplitPane={(kind) => onAddPane(kind)}
onReopenPane={hasClosedPanes ? reopenPane : undefined}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}

View File

@@ -32,6 +32,21 @@ function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}
interface ClosedPaneEntry {
kind: WorkspacePane['kind'];
chatIds: string[];
activeChatIdx: number;
}
const MAX_CLOSED = 10;
const closedPaneStack: ClosedPaneEntry[] = [];
function pushClosed(pane: WorkspacePane): void {
if (pane.kind === 'empty' || pane.kind === 'settings') return;
if (pane.chatIds.length === 0) return;
closedPaneStack.push({ kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx });
if (closedPaneStack.length > MAX_CLOSED) closedPaneStack.shift();
}
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
return kind === 'coder' ? 'BooCoder' : 'Terminal';
}
@@ -137,6 +152,8 @@ export interface UseWorkspacePanesResult {
// falls back to an empty pane to preserve the "always one pane" invariant.
toggleSettingsPane: () => string | null;
removePane: (idx: number) => void;
reopenPane: () => void;
hasClosedPanes: boolean;
removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
validatePanes: (validChatIds: Set<string>) => void;
@@ -391,6 +408,14 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const pane = next[paneIdx]!;
const nextIds = pane.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) {
if (next.length > 1) {
// Last tab closed and other panes exist — remove the whole pane
// instead of leaving an orphaned empty panel.
pushClosed(pane); setHasClosedPanes(true);
const spliced = next.filter((_, i) => i !== paneIdx);
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
return spliced;
}
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
} else {
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
@@ -534,6 +559,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
// The endpoint is idempotent (404 on missing session) so a strict-mode
// double-invoke of the updater is safe.
const removed = prev[idx];
if (removed) { pushClosed(removed); setHasClosedPanes(true); }
if (removed?.kind === 'terminal') {
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
}
@@ -543,6 +569,26 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
});
}, [sessionId]);
const [hasClosedPanes, setHasClosedPanes] = useState(closedPaneStack.length > 0);
const reopenPane = useCallback(() => {
const entry = closedPaneStack.pop();
setHasClosedPanes(closedPaneStack.length > 0);
if (!entry) return;
setPanes((prev) => {
const restored: WorkspacePane = {
id: generateId(),
kind: entry.kind,
chatId: entry.chatIds[entry.activeChatIdx] ?? entry.chatIds[0],
chatIds: entry.chatIds,
activeChatIdx: Math.min(entry.activeChatIdx, entry.chatIds.length - 1),
};
const next = [...prev, restored];
setActivePaneIdx(next.length - 1);
return next;
});
}, []);
// Replaces a single empty default pane with a chat pane. Used by the initial
// chat fetch to land on the most-recent open chat if no saved pane state.
const initializeFirstChatIfEmpty = useCallback((chatId: string) => {
@@ -672,6 +718,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
addSplitPane,
toggleSettingsPane,
removePane,
reopenPane,
hasClosedPanes,
removeChatFromPanes,
initializeFirstChatIfEmpty,
validatePanes,

View File

@@ -0,0 +1,61 @@
---
name: systematic-debugging
description: Guided root-cause debugging. Use when encountering any bug, test failure, unexpected behavior, or performance problem. Enforces investigation before fixes.
---
# Systematic Debugging
No fixes without root cause. Symptom fixes mask real bugs and waste time.
## The Rule
Complete Phase 1 before proposing ANY fix. If you haven't investigated, you cannot fix.
## Phase 1: Root Cause Investigation
1. **Read error messages carefully.** Stack traces, line numbers, error codes. Don't skip past them.
2. **Reproduce consistently.** Exact steps, every time. If not reproducible, gather more data instead of guessing.
3. **Check recent changes.** Git diff, recent commits, new deps, config changes, env differences.
4. **Trace data flow.** Where does the bad value originate? Trace backward through the call stack to the source. Fix at the source, not the symptom.
5. **Multi-component systems:** Before fixing, add diagnostic logging at each component boundary (what enters, what exits). Run once to locate the failing layer, THEN investigate that layer.
## Phase 2: Pattern Analysis
1. Find working examples of similar code in the same codebase.
2. Compare working vs broken — list every difference.
3. If implementing a pattern, read the reference implementation completely, not skimmed.
4. Understand all dependencies, config, and assumptions.
## Phase 3: Hypothesis and Testing
1. State one hypothesis clearly: "X is the root cause because Y."
2. Make the smallest possible change to test it. One variable at a time.
3. If it didn't work, form a NEW hypothesis. Don't stack more fixes on top.
4. After 3 failed fixes: STOP. Question the architecture, not the symptoms.
## Phase 4: Implementation
1. Create a failing test case first (simplest reproduction).
2. Implement a single fix addressing the root cause.
3. Verify: test passes, no regressions, issue actually resolved.
4. If the fix doesn't work and you've tried 3+: the problem is architectural. Discuss before attempting more.
## Red Flags — STOP and return to Phase 1
- "Quick fix for now, investigate later"
- "Just try changing X and see"
- "I don't fully understand but this might work"
- "One more fix attempt" after 2+ failures
- Proposing solutions before tracing data flow
- Each fix reveals a new problem in a different place
## Apply This Skill
Use these tools to investigate before proposing changes:
- `view_file` to read error sites and suspect code paths
- `grep` to find all callers / references to the failing function
- `find_files` to locate related config, test fixtures, schema
- `list_dir` to understand the module layout around the bug
Report your Phase 1 findings (what you observed, what you traced, what you ruled out) before moving to Phase 3.

10
pnpm-lock.yaml generated
View File

@@ -63,6 +63,9 @@ importers:
'@modelcontextprotocol/sdk':
specifier: ^1.29.0
version: 1.29.0(zod@3.25.76)
'@opencode-ai/sdk':
specifier: ~1.15.0
version: 1.15.12
fastify:
specifier: ^4.28.1
version: 4.29.1
@@ -920,6 +923,9 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@opencode-ai/sdk@1.15.12':
resolution: {integrity: sha512-lOaBNX93dkakZe6C42ttX1bkSx3K2c6+Yv+w8Qv02v5rPlu1vCXbmdfYDh9/bw+oq+NKPSaBm9d6kPA19hA5Lg==}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
@@ -4702,6 +4708,10 @@ snapshots:
'@open-draft/until@2.1.0': {}
'@opencode-ai/sdk@1.15.12':
dependencies:
cross-spawn: 7.0.6
'@opentelemetry/api@1.9.1': {}
'@pinojs/redact@0.4.0': {}