Compare commits

..

17 Commits

Author SHA1 Message Date
7b4f41b26f docs: roadmap shipping-state update + external code-review v2 findings
Update boocode_roadmap.md's shipped section through v2.6.4 (provider lifecycle,
persistent agent sessions, cursor/copilot retirement) and add
boocode_code_review_v2.md — a point-in-time external-fork lift/cross-check
findings doc (Paseo + opencode + llama.cpp + the second fork sweep), companion
to the standing boocode_code_review.md inventory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:28:13 +00:00
5527e7a5e8 docs(changelog): v2.6.5-panes-tabs-composer
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:15:46 +00:00
08d6a8fa40 feat(web): morphing send/stop/queue composer button
The composer's primary button now reflects generation state: Send when idle,
Stop while generating with an empty draft, and Queue while generating with a
draft typed (submitting queues it via the existing queue path). Stop is
click-only so a stray Enter never interrupts a run. ChatInput gains generating
+ onStop props.

BooChat: removes the separate centered "Stop generating" pill and wires
generating={streaming} + onStop={handleStop}. BooCoder: generating now keys on
sending || activeTaskId (the dispatch POST is too brief on its own), which also
fixes the queue gates that previously fired mid-run; onStop cancels the active
task via the new api.coder.cancelTask, and the input is no longer disabled while
a task runs so follow-ups can be queued.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:15:14 +00:00
2fd7e5bf97 feat(web): workspace panes & tabs overhaul
A cohesive batch of pane/tab UX + the persisted workspace-state model (grouped
because the changes interleave across useWorkspacePanes, ChatTabBar, Workspace,
sessionEvents and the api types/client):

- Open a whole chat in a fresh pane via a new open_chat_in_new_pane event:
  ChatTabBar tab context menu "Open in new pane", and MessageBubble.fork() now
  lands the fork beside the original instead of replacing the active pane.
  openChatInNewPane detaches the chat from any pane already holding it
  (one-chat-per-pane).
- The tab-bar "+" becomes a New BooChat/BooTerm/BooCode menu (chat as a tab,
  term/coder as split panes); the split button is unchanged.
- Drop the per-message "Open in pane" button (it opened a single message's
  artifact) and its dead code; the artifact-pane machinery is left orphaned for
  a later teardown.
- Session history: the empty/landing pane lists the session's open chats plus
  archived chats (fetched separately), click to open / restore-and-open.
- Relocate-on-close: closing a chat pane moves its tabs (in order) into the
  oldest chat/empty pane instead of discarding them; terminal/coder panes close
  as before. Reopen strips the restored chatIds from all live panes first, so a
  relocated-then-reopened pane never duplicates a tab — no stack-shape change.
- Stable global tab numbering: tabNumbers/nextTabNumber assigned on chat-pane
  open, retired on close (never reused), rendered map-keyed (not positional).
- workspace_panes is now a WorkspaceState envelope { panes, tabNumbers,
  nextTabNumber, closedPaneStack }; the reopen stack moved from a module-level
  array into the persisted envelope so it survives reload. Hydrate/persist
  normalize the legacy bare-array shape. appendClosed dedupes a value-identical
  top entry to neutralize the StrictMode double-invoke of the setPanes updater.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:15:03 +00:00
d05f73be26 feat(server): workspace_panes envelope + read_tab_by_number tool
Widen the sessions.workspace_panes JSONB from a bare WorkspacePane[] to a
WorkspaceState envelope { panes, tabNumbers, nextTabNumber, closedPaneStack }.
The PATCH validator accepts either the legacy array or the envelope (zod union)
and normalizes to a full envelope before storing, so existing array-shaped rows
migrate transparently on next write. The session_workspace_updated WS frame
schema is widened to match (kept byte-identical to the web copy; parity test
passes).

Adds read_tab_by_number, a read-only tool that resolves a session-scoped tab
number to its chat via the persisted tabNumbers map and returns that chat's
transcript (oldest-first, sentinels skipped, capped at 20k chars). Tools gain an
optional ToolExecCtx ({ sql, sessionId }) 4th param on ToolDef.execute, threaded
through executeToolCall from executeToolPhase; the param is optional so existing
filesystem tools and the apps/coder consumer stay compatible. Registered in
ALL_TOOLS + READ_ONLY_TOOL_NAMES.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:14:42 +00:00
e857815d79 feat(web): paste chips trail the typed message text
flattenToMessage now places the typed text first and appends pasted-chip
content after it with a single leading space (file/line chips remain fenced
provenance blocks after that), instead of prepending all attachments. A
leading slash command therefore stays first and the paste reads as its
continuation — `/command <pasted>` rather than `<pasted>` then the command.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:13:40 +00:00
12d31a81a0 docs(changelog): v2.6.4-agent-sessions-fk
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:47:40 +00:00
5da6eb2447 docs(claude-md): sync v2.6 engineering notes (P1.5-a/b, skills, AGENTS.md parsing)
Reflect shipped v2.6.1–v2.6.3 work in the deep reference. The opencode SSE
bullet now describes per-session SSE (P1.5-a) instead of the single-stream
Phase-1 limit; the agent_sessions resume bullet describes the (chat_id, agent)
re-key (P1.5-b) — chat_id CASCADEs from chats, session_id/worktree_id are
informational SET NULL, and the worktrees table supersedes the defanged
session_worktrees. Drop the stale root AGENTS.md navigation pointer (removed
in v1.12; data/AGENTS.md is the registry, not navigation). Add two
conventions: data/AGENTS.md is parsed (## headings need a --- fence, no
free-form rule sections) and the data/skills/<vendor>/ layout with the
boocode/ namespace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:47:16 +00:00
7f6c4780e2 fix(coder): converge agent_sessions.session_id FK to SET NULL (P1.5-b follow-up)
The P1.5-b re-key block (cb1846c) re-adds session_id_fkey as ON DELETE
SET NULL, but the whole block is guarded on chat_id_fkey's absence. A DB
already re-keyed to (chat_id, agent) while session_id_fkey was still
ON DELETE CASCADE never re-enters that block, so applySchema leaves it at
'c' forever — diverging from the schema's stated intent, from worktree_id
(already SET NULL), and from the v2.6.3 changelog's own claim that
session_id is informational SET NULL.

Add a standalone confdeltype-guarded block (mirroring the session_worktrees
defang) that flips session_id_fkey CASCADE -> SET NULL independently of the
re-key gate. Idempotent: fires only while the FK is still 'c' — a no-op on a
fresh deploy (already 'n' from the re-key block) and on every re-run. The
live DB was converged by hand with the identical statements; \d
agent_sessions now shows session_id ... ON DELETE SET NULL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:46:41 +00:00
30b6f70f95 docs(changelog): v2.6.3-chatkey-and-skills
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:06:19 +00:00
c2b3e0a013 skills: committing-changes + using-worktrees judgment skills + AGENTS.md guidance
Two portable agent-judgment skills in data/skills/boocode/, externalizing when/how Opus commits and when it isolates work in a worktree, so weaker agents (opencode build agent, BooCoder) can approximate it. committing-changes: segment by concern, stage explicitly (never git add -A), draft scope-prefix messages, present-and-STOP — commit only on explicit command, never push, identity indifferentketchup. using-worktrees: the when-to-isolate heuristic (just-create-when-clear / propose-when-ambiguous / skip), stable-base mechanics, runtime-isolation caveat — deliberately autonomous vs committing's command-gate. Each has an eval.yaml (matching improving-boocode-guidance) with a negative-trigger task. AGENTS.md gets a parser-safe preamble (the registry throws on bare ## headings) pointing at both skills.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:04:48 +00:00
cb1846c0d5 feat(coder): re-key agent_sessions to (chat_id, agent) + worktrees table (P1.5-b)
The tab (a chat) is the context unit: two opencode tabs in one session are two independent agent contexts sharing one worktree. agent_sessions re-keys from (session_id, agent) to (chat_id, agent) — chat_id FK ON DELETE CASCADE (closing a tab ends its context); worktree_id and session_id become informational SET NULL columns. New worktrees table (one-per-session, survives session delete via session_id SET NULL) supersedes session_worktrees, which is defanged (CASCADE dropped) not yet removed. chat_id is threaded end-to-end: tasks.chat_id added, written by the coder message + skills routes from the frontend tab, read by runOpenCodeServerTask which falls back to resolve-or-create a chat for session-less creators (arena/MCP/new_task/generic) so ensureSession never gets a null key. Idempotent migration with a backfill-verify gate (0-row assertion after the test session was deleted). config_hash fingerprint logic preserved; one-worktree-per-session unchanged; runExternalAgent untouched. Column rename worktree_path -> path repointed at all five readers (server delete-guard, risk/stash endpoints, ensureSessionWorktree). Supersedes the earlier (worktree_id) draft.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:04:35 +00:00
f1a85627e4 fix(coder): strip dcp-message-id tags split across stream chunks
The dcp tag (<dcp-message-id>mNNNN</dcp-message-id>) is streamed token-by-token, so it arrives split across SSE deltas. The existing per-chunk stripDcpTags never sees a complete tag in any single fragment, so fragments pass through and the dispatcher reassembles the tag in textChunks (persisted + shown) — and the terminal message.part.updated path that would strip the full text is suppressed by the dedup gate. Add a stateful cross-chunk stripper (dcp-strip.ts: makeDcpStreamStripper) at the dispatcher's opencode frame boundary: it emits text that cannot be part of a forming tag, holds back only a trailing partial-tag prefix (without swallowing legitimate <…> content), and flushes at turn end. Fixes both live delta frames and persisted content. 11 unit tests incl. split-at-every-boundary and the documented per-chunk-fails case. opencode path only; ACP (goose/qwen/claude) untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:16:47 +00:00
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
43 changed files with 2314 additions and 319 deletions

View File

@@ -2,6 +2,22 @@
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. 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.5-panes-tabs-composer — 2026-05-31
A workspace UX batch across BooChat panes, tabs, and the composer, plus the persistence model that backs them. **Panes & tabs:** a chat can be opened in a fresh pane (the ChatTabBar tab context menu's "Open in new pane", and the fork button — which now lands the fork beside the original via a new `open_chat_in_new_pane` event instead of replacing the active pane); the per-pane "+" became a New BooChat/BooTerm/BooCode menu; closing a chat pane relocates its tabs (in order) into the oldest chat/empty pane instead of discarding them, and reopen strips the restored chatIds from every live pane first so a relocated-then-reopened pane never duplicates a tab (no stack-shape change); each tab carries a stable session-scoped number assigned on open and retired on close (never reused), rendered map-keyed rather than positional. The per-message "Open in pane" artifact button was removed, and the empty/landing pane became a real session history — the session's open chats plus separately-fetched archived chats, click to open or restore-and-open. **Persistence:** `sessions.workspace_panes` was widened from a bare `WorkspacePane[]` to a `WorkspaceState` envelope (`panes` + `tabNumbers`/`nextTabNumber` + `closedPaneStack`) so tab numbers and the reopen stack survive reload; the PATCH validator accepts the legacy array or the envelope (zod union) and migrates on write, and the `session_workspace_updated` WS-frame schema was widened on both web and server (byte-identical, parity test green) — the same schema-drift class as `v2.6.4-agent-sessions-fk`. **Composer:** the send button morphs Send → Stop → Queue with generation state (BooCoder keys on `sending || activeTaskId`, which also corrected its queue gates and added `cancelTask`), the standalone "Stop generating" pill was folded into it, and pasted chips now trail the typed text so a leading slash command stays first. **Tooling:** adds the read-only `read_tab_by_number` tool — resolves a session-scoped tab number to its chat via the persisted `tabNumbers` map and returns that chat's transcript; tools gained an optional `ToolExecCtx` (`{ sql, sessionId }`) on `execute` to support DB-reading tools. Builds on `v2.6.4-agent-sessions-fk`.
## v2.6.4-agent-sessions-fk — 2026-05-31
Follow-up to `v2.6.3-chatkey-and-skills` (P1.5-b): the live `agent_sessions.session_id` foreign key is converged from `ON DELETE CASCADE` to `ON DELETE SET NULL`, matching the schema's stated intent. The P1.5-b re-key block re-adds `session_id_fkey` as `SET NULL`, but the whole block is guarded on `chat_id_fkey`'s absence — so a database already re-keyed to `(chat_id, agent)` while `session_id_fkey` was still `CASCADE` never re-enters it, leaving the live FK at `CASCADE` and diverging from both `worktree_id` (already `SET NULL`) and the `v2.6.3` changelog's own claim that `session_id` is informational `SET NULL`. The fix adds a standalone `confdeltype`-guarded `DO` block (mirroring the `session_worktrees` defang) that flips `session_id_fkey` `CASCADE → SET NULL` independently of the re-key gate; it is idempotent — fires only while the FK is still `'c'`, a no-op on a fresh deploy (already `'n'`) and on every re-run. The live DB was converged by hand with the identical statements, so `applySchema` and the hand-applied state match (`\d agent_sessions` now shows `session_id ... ON DELETE SET NULL`). Also bundles a CLAUDE.md doc-sync (committed separately): per-session SSE (P1.5-a) and the `(chat_id, agent)` re-key reflected in the engineering notes, the stale root `AGENTS.md` navigation pointer dropped, and new conventions for `data/AGENTS.md` parsing and the `data/skills/<vendor>/` layout.
## v2.6.3-chatkey-and-skills — 2026-05-31
Three threads. **agent_sessions re-keyed to `(chat_id, agent)` (P1.5-b):** the tab (a chat) is now the agent-context unit, so two opencode tabs in one BooCode session are two independent contexts that share one worktree. `chat_id` is threaded end-to-end — `tasks.chat_id` added, stamped by the coder message + skills routes from the frontend tab, read by `runOpenCodeServerTask` which falls back to resolve-or-create a chat for session-less creators (arena/MCP/new_task/generic `/api/tasks`) so `ensureSession` never receives a degenerate `(null, agent)` key. A new first-class `worktrees` table (one-per-session, survives session delete via `session_id ON DELETE SET NULL`) supersedes `session_worktrees`, which is defanged (CASCADE dropped, not yet removed); `agent_sessions.chat_id` CASCADEs from `chats` (closing a tab ends its context) while `worktree_id`/`session_id` are informational `SET NULL`. The migration is idempotent with a backfill-verify gate; the live re-key was applied against an empty table after the 35-chat test session `20d28876` was deleted (backed up first). This corrects and supersedes an earlier draft that wrongly keyed on `(worktree_id, agent)`; the delete-guard from `v2.6.2-delete-guard-and-sse` is repointed here from `session_worktrees` to `worktrees` (`worktree_path``path`). **dcp-strip cross-chunk fix:** the `<dcp-message-id>` tag streams split across SSE deltas, which the per-chunk strip from `v2.6.1-phase1-opencode` missed — a stateful `makeDcpStreamStripper` at the dispatcher boundary holds back partial-tag tails so neither live frames nor persisted content carry the tag (11 unit tests). **Agent-judgment skills:** `committing-changes` (segment by concern, stage explicitly, present-and-stop, never push) and `using-worktrees` (the when-to-isolate heuristic, autonomous-when-clear vs committing's command-gate) land in `data/skills/boocode/` with eval.yamls, plus a parser-safe `data/AGENTS.md` preamble pointing at both.
## 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.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.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).

View File

@@ -2,7 +2,7 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
**Cursor agents:** start with `AGENTS.md` (navigation) and `docs/ARCHITECTURE.md` (diagram). This file is the deep engineering reference. **Cursor agents:** start with `docs/ARCHITECTURE.md` (diagram). This file is the deep engineering reference. (Note: the root navigation `AGENTS.md` was removed in v1.12; `data/AGENTS.md` is the agent *registry*, not navigation.)
## What is BooCode ## What is BooCode
@@ -90,9 +90,9 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
- **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. - **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. - `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.
- **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** 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 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). Per-session SSE (P1.5-a): each live session owns its own `event.subscribe({directory})` loop + AbortController, so concurrent sessions in different worktrees stream independently; a `sessionID` demux guard drops cross-session events when two share a dir. 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). - **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`. - **agent_sessions resume**: `config_hash = sha256('opencode_server|<model>')` — must NOT include the server port (random per boot; including it breaks cross-restart resume). P1.5-b: `agent_sessions` is keyed `(chat_id, agent)` — the tab/chat is the context unit (two opencode tabs in one session = two contexts sharing one worktree). `chat_id` CASCADEs from `chats`; `session_id`/`worktree_id` are informational `SET NULL`. The `worktrees` table (one-per-session, `session_id` SET NULL so it survives session delete) supersedes the defanged `session_worktrees`. `tasks.chat_id` threads the tab id to the dispatcher; `runOpenCodeServerTask` falls back to resolve-or-create a chat when it's null (arena/MCP/new_task). 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/`) ### Frontend (`apps/web/src/`)
@@ -192,6 +192,8 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
- **ReadableStream test stubs** use `pull()` (not `start()`) so chunks are produced lazily — `start()` enqueues everything and calls `controller.close()` before the consumer reads, so a subsequent `reader.cancel()` finds the stream already closed and the `cancel()` callback never fires. Also provide MORE chunks than the test will consume so the source stays in 'readable' state when cancel runs (e.g. cap test reads ~6 chunks, stub provides 10). - **ReadableStream test stubs** use `pull()` (not `start()`) so chunks are produced lazily — `start()` enqueues everything and calls `controller.close()` before the consumer reads, so a subsequent `reader.cancel()` finds the stream already closed and the `cancel()` callback never fires. Also provide MORE chunks than the test will consume so the source stays in 'readable' state when cancel runs (e.g. cap test reads ~6 chunks, stub provides 10).
- Tool-name whitelists must derive from `ALL_TOOLS` in `services/tools.ts`, never hardcoded. `services/agents.ts` `ALL_TOOL_NAMES` had this drift class until v1.12 — same pattern applies to any future tool-aware code. - Tool-name whitelists must derive from `ALL_TOOLS` in `services/tools.ts`, never hardcoded. `services/agents.ts` `ALL_TOOL_NAMES` had this drift class until v1.12 — same pattern applies to any future tool-aware code.
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects. - Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
- `data/AGENTS.md` is PARSED (`agents.ts` `splitSections`/`parseAgentSection`): each `## <Name>` is one agent and must be followed by a `---` frontmatter fence or the block throws; content before the first `## ` is discarded. Do NOT add free-form `## ` rule sections — they break the registry. Cross-cutting agent rules go in CLAUDE.md or a parser-ignored preamble.
- Skills live in `data/skills/<vendor>/`; Sam's own namespace is `boocode/` (`committing-changes`, `using-worktrees`, `improving-boocode-guidance`) — `SKILL.md` + optional `eval.yaml` (gerund names; eval = `skill:` + `tasks:` of `prompt`+`grader`, incl. a negative-trigger task). `data/skills/` is canonical; a divergent mirror at `/opt/skills/` exists.
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports). - MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
- **Workspace dependency pattern** (`apps/coder``@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer. - **Workspace dependency pattern** (`apps/coder``@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`. - **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.

View File

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

View File

@@ -224,8 +224,8 @@ export function registerMessageRoutes(
// External provider: create a task for the dispatcher // External provider: create a task for the dispatcher
const projectId = sessionRows[0]!.project_id; const projectId = sessionRows[0]!.project_id;
const [task] = await sql<{ id: string; state: string }[]>` const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id) INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id)
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId}) VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId}, ${chatId})
RETURNING id, state RETURNING id, state
`; `;
reply.code(202); reply.code(202);

View File

@@ -91,8 +91,8 @@ export function registerSkillRoutes(
const taskInput = `${body}\n\n---\n\n${userText}`; const taskInput = `${body}\n\n---\n\n${userText}`;
const [task] = await sql<{ id: string; state: string }[]>` const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id) INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id)
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId}) VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId}, ${chatId})
RETURNING id, state RETURNING id, state
`; `;
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`; await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;

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 path AS worktree_path FROM 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 path AS worktree_path FROM 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

@@ -83,16 +83,20 @@ CREATE TABLE IF NOT EXISTS session_worktrees (
base_commit TEXT, base_commit TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp() created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
); );
-- Migrate existing FK to CASCADE (idempotent: drops the old constraint if present). -- P1.5-b: DEFANG the CASCADE — a session delete must no longer wipe its worktree
-- row. This table is SUPERSEDED by `worktrees` below; all readers are repointed
-- this phase, so the row just persists (dead) on session delete until a later
-- cleanup drops the table. session_id is this table's PRIMARY KEY, so it cannot be
-- nullable → SET NULL is invalid and NO ACTION/RESTRICT would block deletes; the
-- only valid defang is to drop the FK with no replacement. Idempotent: only fires
-- while the FK is still ON DELETE CASCADE ('c').
DO $$ BEGIN DO $$ BEGIN
IF EXISTS ( IF EXISTS (
SELECT 1 FROM pg_constraint SELECT 1 FROM pg_constraint
WHERE conname = 'session_worktrees_session_id_fkey' WHERE conname = 'session_worktrees_session_id_fkey'
AND confdeltype <> 'c' AND confdeltype = 'c'
) THEN ) THEN
ALTER TABLE session_worktrees DROP CONSTRAINT session_worktrees_session_id_fkey; 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 IF;
END $$; END $$;
@@ -127,6 +131,101 @@ END $$;
-- v2.6: config fingerprint for stale-session detection (auto-recover on model change). -- 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; ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT;
-- ─── P1.5-b (corrected): worktrees entity + re-key agent_sessions to (chat_id, agent) ───
-- The TAB (a chat) is the context unit: two opencode tabs in one session = two
-- independent contexts sharing one worktree. So agent_sessions keys on
-- (chat_id, agent), NOT (worktree_id, agent) or (session_id, agent). The
-- `worktrees` table is one-per-session (selectable later) and only referenced
-- informationally by agent_sessions.worktree_id (SET NULL); chat_id is the key.
--
-- PREREQUISITE: the unmigratable test session (35 chats, 1 agent_sessions row that
-- maps to no single chat) is DELETED before this runs, so agent_sessions is empty
-- and the chat_id backfill is N/A. If a row with NULL chat_id remains, the verify
-- gate below RAISEs and aborts — delete the offending session first.
-- worktree as a first-class entity; survives session delete (session_id SET NULL).
CREATE TABLE IF NOT EXISTS worktrees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID REFERENCES sessions(id) ON DELETE SET NULL,
project_id UUID,
path TEXT NOT NULL,
branch TEXT,
base_commit TEXT,
slug TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE UNIQUE INDEX IF NOT EXISTS worktrees_active_path_uidx ON worktrees(path) WHERE status='active';
-- Migrate any surviving session_worktrees rows → worktrees (idempotent; 0 rows
-- after the test-session delete, kept for generality / fresh-DB safety).
INSERT INTO worktrees (session_id, path, branch, base_commit, status)
SELECT sw.session_id, sw.worktree_path, 'session-' || sw.session_id, sw.base_commit, 'active'
FROM session_worktrees sw
WHERE NOT EXISTS (SELECT 1 FROM worktrees w WHERE w.session_id = sw.session_id AND w.status='active');
-- Dispatch hint: which chat (tab) a task belongs to. The coder message route and
-- skills route set it from the frontend tab; session-less creators (arena, MCP,
-- new_task, generic /api/tasks) leave it NULL and the dispatcher creates a chat.
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE SET NULL;
-- Re-key columns on agent_sessions.
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS chat_id UUID;
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS worktree_id UUID;
-- BACKFILL-VERIFY GATE: the new PK is (chat_id, agent), so chat_id must be
-- non-null on every row before the swap. With the test session deleted this is a
-- 0-row assertion; if any row has NULL chat_id (an unmigratable pre-existing row),
-- abort loudly rather than create a degenerate (NULL, agent) key.
DO $$
DECLARE n int;
BEGIN
SELECT count(*) INTO n FROM agent_sessions WHERE chat_id IS NULL;
IF n > 0 THEN
RAISE EXCEPTION 'P1.5-b: % agent_sessions row(s) have NULL chat_id — delete the unmigratable session(s) before applying', n;
END IF;
END $$;
-- Swap PK (session_id,agent) → (chat_id,agent) + FKs (run-once, guarded on the new
-- FK's absence). chat_id CASCADEs from chats (closing a tab ends its context);
-- worktree_id is informational SET NULL; session_id defanged to nullable SET NULL.
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agent_sessions_chat_id_fkey') THEN
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_pkey;
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_session_id_fkey;
ALTER TABLE agent_sessions ALTER COLUMN session_id DROP NOT NULL;
ALTER TABLE agent_sessions ALTER COLUMN chat_id SET NOT NULL;
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_pkey PRIMARY KEY (chat_id, agent);
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_chat_id_fkey
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL;
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_worktree_id_fkey
FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE SET NULL;
END IF;
END $$;
-- P1.5-b follow-up: converge agent_sessions.session_id FK CASCADE → SET NULL.
-- The re-key block above re-adds session_id_fkey as SET NULL, but it is guarded on
-- chat_id_fkey's ABSENCE — so a DB already re-keyed to (chat_id, agent) while
-- session_id_fkey was still ON DELETE CASCADE never re-enters that block and stays
-- 'c'. This standalone guard flips it to SET NULL ('n'), matching worktree_id.
-- Idempotent (mirrors the session_worktrees defang's confdeltype check): only fires
-- while the FK is still CASCADE — a no-op on a fresh deploy (already 'n' from the
-- re-key block) and on every re-run thereafter.
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'agent_sessions_session_id_fkey'
AND confdeltype = 'c'
) THEN
ALTER TABLE agent_sessions ALTER COLUMN session_id DROP NOT NULL;
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 SET NULL;
END IF;
END $$;
-- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this). -- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this).
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT; ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { stripDcpTags, makeDcpStreamStripper } from '../dcp-strip.js';
// Feed chunks through a fresh stripper and return the fully reassembled output
// (everything emitted during streaming + the final flush) — i.e. what the
// dispatcher would accumulate into the persisted message content.
function run(chunks: string[]): string {
const s = makeDcpStreamStripper();
let out = '';
for (const c of chunks) out += s.push(c);
out += s.flush();
return out;
}
describe('stripDcpTags (one-shot)', () => {
it('removes a complete tag', () => {
expect(stripDcpTags('Yes — "Test".\n\n<dcp-message-id>m0019</dcp-message-id>')).toBe(
'Yes — "Test".\n\n',
);
});
it('leaves text without a tag untouched', () => {
expect(stripDcpTags('no tag here')).toBe('no tag here');
});
});
describe('per-chunk strip is INSUFFICIENT (documents the bug)', () => {
it('a tag split across chunks survives a naive per-chunk .replace()', () => {
const chunks = ['Yes.\n\n<dcp', '-message', '-id>m0019</dcp', '-message-id>'];
const naive = chunks.map(stripDcpTags).join('');
// The reassembled content still contains the tag — this is the screenshot bug.
expect(naive).toContain('<dcp-message-id>m0019</dcp-message-id>');
});
});
describe('makeDcpStreamStripper (cross-chunk fix)', () => {
it('strips a tag split across chunks (the real opencode case)', () => {
expect(run(['Yes.\n\n<dcp', '-message', '-id>m0019</dcp', '-message-id>'])).toBe('Yes.\n\n');
});
it('strips a tag split at EVERY character boundary', () => {
const full = 'Answer.<dcp-message-id>m0019</dcp-message-id>';
expect(run([...full])).toBe('Answer.');
});
it('strips a tag delivered whole in one chunk', () => {
expect(run(['Answer.<dcp-message-id>m0019</dcp-message-id>'])).toBe('Answer.');
});
it('passes through text with no tag', () => {
expect(run(['hello ', 'world'])).toBe('hello world');
});
it('does NOT swallow legitimate < content (code/HTML/generics)', () => {
expect(run(['use ', '<div>', ' and ', 'Array<', 'string>'])).toBe('use <div> and Array<string>');
});
it('handles a lone < that is not a dcp tag, split across chunks', () => {
expect(run(['a <', 'b c'])).toBe('a <b c');
});
it('emits surrounding text and strips a mid-text tag', () => {
expect(run(['before ', '<dcp-message-id>', 'm1', '</dcp-message-id>', ' after'])).toBe(
'before after',
);
});
it('flushes a truncated/never-closed partial tag without leaking it as a complete tag', () => {
// If the stream ends mid-tag, flush strips complete tags; an incomplete
// remnant is returned as-is (no complete tag ever existed to render).
const out = run(['done.<dcp-message-id>m00']);
expect(out).not.toContain('</dcp-message-id>');
});
});

View File

@@ -37,8 +37,15 @@ export interface EnsureSessionOpts {
agent: string; agent: string;
/** Resolved model id. */ /** Resolved model id. */
model: string; model: string;
/** P1.5-b: the chat (tab) this turn belongs to. agent_sessions is keyed
* (chat_id, agent) — the tab/chat is the context unit. Always non-null:
* the dispatcher creates a chat for session-less tasks before calling. */
chatId: string;
/** Shared per-session worktree (one per `sessions.id`, not per pane). */ /** Shared per-session worktree (one per `sessions.id`, not per pane). */
worktreePath: string; worktreePath: string;
/** P1.5-b: the `worktrees.id` for this session's worktree — stored on the
* agent_sessions row informationally (NOT the key). */
worktreeId: string;
projectId: string; projectId: string;
} }
@@ -47,6 +54,10 @@ export interface AgentSessionHandle {
sessionId: string; sessionId: string;
agent: string; agent: string;
backend: AgentBackendKind; backend: AgentBackendKind;
/** P1.5-b: the chat (tab) this session is keyed on (with agent). */
chatId: string;
/** P1.5-b: the worktree this session's chat runs in (informational link). */
worktreeId: string;
/** Provider's own session id (resume token); null until the backend assigns one. */ /** Provider's own session id (resume token); null until the backend assigns one. */
agentSessionId: string | null; agentSessionId: string | null;
/** opencode HTTP server port; null for ACP backends. */ /** opencode HTTP server port; null for ACP backends. */

View File

@@ -3,7 +3,9 @@
* *
* Warm, multi-turn backend for the `opencode` agent. One `opencode serve` HTTP * Warm, multi-turn backend for the `opencode` agent. One `opencode serve` HTTP
* server per BooCoder process; one opencode session per BooCode session (resumed * server per BooCoder process; one opencode session per BooCode session (resumed
* on switch-back); a single SSE read loop demuxes all sessions' events. * 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 * Implements the Phase 0 `AgentBackend` interface. Emits transport-agnostic
* `AgentEvent`s — the dispatcher (Phase 1.7, NOT wired in this batch) maps them * `AgentEvent`s — the dispatcher (Phase 1.7, NOT wired in this batch) maps them
@@ -73,6 +75,9 @@ interface SessionState {
activeTurn: TurnState | null; activeTurn: TurnState | null;
/** Inactivity backstop timer for the active turn; null when no turn in flight. */ /** Inactivity backstop timer for the active turn; null when no turn in flight. */
watchdog: ReturnType<typeof setTimeout> | null; 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 { export interface OpenCodeServerBackendDeps {
@@ -94,7 +99,6 @@ export class OpenCodeServerBackend implements AgentBackend {
private port: number | null = null; private port: number | null = null;
private up = false; private up = false;
private serverStarting: Promise<void> | null = null; private serverStarting: Promise<void> | null = null;
private sseRunning = false;
/** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */ /** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */
private readonly byOpencodeId = new Map<string, SessionState>(); private readonly byOpencodeId = new Map<string, SessionState>();
@@ -150,37 +154,58 @@ export class OpenCodeServerBackend implements AgentBackend {
// ─── SSE read loop + demux + translate (1.3) + dedup (1.4) ─────────────────── // ─── SSE read loop + demux + translate (1.3) + dedup (1.4) ───────────────────
/** Per-directory SSE subscription. opencode scopes events by directory (defaults /** Per-session SSE subscription, scoped to the session's worktree directory.
* to process.cwd if omitted) — so we must subscribe with the same directory used * opencode scopes events by the `directory` query param (defaults to the
* to create the session. Called from ensureSession; reconnects while up. */ * server's cwd if omitted), so two sessions in different worktrees each get
private startEventLoop(directory: string): void { * their own dir-scoped stream and never drop each other's events. Idempotent:
if (this.sseRunning) return; * a no-op if this session's loop is already running. Started from ensureSession
this.sseRunning = true; * (and defensively from prompt) once worktreePath is known. */
this.sseDirectory = directory; private startSessionEventLoop(state: SessionState): void {
void this.runEventLoop(directory); 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 sseDirectory: string | null = null; private async runSessionEventLoop(state: SessionState, abort: AbortController): Promise<void> {
const signal = abort.signal;
private async runEventLoop(directory: string): Promise<void> { while (this.up && this.client && !signal.aborted) {
while (this.up && this.client) {
try { try {
const sub = await this.client.event.subscribe({ directory }); // 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) { 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); this.dispatchEvent(ev);
} }
if (this.up) { if (this.up && !signal.aborted) {
await this.reconcileInFlight(); await this.reconcile(state); // recover an idle/error lost during the gap
await sleep(SSE_RECONNECT_DELAY_MS); await sleep(SSE_RECONNECT_DELAY_MS);
} }
} catch (err) { } catch (err) {
if (!this.up) break; if (!this.up || signal.aborted) break;
this.log.warn({ err: errMsg(err) }, 'opencode-server: event loop error; reconnecting'); this.log.warn(
await this.reconcileInFlight(); { err: errMsg(err), agentSessionId: state.agentSessionId },
'opencode-server: session event loop error; reconnecting',
);
await this.reconcile(state);
await sleep(SSE_RECONNECT_DELAY_MS); await sleep(SSE_RECONNECT_DELAY_MS);
} }
} }
this.sseRunning = false;
} }
/** Demux one event to the owning session's active turn. Unknown/between-turns → drop. */ /** Demux one event to the owning session's active turn. Unknown/between-turns → drop. */
@@ -354,13 +379,6 @@ export class OpenCodeServerBackend implements AgentBackend {
} }
} }
/** Reconcile every in-flight turn against the server (called after an SSE drop). */
private async reconcileInFlight(): Promise<void> {
const states = [...this.byOpencodeId.values()].filter((s) => s.activeTurn);
if (states.length === 0) return;
await Promise.allSettled(states.map((s) => this.reconcile(s)));
}
/** /**
* Ask the server whether this session's turn already finished — recovers a * 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. * session.idle/error lost during an SSE gap. Returns true if it settled the turn.
@@ -405,9 +423,12 @@ export class OpenCodeServerBackend implements AgentBackend {
if (!this.client) throw new Error('opencode-server: client not ready after ensureServer'); if (!this.client) throw new Error('opencode-server: client not ready after ensureServer');
const configHash = sessionConfigHash(opts.model); const configHash = sessionConfigHash(opts.model);
// P1.5-b: agent_sessions is keyed (chat_id, agent) — the tab/chat is the
// context unit (two tabs in one session = two contexts sharing one worktree).
// session_id + worktree_id are retained as informational (SET NULL) columns.
const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>` 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 SELECT agent_session_id, status, config_hash FROM agent_sessions
WHERE session_id = ${sessionId} AND agent = ${opts.agent} WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
`; `;
let agentSessionId = row?.agent_session_id ?? null; let agentSessionId = row?.agent_session_id ?? null;
@@ -429,10 +450,12 @@ export class OpenCodeServerBackend implements AgentBackend {
agentSessionId = created.data.id; agentSessionId = created.data.id;
await this.sql` await this.sql`
INSERT INTO agent_sessions INSERT INTO agent_sessions
(session_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash) (chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
VALUES VALUES
(${sessionId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash}) (${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
ON CONFLICT (session_id, agent) DO UPDATE SET ON CONFLICT (chat_id, agent) DO UPDATE SET
session_id = EXCLUDED.session_id,
worktree_id = EXCLUDED.worktree_id,
backend = 'opencode_server', backend = 'opencode_server',
agent_session_id = EXCLUDED.agent_session_id, agent_session_id = EXCLUDED.agent_session_id,
server_port = EXCLUDED.server_port, server_port = EXCLUDED.server_port,
@@ -444,42 +467,21 @@ export class OpenCodeServerBackend implements AgentBackend {
await this.sql` await this.sql`
UPDATE agent_sessions UPDATE agent_sessions
SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash} SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash}
WHERE session_id = ${sessionId} AND agent = ${opts.agent} WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
`; `;
} }
// Both branches above guarantee agentSessionId is non-null. // Both branches above guarantee agentSessionId is non-null.
const ocSessionId = agentSessionId!; const ocSessionId = agentSessionId!;
// Start (or re-start) the SSE event loop scoped to this session's directory.
// opencode scopes events by the `directory` query param; without it events
// default to the server's CWD which doesn't match our worktree paths.
//
// KNOWN Phase 1 LIMITATION: one SSE stream at a time, scoped to a single
// directory. Under 1.9 concurrency, if two opencode sessions use different
// worktree directories simultaneously, re-subscribing for the second drops
// the first session's events (the watchdog backstop prevents a full hang,
// but streamed content is lost). Phase 2 should move to per-session SSE
// subscriptions or a directory-agnostic event path.
if (!this.sseRunning || this.sseDirectory !== opts.worktreePath) {
if (this.sseRunning && this.sseDirectory && this.sseDirectory !== opts.worktreePath) {
this.log.warn(
{ prev: this.sseDirectory, next: opts.worktreePath },
'opencode-server: SSE directory changed — concurrent sessions will lose events from the previous directory',
);
}
this.sseRunning = false;
this.startEventLoop(opts.worktreePath);
}
// Register / refresh the demux entry the SSE loop keys on. Preserve an existing // Register / refresh the demux entry the SSE loop keys on. Preserve an existing
// entry (and any in-flight turn) — just refresh the routing fields. // entry (and any in-flight turn) — just refresh the routing fields.
const existing = this.byOpencodeId.get(ocSessionId); let state = this.byOpencodeId.get(ocSessionId);
if (existing) { if (state) {
existing.boocodeSessionId = sessionId; state.boocodeSessionId = sessionId;
existing.worktreePath = opts.worktreePath; state.worktreePath = opts.worktreePath;
} else { } else {
this.byOpencodeId.set(ocSessionId, { state = {
boocodeSessionId: sessionId, boocodeSessionId: sessionId,
agentSessionId: ocSessionId, agentSessionId: ocSessionId,
worktreePath: opts.worktreePath, worktreePath: opts.worktreePath,
@@ -487,13 +489,22 @@ export class OpenCodeServerBackend implements AgentBackend {
partTypeById: new Map(), partTypeById: new Map(),
activeTurn: null, activeTurn: null,
watchdog: 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 { return {
sessionId, sessionId,
agent: opts.agent, agent: opts.agent,
backend: 'opencode_server', backend: 'opencode_server',
chatId: opts.chatId,
worktreeId: opts.worktreeId,
agentSessionId: ocSessionId, agentSessionId: ocSessionId,
serverPort: this.port, serverPort: this.port,
}; };
@@ -516,12 +527,17 @@ export class OpenCodeServerBackend implements AgentBackend {
partTypeById: new Map(), partTypeById: new Map(),
activeTurn: null, activeTurn: null,
watchdog: null, watchdog: null,
sseAbort: null,
}; };
this.byOpencodeId.set(oc, state); this.byOpencodeId.set(oc, state);
} }
const session = state; const session = state;
// Authoritative per-turn directory for SDK routing + reconcile. // Authoritative per-turn directory for SDK routing + reconcile.
session.worktreePath = ctx.worktreePath; 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; const client = this.client;
return await new Promise<TurnResult>((resolve) => { return await new Promise<TurnResult>((resolve) => {
@@ -577,15 +593,21 @@ export class OpenCodeServerBackend implements AgentBackend {
// ─── teardown ──────────────────────────────────────────────────────────────── // ─── teardown ────────────────────────────────────────────────────────────────
async closeSession(handle: AgentSessionHandle): Promise<void> { async closeSession(handle: AgentSessionHandle): Promise<void> {
if (handle.agentSessionId) this.byOpencodeId.delete(handle.agentSessionId); 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` await this.sql`
UPDATE agent_sessions SET status = 'closed' UPDATE agent_sessions SET status = 'closed'
WHERE session_id = ${handle.sessionId} AND agent = ${handle.agent} WHERE chat_id = ${handle.chatId} AND agent = ${handle.agent}
`.catch(() => {}); `.catch(() => {});
} }
async dispose(): Promise<void> { async dispose(): Promise<void> {
this.up = false; 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; const child = this.child;
this.child = null; this.child = null;
this.client = null; this.client = null;
@@ -602,6 +624,20 @@ export class OpenCodeServerBackend implements AgentBackend {
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── 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}. */ /** BooCoder model string "provider/model" → opencode's structured {providerID, modelID}. */
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined { function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
if (!model || !model.trim()) return undefined; if (!model || !model.trim()) return undefined;

View File

@@ -0,0 +1,77 @@
/**
* Strip opencode-dcp plugin tags (`<dcp-message-id>mNNNN</dcp-message-id>`) that
* the @tarquinen/opencode-dcp plugin appends to assistant text and which
* otherwise render as literal text in the UI.
*
* Why a streaming stripper and not a per-chunk `.replace()`: opencode streams
* assistant text token-by-token, so the tag arrives SPLIT across many SSE deltas
* (`<dcp`, `-message`, `-id>`, `m0019`, `</dcp`, …). A per-chunk regex never sees
* a complete tag in any single fragment, so the fragments pass through and the
* dispatcher reassembles the full tag in the persisted/displayed content. The
* stripper below buffers across chunks: it emits everything that cannot be part
* of a forming tag and holds back only a trailing partial-tag prefix until the
* next chunk resolves it — without holding back legitimate `<…>` content.
*/
const DCP_TAG_RE = /<dcp-message-id>[^<]*<\/dcp-message-id>/g;
const OPEN = '<dcp-message-id>';
const CLOSE = '</dcp-message-id>';
/** One-shot strip of COMPLETE tags. Safe for non-streaming / final content. */
export function stripDcpTags(s: string): string {
return s.replace(DCP_TAG_RE, '');
}
/**
* Could `tail` (a substring starting at a `<`) still grow into a complete dcp
* tag on a future chunk? If so the caller must hold it back rather than emit it.
* Returns false for unrelated `<` content (`<div>`, `<T>`, …) so those stream
* normally.
*/
function isPartialDcp(tail: string): boolean {
// A prefix of the opening marker: '<', '<d', …, '<dcp-message-id'.
if (OPEN.startsWith(tail)) return true;
// Opening marker fully seen — content (and maybe a forming close) still streaming.
if (tail.startsWith(OPEN)) {
const rest = tail.slice(OPEN.length);
const lt = rest.indexOf('<');
if (lt === -1) return true; // still inside the [^<]* content run
return CLOSE.startsWith(rest.slice(lt)); // a partial close marker forming
}
return false;
}
export interface DcpStreamStripper {
/** Feed one text chunk; returns the portion safe to emit now (may be ''). */
push(chunk: string): string;
/** Stream end: returns whatever was held back, with complete tags stripped. */
flush(): string;
}
/** Stateful, cross-chunk-safe dcp stripper. One instance per turn. */
export function makeDcpStreamStripper(): DcpStreamStripper {
let buf = '';
return {
push(chunk: string): string {
buf += chunk;
buf = buf.replace(DCP_TAG_RE, ''); // drop any now-complete tags
// Find the earliest `<` whose suffix is a forming dcp tag; hold from there,
// emit everything before it (real text, including unrelated `<…>`).
for (let i = buf.indexOf('<'); i !== -1; i = buf.indexOf('<', i + 1)) {
if (isPartialDcp(buf.slice(i))) {
const emit = buf.slice(0, i);
buf = buf.slice(i);
return emit;
}
}
const emit = buf;
buf = '';
return emit;
},
flush(): string {
const out = stripDcpTags(buf);
buf = '';
return out;
},
};
}

View File

@@ -4,6 +4,7 @@ import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames'; import type { WsFrame } from '@boocode/server/ws-frames';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js'; import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
import { makeDcpStreamStripper } from './dcp-strip.js';
import { dispatchViaAcp } from './acp-dispatch.js'; import { dispatchViaAcp } from './acp-dispatch.js';
import { getResolvedRegistry } from './provider-config-registry.js'; import { getResolvedRegistry } from './provider-config-registry.js';
import { dispatchViaPty } from './pty-dispatch.js'; import { dispatchViaPty } from './pty-dispatch.js';
@@ -77,8 +78,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
mode_id: string | null; mode_id: string | null;
thinking_option_id: string | null; thinking_option_id: string | null;
session_id: string | null; session_id: string | null;
chat_id: string | null;
}[]>` }[]>`
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id
FROM tasks FROM tasks
WHERE state = 'pending' WHERE state = 'pending'
ORDER BY created_at ORDER BY created_at
@@ -109,6 +111,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
mode_id: string | null; mode_id: string | null;
thinking_option_id: string | null; thinking_option_id: string | null;
session_id: string | null; session_id: string | null;
chat_id: string | null;
}): Promise<void> { }): Promise<void> {
const taskId = task.id; const taskId = task.id;
@@ -510,6 +513,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
mode_id: string | null; mode_id: string | null;
thinking_option_id: string | null; thinking_option_id: string | null;
session_id: string | null; session_id: string | null;
chat_id: string | null;
}, },
installPath: string | null, installPath: string | null,
): Promise<void> { ): Promise<void> {
@@ -542,10 +546,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId} WHERE id = ${taskId}
`; `;
// Resolve session + chat (mirrors runExternalAgent). // Resolve session + chat. P1.5-b: the chat (tab) is the context key, so the
// chat_id MUST be non-null and stable before ensureSession. The coder message
// route + skills route stamp task.chat_id with the frontend tab's chat — use
// it directly. Session-less creators (arena, MCP, new_task, generic
// /api/tasks) leave it null; fall back to resolving/creating a real chat so
// ensureSession never receives a degenerate (null, agent) key.
let sessionId: string; let sessionId: string;
let chatId: string; let chatId: string;
if (task.session_id) { if (task.chat_id && task.session_id) {
sessionId = task.session_id;
chatId = task.chat_id;
} else if (task.session_id) {
sessionId = task.session_id; sessionId = task.session_id;
const chats = await sql<{ id: string }[]>` const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1 SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
@@ -586,7 +598,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// Persistent, session-keyed worktree (shared across turns; NOT torn down // Persistent, session-keyed worktree (shared across turns; NOT torn down
// per turn — Phase 3 reaps it). Captures base_commit for a stable diff. // per turn — Phase 3 reaps it). Captures base_commit for a stable diff.
const { worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, { const { worktreeId, worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
signal: ac.signal, signal: ac.signal,
}); });
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready'); log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
@@ -620,21 +632,30 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
const textChunks: string[] = []; const textChunks: string[] = [];
const reasoningChunks: string[] = []; const reasoningChunks: string[] = [];
const toolSnaps = new Map<string, AcpToolSnapshot>(); const toolSnaps = new Map<string, AcpToolSnapshot>();
// opencode's dcp plugin appends <dcp-message-id>…</dcp-message-id> to the
// text, streamed split across deltas — a per-chunk regex misses it (see
// dcp-strip.ts). Buffer text through a cross-chunk stripper so neither the
// live `delta` frames nor the persisted content ever carry the tag.
const dcp = makeDcpStreamStripper();
// Map transport-agnostic AgentEvents → the SAME WS frames the ACP path emits. // 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 // This boundary is where message_id/chat_id get attached (the backend never
// owns them). // owns them).
const onEvent = (e: AgentEvent): void => { const onEvent = (e: AgentEvent): void => {
switch (e.type) { switch (e.type) {
case 'text': case 'text': {
textChunks.push(e.text); const safe = dcp.push(e.text);
if (safe) {
textChunks.push(safe);
broker.publishFrame(sessionId, { broker.publishFrame(sessionId, {
type: 'delta', type: 'delta',
message_id: assistantId, message_id: assistantId,
chat_id: chatId, chat_id: chatId,
content: e.text, content: safe,
} as WsFrame); } as WsFrame);
}
break; break;
}
case 'reasoning': case 'reasoning':
reasoningChunks.push(e.text); reasoningChunks.push(e.text);
broker.publishFrame(sessionId, { broker.publishFrame(sessionId, {
@@ -670,7 +691,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
const handle = await backend.ensureSession(sessionId, { const handle = await backend.ensureSession(sessionId, {
agent, agent,
model, model,
chatId,
worktreePath, worktreePath,
worktreeId,
projectId: task.project_id, projectId: task.project_id,
}); });
const result = await backend.prompt(handle, task.input, { const result = await backend.prompt(handle, task.input, {
@@ -680,6 +703,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
onEvent, onEvent,
}); });
// Flush any text held back mid-tag at stream end (complete tags stripped).
const dcpTail = dcp.flush();
if (dcpTail) {
textChunks.push(dcpTail);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: assistantId,
chat_id: chatId,
content: dcpTail,
} as WsFrame);
}
const assistantContent = textChunks.join('').slice(0, 50_000); const assistantContent = textChunks.join('').slice(0, 50_000);
const reasoningText = reasoningChunks.join('').slice(0, 200_000); const reasoningText = reasoningChunks.join('').slice(0, 200_000);
const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'opencode turn failed').slice(0, 500); const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'opencode turn failed').slice(0, 500);

View File

@@ -119,16 +119,18 @@ export async function cleanupWorktree(
// ─── v2.6: session-keyed persistent worktree ──────────────────────────────── // ─── v2.6: session-keyed persistent worktree ────────────────────────────────
export interface SessionWorktree { export interface SessionWorktree {
/** P1.5-b: the `worktrees.id` — stored on agent_sessions informationally. */
worktreeId: string;
worktreePath: string; worktreePath: string;
baseCommit: string | null; baseCommit: string | null;
} }
/** /**
* v2.6: create-or-reuse ONE worktree per BooCode session (shared across all * v2.6 / P1.5-b: create-or-reuse ONE worktree per BooCode session (shared across
* agents/turns in the session), recorded in `session_worktrees`. Unlike the * all tabs/agents in the session), recorded in `worktrees` (was the superseded
* per-task `createWorktree`, this persists — it is NOT torn down per turn * `session_worktrees`). Persists — NOT torn down per turn (cleanup is Phase 3) —
* (cleanup is Phase 3). Captures the project's current HEAD as `base_commit` * and now survives session delete (`worktrees.session_id` is ON DELETE SET NULL).
* so the accumulating diff has a stable baseline across turns. * Captures the project's current HEAD as `base_commit` for a stable diff baseline.
* *
* Distinct path namespace (`session-<id>` branch, `/sess-<id>` dir) so it never * 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. * collides with the per-task worktrees that arena/new_task/MCP still use.
@@ -139,11 +141,13 @@ export async function ensureSessionWorktree(
sessionId: string, sessionId: string,
opts?: { signal?: AbortSignal }, opts?: { signal?: AbortSignal },
): Promise<SessionWorktree> { ): Promise<SessionWorktree> {
const [existing] = await sql<{ worktree_path: string; base_commit: string | null }[]>` const [existing] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId} SELECT id, path, base_commit FROM worktrees
WHERE session_id = ${sessionId} AND status = 'active'
LIMIT 1
`; `;
if (existing) { if (existing) {
return { worktreePath: existing.worktree_path, baseCommit: existing.base_commit }; return { worktreeId: existing.id, worktreePath: existing.path, baseCommit: existing.base_commit };
} }
const worktreePath = `${WORKTREE_BASE}/sess-${sessionId}`; const worktreePath = `${WORKTREE_BASE}/sess-${sessionId}`;
@@ -167,21 +171,191 @@ export async function ensureSessionWorktree(
throw new Error(`Failed to create session worktree: ${result.stderr.trim() || result.stdout.trim()}`); 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. // Insert-or-get: WHERE NOT EXISTS keeps the first writer's row if two turns race
await sql` // the create (the partial unique on active path also backstops it).
INSERT INTO session_worktrees (session_id, worktree_path, base_commit) const [inserted] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
VALUES (${sessionId}, ${worktreePath}, ${baseCommit}) INSERT INTO worktrees (session_id, path, branch, base_commit, status)
ON CONFLICT (session_id) DO NOTHING SELECT ${sessionId}, ${worktreePath}, ${branchName}, ${baseCommit}, 'active'
WHERE NOT EXISTS (
SELECT 1 FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'
)
RETURNING id, path, base_commit
`; `;
const [row] = await sql<{ worktree_path: string; base_commit: string | null }[]>` if (inserted) {
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId} return { worktreeId: inserted.id, worktreePath: inserted.path, baseCommit: inserted.base_commit };
}
// Lost the race — another turn inserted first; read its row.
const [row] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
SELECT id, path, base_commit FROM worktrees
WHERE session_id = ${sessionId} AND status = 'active'
LIMIT 1
`; `;
return { return {
worktreePath: row?.worktree_path ?? worktreePath, worktreeId: row!.id,
worktreePath: row?.path ?? worktreePath,
baseCommit: row?.base_commit ?? baseCommit, 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). */ /** Minimal shell escape for paths (single-quote wrapping). */
function shellEscape(s: string): string { function shellEscape(s: string): string {
// Replace single quotes with escaped version, wrap in single quotes // 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 { Sql } from '../db.js';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import type { Broker } from '../services/broker.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'; import { getSetting } from './settings.js';
const CreateBody = z.object({ const CreateBody = z.object({
@@ -28,9 +28,7 @@ const HtmlArtifactStateZ = z.object({
title: z.string().max(500), title: z.string().max(500),
}); });
const WorkspacePaneZ = z.object({ const PaneKindZ = z.enum([
id: z.string().min(1).max(200),
kind: z.enum([
'chat', 'chat',
'terminal', 'terminal',
'coder', 'coder',
@@ -39,7 +37,11 @@ const WorkspacePaneZ = z.object({
'settings', 'settings',
'markdown_artifact', 'markdown_artifact',
'html_artifact', 'html_artifact',
]), ]);
const WorkspacePaneZ = z.object({
id: z.string().min(1).max(200),
kind: PaneKindZ,
chatId: z.string().min(1).max(200).optional(), chatId: z.string().min(1).max(200).optional(),
chatIds: z.array(z.string().min(1).max(200)).max(50), chatIds: z.array(z.string().min(1).max(200)).max(50),
activeChatIdx: z.number().int(), activeChatIdx: z.number().int(),
@@ -47,8 +49,27 @@ const WorkspacePaneZ = z.object({
html_artifact_state: HtmlArtifactStateZ.optional(), html_artifact_state: HtmlArtifactStateZ.optional(),
}); });
// v2.6.x: workspace_panes column widened from a bare WorkspacePane[] to a
// WorkspaceState envelope (panes + stable session-scoped tab numbering +
// reopen stack). closedPaneStack entries are lighter than full panes — just
// the kind + chat ids needed to recreate a closed pane on reopen.
const ClosedPaneEntryZ = z.object({
kind: PaneKindZ,
chatIds: z.array(z.string().min(1).max(200)).max(50),
activeChatIdx: z.number().int(),
});
const WorkspaceStateZ = z.object({
panes: z.array(WorkspacePaneZ).max(10),
tabNumbers: z.record(z.string(), z.number().int()).default({}),
nextTabNumber: z.number().int().default(1),
closedPaneStack: z.array(ClosedPaneEntryZ).max(10).default([]),
});
// Accept either the legacy bare array OR the envelope. The handler normalizes
// to a full envelope before storing (see MIGRATION rule in the PATCH handler).
const WorkspacePanesBody = z.object({ const WorkspacePanesBody = z.object({
workspace_panes: z.array(WorkspacePaneZ).max(10), workspace_panes: z.union([z.array(WorkspacePaneZ).max(10), WorkspaceStateZ]),
}); });
const PatchBody = z.object({ const PatchBody = z.object({
@@ -308,12 +329,20 @@ export function registerSessionRoutes(
reply.code(400); reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() }; return { error: 'invalid body', details: parsed.error.flatten() };
} }
const workspacePanes = parsed.data.workspace_panes.map((pane) => // v2.6.x MIGRATION: the body is either a legacy bare WorkspacePane[] or
// the WorkspaceState envelope. Normalize to a full envelope so the column
// always stores the envelope shape going forward.
const body = parsed.data.workspace_panes;
const envelope = Array.isArray(body)
? { panes: body, tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] }
: body;
// agent → coder normalization on the panes array (unchanged write rule).
envelope.panes = envelope.panes.map((pane) =>
pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane, pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane,
); );
const rows = await sql<Session[]>` const rows = await sql<Session[]>`
UPDATE sessions UPDATE sessions
SET workspace_panes = ${sql.json(workspacePanes as never)}, SET workspace_panes = ${sql.json(envelope as never)},
updated_at = clock_timestamp() updated_at = clock_timestamp()
WHERE id = ${req.params.id} WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at, RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
@@ -426,10 +455,55 @@ export function registerSessionRoutes(
} }
); );
app.delete<{ Params: { id: string } }>( app.delete<{ Params: { id: string }; Querystring: { force?: string } }>(
'/api/sessions/:id', '/api/sessions/:id',
async (req, reply) => { async (req, reply) => {
const id = req.params.id; const id = req.params.id;
const force = req.query.force === 'true' || req.query.force === '1';
// Session-delete work-loss guard. The check MUST run BEFORE the DELETE:
// worktrees.session_id is ON DELETE SET NULL (P1.5-b), so once the session
// is gone the worktree rows no longer point back to it — read them while
// the link still exists.
//
// Optimization: read worktrees (P1.5-b — was 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<{ path: string }[]>`
SELECT path FROM 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 }[]>` const deleted = await sql<{ project_id: string }[]>`
DELETE FROM sessions WHERE id = ${id} RETURNING project_id DELETE FROM sessions WHERE id = ${id} RETURNING project_id
`; `;

View File

@@ -2,6 +2,7 @@ import type { Agent, Session, ToolCall } from '../../types/api.js';
import * as modelContext from '../model-context.js'; import * as modelContext from '../model-context.js';
import { PathScopeError } from '../path_guard.js'; import { PathScopeError } from '../path_guard.js';
import { TOOLS_BY_NAME } from '../tools.js'; import { TOOLS_BY_NAME } from '../tools.js';
import type { ToolExecCtx } from '../tools.js';
import { matchToolGlob } from '../agents.js'; import { matchToolGlob } from '../agents.js';
import { maybeFlagForCompaction } from './payload.js'; import { maybeFlagForCompaction } from './payload.js';
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js'; import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
@@ -31,6 +32,7 @@ async function executeToolCall(
projectRoot: string, projectRoot: string,
toolCall: ToolCall, toolCall: ToolCall,
extraRoots: readonly string[], extraRoots: readonly string[],
toolCtx?: ToolExecCtx,
): Promise<{ output: unknown; truncated: boolean; error?: string }> { ): Promise<{ output: unknown; truncated: boolean; error?: string }> {
const tool = TOOLS_BY_NAME[toolCall.name]; const tool = TOOLS_BY_NAME[toolCall.name];
if (!tool) { if (!tool) {
@@ -65,7 +67,7 @@ async function executeToolCall(
}; };
} }
try { try {
const output = await tool.execute(parsed.data, projectRoot, extraRoots); const output = await tool.execute(parsed.data, projectRoot, extraRoots, toolCtx);
const truncated = const truncated =
typeof output === 'object' && output !== null && 'truncated' in output typeof output === 'object' && output !== null && 'truncated' in output
? Boolean((output as { truncated: unknown }).truncated) ? Boolean((output as { truncated: unknown }).truncated)
@@ -289,7 +291,10 @@ export async function executeToolPhase(
}); });
return; return;
} }
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths); const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths, {
sql: ctx.sql,
sessionId,
});
if (SYNTHESIS_TOOLS.has(tc.name)) { if (SYNTHESIS_TOOLS.has(tc.name)) {
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) }); synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
} }

View File

@@ -0,0 +1,142 @@
// v2.6.x: read_tab_by_number tool. Reads the conversation transcript of the
// chat that occupies a given session-scoped tab number. Stable tab numbers are
// stored in the session's workspace_panes envelope (WorkspaceState.tabNumbers),
// keyed by chat id. Lives in its own file (not appended to tools.ts) so tests
// can import the executor directly without dragging in the whole tool registry.
// Registered in tools.ts ALL_TOOLS + READ_ONLY_TOOL_NAMES.
import { z } from 'zod';
import type { Sql } from '../db.js';
// type-only import to dodge the runtime cycle (tools.ts re-exports this tool
// via ALL_TOOLS; importing ToolDef/ToolExecCtx at type level keeps the dep
// one-way).
import type { ToolDef, ToolExecCtx } from './tools.js';
const ReadTabByNumberInput = z.object({
number: z.number().int().positive(),
});
export type ReadTabByNumberInputT = z.infer<typeof ReadTabByNumberInput>;
// Cap total transcript size so a long conversation can't blow the context
// window. The model gets a clear truncation marker when the cap is hit.
const MAX_TRANSCRIPT_CHARS = 20_000;
// WorkspaceState envelope shape (panes omitted — we only need tabNumbers here).
interface WorkspaceStateLike {
panes?: unknown;
tabNumbers?: Record<string, number>;
nextTabNumber?: number;
closedPaneStack?: unknown[];
}
// MIGRATION: the stored workspace_panes value may be the legacy bare
// WorkspacePane[] OR the WorkspaceState envelope. Normalize to an envelope so
// tabNumbers is always available (empty for the legacy shape — no tab numbers
// were tracked before the envelope landed).
function normalizeWorkspaceState(v: unknown): {
tabNumbers: Record<string, number>;
} {
if (Array.isArray(v)) {
return { tabNumbers: {} };
}
if (v && typeof v === 'object' && Array.isArray((v as WorkspaceStateLike).panes)) {
const env = v as WorkspaceStateLike;
return { tabNumbers: env.tabNumbers ?? {} };
}
return { tabNumbers: {} };
}
// Pure executor split out from the ToolDef wrapper so tests can call it with a
// mocked Sql. Returns a transcript string (read-only — never writes).
export async function executeReadTabByNumber(
input: ReadTabByNumberInputT,
sql: Sql,
sessionId: string,
): Promise<string> {
const sessionRows = await sql<{ workspace_panes: unknown }[]>`
SELECT workspace_panes FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) {
return `Session not found.`;
}
const { tabNumbers } = normalizeWorkspaceState(sessionRows[0]!.workspace_panes);
// Reverse-lookup: find the chat id whose stable tab number equals the input.
let chatId: string | null = null;
for (const [cid, num] of Object.entries(tabNumbers)) {
if (num === input.number) {
chatId = cid;
break;
}
}
if (chatId === null) {
return `No tab is numbered ${input.number} in this session.`;
}
// Read the conversation: skip system sentinels (role='system') and empty
// content rows. Oldest first.
const messages = await sql<{ role: string; content: string }[]>`
SELECT role, content
FROM messages
WHERE chat_id = ${chatId}
AND role <> 'system'
AND content <> ''
ORDER BY created_at ASC
`;
if (messages.length === 0) {
return `Tab ${input.number} (chat ${chatId}) has no messages yet.`;
}
// Format a compact transcript, capping total output size.
const parts: string[] = [];
let total = 0;
let truncated = false;
for (const m of messages) {
const block = `### ${m.role}\n${m.content}`;
// +2 accounts for the "\n\n" joiner between blocks.
if (total + block.length + 2 > MAX_TRANSCRIPT_CHARS) {
truncated = true;
break;
}
parts.push(block);
total += block.length + 2;
}
let out = parts.join('\n\n');
if (truncated) {
out += `\n\n[transcript truncated at ${MAX_TRANSCRIPT_CHARS} chars]`;
}
return out;
}
export const readTabByNumber: ToolDef<ReadTabByNumberInputT> = {
name: 'read_tab_by_number',
description:
'Read the conversation transcript of the tab with the given session-scoped tab number. Tab numbers are stable per session (shown in the workspace tab strip). Returns the messages of that tab oldest-first as a compact transcript. Read-only.',
inputSchema: ReadTabByNumberInput,
jsonSchema: {
type: 'function',
function: {
name: 'read_tab_by_number',
description:
'Read the conversation transcript of the tab with the given session-scoped tab number. Read-only.',
parameters: {
type: 'object',
properties: {
number: {
type: 'integer',
description: 'The session-scoped tab number (positive integer).',
},
},
required: ['number'],
additionalProperties: false,
},
},
},
async execute(input, _projectRoot, _extraRoots, toolCtx?: ToolExecCtx) {
if (!toolCtx) {
return 'read_tab_by_number unavailable: no session context';
}
return await executeReadTabByNumber(input, toolCtx.sql, toolCtx.sessionId);
},
};

View File

@@ -1,6 +1,7 @@
import { readFile, readdir, stat } from 'node:fs/promises'; import { readFile, readdir, stat } from 'node:fs/promises';
import { resolve, basename, relative } from 'node:path'; import { resolve, basename, relative } from 'node:path';
import { z } from 'zod'; import { z } from 'zod';
import type { Sql } from '../db.js';
import { pathGuard, PathScopeError } from './path_guard.js'; import { pathGuard, PathScopeError } from './path_guard.js';
import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.js'; import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.js';
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js'; import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
@@ -30,6 +31,9 @@ import {
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the // with the pause-on-pending-grant branch in inference/tool-phase.ts and the
// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts. // POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
import { requestReadAccess } from './request_read_access.js'; import { requestReadAccess } from './request_read_access.js';
// v2.6.x: read-only tool that reads a tab's transcript by its session-scoped
// tab number. Needs DB/session context (ToolExecCtx 4th arg).
import { readTabByNumber } from './read_tab_by_number.js';
const MAX_FILE_BYTES = 5 * 1024 * 1024; const MAX_FILE_BYTES = 5 * 1024 * 1024;
const DEFAULT_VIEW_LINES = 200; const DEFAULT_VIEW_LINES = 200;
@@ -48,6 +52,16 @@ export interface ToolJsonSchema {
}; };
} }
// v2.6.x: optional DB/session context threaded into a tool's execute(). Only
// tools that need to read session-scoped DB state (e.g. read_tab_by_number)
// use it; every other tool ignores the 4th arg. Kept optional so existing
// 3-arg execute() implementations stay assignable (apps/coder consumes this
// type from the compiled dist — the optional param keeps it backward-compatible).
export interface ToolExecCtx {
sql: Sql;
sessionId: string;
}
export interface ToolDef<TInput> { export interface ToolDef<TInput> {
name: string; name: string;
description: string; description: string;
@@ -59,7 +73,15 @@ export interface ToolDef<TInput> {
// view_truncated_output) forward it to pathGuard; other tools accept the // view_truncated_output) forward it to pathGuard; other tools accept the
// arg and ignore it. The execute signature stays compatible with // arg and ignore it. The execute signature stays compatible with
// pre-v1.13.17 callsites because the parameter is optional. // pre-v1.13.17 callsites because the parameter is optional.
execute(input: TInput, projectRoot: string, extraRoots?: readonly string[]): Promise<unknown>; // v2.6.x: optional 4th param toolCtx carries DB/session context for tools
// that read session-scoped state (read_tab_by_number). Optional so 3-arg
// implementations remain assignable.
execute(
input: TInput,
projectRoot: string,
extraRoots?: readonly string[],
toolCtx?: ToolExecCtx,
): Promise<unknown>;
} }
const ViewFileInput = z.object({ const ViewFileInput = z.object({
@@ -694,6 +716,9 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
// state change is appending to sessions.allowed_read_paths via the // state change is appending to sessions.allowed_read_paths via the
// grant endpoint, gated by user consent. // grant endpoint, gated by user consent.
requestReadAccess as ToolDef<unknown>, requestReadAccess as ToolDef<unknown>,
// v2.6.x: read a tab's transcript by its session-scoped tab number.
// Read-only; uses the ToolExecCtx 4th arg for DB/session access.
readTabByNumber as ToolDef<unknown>,
].sort((a, b) => a.name.localeCompare(b.name)); ].sort((a, b) => a.name.localeCompare(b.name));
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is // v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
@@ -734,6 +759,9 @@ export const READ_ONLY_TOOL_NAMES = [
// state directly (the grant endpoint appends to sessions.allowed_read_paths // state directly (the grant endpoint appends to sessions.allowed_read_paths
// only with user consent). Belongs in the read-only budget tier. // only with user consent). Belongs in the read-only budget tier.
'request_read_access', 'request_read_access',
// v2.6.x: reads a tab's transcript from session-scoped DB state; never
// writes. Belongs in the read-only budget tier.
'read_tab_by_number',
] as const; ] as const;
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries( export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(

View File

@@ -25,6 +25,20 @@ export interface AvailableProject {
export type SessionStatus = 'open' | 'archived'; 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 { export interface Session {
id: string; id: string;
project_id: string; project_id: string;

View File

@@ -203,7 +203,12 @@ export const SessionDeletedFrame = z.object({
export const SessionWorkspaceUpdatedFrame = z.object({ export const SessionWorkspaceUpdatedFrame = z.object({
type: z.literal('session_workspace_updated'), type: z.literal('session_workspace_updated'),
session_id: Uuid, session_id: Uuid,
workspace_panes: z.array(OpaqueObject), // v2.6.x: widened from z.array — the payload is now either the legacy bare
// WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
// nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
// every envelope frame at validation. MUST be mirrored in the server's
// byte-identical copy (parity test).
workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
}); });
export const ChatCreatedFrame = z.object({ export const ChatCreatedFrame = z.object({

View File

@@ -22,6 +22,7 @@ import type {
CoderTaskDetail, CoderTaskDetail,
PermissionPrompt, PermissionPrompt,
AgentCommand, AgentCommand,
WorkspaceState,
} from './types'; } from './types';
export class ApiError extends Error { export class ApiError extends Error {
@@ -151,8 +152,17 @@ export const api = {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
remove: (id: string) => // force=true bypasses the server-side worktree work-loss guard. A blocked
request<void>(`/api/sessions/${id}`, { method: 'DELETE' }), // 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) => archive: (id: string) =>
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }), request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
unarchive: (id: string) => unarchive: (id: string) =>
@@ -166,10 +176,10 @@ export const api = {
), ),
openChatsCount: (id: string) => openChatsCount: (id: string) =>
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`), request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
updateWorkspacePanes: (id: string, panes: Session['workspace_panes']) => updateWorkspacePanes: (id: string, state: WorkspaceState) =>
request<Session>(`/api/sessions/${id}/workspace`, { request<Session>(`/api/sessions/${id}/workspace`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ workspace_panes: panes }), body: JSON.stringify({ workspace_panes: state }),
}), }),
}, },
@@ -345,6 +355,10 @@ export const api = {
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`), request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
getTask: (taskId: string) => getTask: (taskId: string) =>
request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`), request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`),
// Cancel a pending/running coder task (cancels permission wait + inference;
// server sets state='cancelled'). Used by CoderPane's stop button.
cancelTask: (taskId: string) =>
request<{ cancelled: boolean }>(`/api/coder/tasks/${taskId}/cancel`, { method: 'POST' }),
listMessages: (sessionId: string, chatId?: string) => listMessages: (sessionId: string, chatId?: string) =>
request<CoderMessageWire[]>( request<CoderMessageWire[]>(
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`, `/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,

View File

@@ -34,6 +34,19 @@ export interface AvailableProject {
export type SessionStatus = 'open' | 'archived'; 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 { export interface Session {
id: string; id: string;
project_id: string; project_id: string;
@@ -47,7 +60,10 @@ export interface Session {
// v1.9: null = inherit from project.default_web_search_enabled. // v1.9: null = inherit from project.default_web_search_enabled.
web_search_enabled: boolean | null; web_search_enabled: boolean | null;
// v1.12.1: server-authoritative pane layout, replaces localStorage. // v1.12.1: server-authoritative pane layout, replaces localStorage.
workspace_panes: WorkspacePane[]; // A value may be the legacy bare WorkspacePane[] (older rows) OR the new
// WorkspaceState envelope (panes + tab numbering + reopen stack). Normalize
// on read via useWorkspacePanes' toWorkspaceState.
workspace_panes: WorkspacePane[] | WorkspaceState;
// v1.13.17: paths the agent has been granted read access to via the // v1.13.17: paths the agent has been granted read access to via the
// request_read_access tool. Empty by default. Settings UI surfaces the // request_read_access tool. Empty by default. Settings UI surfaces the
// list with per-row revoke; the grant flow itself appends through the // list with per-row revoke; the grant flow itself appends through the
@@ -498,6 +514,30 @@ export interface WorkspacePane {
html_artifact_state?: HtmlArtifactState; html_artifact_state?: HtmlArtifactState;
} }
// Reopen LIFO stack entry. Shape unchanged from the prior module-level stack;
// now persisted inside the WorkspaceState envelope so the reopen-pane stack
// survives a reload / cross-device sync.
export interface ClosedPaneEntry {
kind: WorkspacePane['kind'];
chatIds: string[];
activeChatIdx: number;
}
// Envelope persisted to sessions.workspace_panes. Supersedes the bare
// WorkspacePane[] shape (still accepted on read for legacy rows — see the
// migration in useWorkspacePanes.toWorkspaceState). The server accepts either
// shape; the frontend always emits this envelope going forward.
export interface WorkspaceState {
panes: WorkspacePane[];
// Stable, session-scoped tab number per chat id. Numbers only ever increase
// and are never reused (retired entries are pruned on tab close).
tabNumbers: { [chatId: string]: number };
// Next number to hand out; starts at 1; ONLY increments.
nextTabNumber: number;
// Reopen LIFO stack, max 10, most-recent last.
closedPaneStack: ClosedPaneEntry[];
}
export type WsFrame = export type WsFrame =
| { type: 'snapshot'; messages: Message[] } | { type: 'snapshot'; messages: Message[] }
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole } | { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole }

View File

@@ -203,7 +203,12 @@ export const SessionDeletedFrame = z.object({
export const SessionWorkspaceUpdatedFrame = z.object({ export const SessionWorkspaceUpdatedFrame = z.object({
type: z.literal('session_workspace_updated'), type: z.literal('session_workspace_updated'),
session_id: Uuid, session_id: Uuid,
workspace_panes: z.array(OpaqueObject), // v2.6.x: widened from z.array — the payload is now either the legacy bare
// WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
// nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
// every envelope frame at validation. MUST be mirrored in the server's
// byte-identical copy (parity test).
workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
}); });
export const ChatCreatedFrame = z.object({ export const ChatCreatedFrame = z.object({

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
import { Check, Plus, Send } from 'lucide-react'; import { Check, ListPlus, Plus, Send, Square } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -51,6 +51,11 @@ interface Props {
webSearchEnabled?: boolean | null; webSearchEnabled?: boolean | null;
onSend: (content: string) => void | Promise<void>; onSend: (content: string) => void | Promise<void>;
onForceSend?: (content: string) => void | Promise<void>; onForceSend?: (content: string) => void | Promise<void>;
// When the assistant/agent is generating, the send button morphs: empty draft
// → Stop (calls onStop); non-empty draft → Queue (submits, which the caller
// queues while busy). Omitting onStop falls back to a (disabled) Send button.
generating?: boolean;
onStop?: () => void | Promise<void>;
// Batch 9.6: slash-command dispatch. When the input parses to a known skill, // Batch 9.6: slash-command dispatch. When the input parses to a known skill,
// ChatInput calls this with the skill name + the post-name args (possibly // ChatInput calls this with the skill name + the post-name args (possibly
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop // empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
@@ -78,7 +83,7 @@ interface Props {
modelContextLimit?: number | null; modelContextLimit?: number | null;
} }
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) { export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
const { isMobile } = useViewport(); const { isMobile } = useViewport();
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -651,14 +656,38 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
rows={3} rows={3}
className="resize-none min-h-[68px] max-h-[240px]" className="resize-none min-h-[68px] max-h-[240px]"
/> />
{(() => {
const hasContent = value.trim().length > 0 || attachments.length > 0;
// While generating with an empty draft, the button stops generation.
if (generating && onStop && !hasContent) {
return (
<Button
onClick={() => void onStop()}
size="icon-lg"
variant="outline"
aria-label="Stop generating"
title="Stop generating"
>
<Square className="fill-current size-3.5" />
</Button>
);
}
// With a draft, submit. While generating the caller queues it, so the
// button reads as Queue; otherwise it's a normal Send.
const queueing = !!generating && hasContent;
return (
<Button <Button
onClick={() => void submit()} onClick={() => void submit()}
disabled={disabled || busy || (!value.trim() && attachments.length === 0)} disabled={disabled || busy || !hasContent}
size="icon-lg" size="icon-lg"
aria-label="Send" variant={queueing ? 'secondary' : 'default'}
aria-label={queueing ? 'Queue message' : 'Send'}
title={queueing ? 'Queue message' : 'Send'}
> >
<Send /> {queueing ? <ListPlus /> : <Send />}
</Button> </Button>
);
})()}
</div> </div>
</div> </div>
<AttachmentPreviewModal <AttachmentPreviewModal

View File

@@ -16,11 +16,15 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { useLongPress } from '@/hooks/useLongPress'; import { useLongPress } from '@/hooks/useLongPress';
import { sessionEvents } from '@/hooks/sessionEvents';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface Props { interface Props {
pane: WorkspacePane; pane: WorkspacePane;
tabs: Chat[]; tabs: Chat[];
// v2.6.x (Batch 3a): stable session-scoped tab number per chat id. Keyed by
// chat.id, NEVER by tab position.
tabNumbers: Record<string, number>;
onSwitchTab: (tabIdx: number) => void; onSwitchTab: (tabIdx: number) => void;
onRemoveTab: (chatId: string) => void; onRemoveTab: (chatId: string) => void;
onCloseOthers: (chatId: string) => void; onCloseOthers: (chatId: string) => void;
@@ -37,6 +41,7 @@ interface Props {
export function ChatTabBar({ export function ChatTabBar({
pane, pane,
tabs, tabs,
tabNumbers,
onSwitchTab, onSwitchTab,
onRemoveTab, onRemoveTab,
onCloseOthers, onCloseOthers,
@@ -83,6 +88,9 @@ export function ChatTabBar({
const isLast = tabIdx === tabs.length - 1; const isLast = tabIdx === tabs.length - 1;
const onlyTab = tabs.length === 1; const onlyTab = tabs.length === 1;
const label = chat.name ?? 'New chat'; const label = chat.name ?? 'New chat';
// v2.6.x: stable tab number keyed by chat.id (NOT tab position).
// Omit gracefully when not yet assigned.
const tabNumber = tabNumbers[chat.id];
return ( return (
<ContextMenu key={chat.id}> <ContextMenu key={chat.id}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
@@ -117,8 +125,11 @@ export function ChatTabBar({
className="bg-transparent border-b border-border text-xs outline-none w-28" className="bg-transparent border-b border-border text-xs outline-none w-28"
/> />
) : ( ) : (
<span className="truncate max-w-[140px]" title={label}> <span
{label} className="truncate max-w-[140px]"
title={tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
>
{tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
</span> </span>
)} )}
<button <button
@@ -138,6 +149,13 @@ export function ChatTabBar({
<ContextMenuItem onSelect={onNewTab}> <ContextMenuItem onSelect={onNewTab}>
New chat New chat
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem
onSelect={() =>
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id })
}
>
Open in new pane
</ContextMenuItem>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}> <ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
Rename Rename
@@ -174,15 +192,31 @@ export function ChatTabBar({
)} )}
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0"> <div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button <button
type="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]" 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" aria-label="New chat, terminal, or coder"
title="New tab" title="New chat / terminal / coder"
> >
<Plus size={12} /> <Plus size={12} />
</button> </button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
{/* New BooChat opens a tab in THIS pane; terminal/coder can't be
tabs, so they split into a new pane (matches the Split menu). */}
<DropdownMenuItem onSelect={onNewTab}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen, Brain } from 'lucide-react'; import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Chat, ErrorReason, Message } from '@/api/types'; import type { Chat, ErrorReason, Message } from '@/api/types';
import { api, ApiError } from '@/api/client'; import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events'; import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
import { CapHitSentinel } from './CapHitSentinel'; import { CapHitSentinel } from './CapHitSentinel';
@@ -105,18 +105,6 @@ const ERROR_REASON_LABELS: Record<ErrorReason, string> = {
// moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact // moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact
// panes can render assistant content with the same Shiki + remark-gfm setup. // panes can render assistant content with the same Shiki + remark-gfm setup.
// Pane-header title derivation for a markdown artifact. Order matches the
// server slug logic in services/artifacts.ts: first `# ` heading → first 6
// words of the body → 'Markdown artifact'. Truncated to keep the pane header
// readable.
function deriveMarkdownTitle(content: string): string {
const headingMatch = content.match(/^\s*#\s+(.+?)\s*$/m);
if (headingMatch && headingMatch[1]) return headingMatch[1].slice(0, 80);
const words = content.trim().split(/\s+/).slice(0, 6).join(' ');
if (words) return words.slice(0, 80);
return 'Markdown artifact';
}
export interface MessageActions { export interface MessageActions {
onRegenerate?: (chatId: string, messageId: string) => Promise<void>; onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
onResend?: (chatId: string, content: string) => Promise<void>; onResend?: (chatId: string, content: string) => Promise<void>;
@@ -129,8 +117,8 @@ interface Props {
sessionChats?: Chat[]; sessionChats?: Chat[];
capHitInfo?: { position: number; isLatest: boolean }; capHitInfo?: { position: number; isLatest: boolean };
actions?: MessageActions; actions?: MessageActions;
/** Hide actions that don't apply (fork, delete, open-in-pane). */ /** Hide actions that don't apply (fork, delete). */
hideActions?: ('fork' | 'delete' | 'openInPane')[]; hideActions?: ('fork' | 'delete')[];
} }
function StatsLine({ message }: { message: Message }) { function StatsLine({ message }: { message: Message }) {
@@ -226,7 +214,7 @@ function ActionRow({
} else { } else {
const chat = await api.chats.fork(message.chat_id, { messageId: message.id }); const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
sessionEvents.emit({ type: 'refetch_messages' }); sessionEvents.emit({ type: 'refetch_messages' });
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id }); sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id });
} }
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'fork failed'); toast.error(err instanceof Error ? err.message : 'fork failed');
@@ -258,54 +246,6 @@ function ActionRow({
const canResend = isUser && message.status === 'complete' && !!message.content?.trim(); const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
const canFork = message.status === 'complete'; const canFork = message.status === 'complete';
const canDelete = message.status !== 'streaming'; const canDelete = message.status !== 'streaming';
const [openingPane, setOpeningPane] = useState(false);
// v1.14.x-html-artifact-panes: probe for an html_artifact part. If present,
// open the HTML pane variant; otherwise fall back to the markdown variant.
// Title derivation for markdown: first `# ` heading → first 6 words of the
// body → 'Markdown artifact' (mirrors the slug logic in
// services/artifacts.ts).
async function openInPane() {
if (openingPane || message.status === 'streaming') return;
setOpeningPane(true);
try {
try {
const payload = await api.messages.getHtmlArtifact(
message.chat_id,
message.id,
);
sessionEvents.emit({
type: 'open_html_artifact_pane',
state: {
chat_id: message.chat_id,
message_id: message.id,
title: payload.title,
},
});
return;
} catch (err) {
// 404 (no html_artifact part) is the expected fall-through path —
// markdown variant opens below. Any other error (network, 500) is
// a real failure; toast and bail rather than masquerading as markdown.
const status = err instanceof ApiError ? err.status : null;
if (status !== 404) {
toast.error(err instanceof Error ? err.message : 'open in pane failed');
return;
}
}
const title = deriveMarkdownTitle(message.content);
sessionEvents.emit({
type: 'open_markdown_artifact_pane',
state: {
chat_id: message.chat_id,
message_id: message.id,
title,
},
});
} finally {
setOpeningPane(false);
}
}
return ( return (
<> <>
@@ -330,18 +270,6 @@ function ActionRow({
<RefreshCw className="size-3" /> <RefreshCw className="size-3" />
</button> </button>
)} )}
{isAssistant && !hiddenSet.has('openInPane') && (
<button
type="button"
onClick={() => void openInPane()}
disabled={openingPane || message.status === 'streaming'}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Open in pane"
title="Open in pane"
>
<PanelRightOpen className="size-3" />
</button>
)}
{isAssistant && ( {isAssistant && (
<button <button
type="button" type="button"

View File

@@ -19,12 +19,12 @@ import {
DialogDescription, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { AddProjectModal } from './AddProjectModal'; import { AddProjectModal } from './AddProjectModal';
import { api } from '@/api/client'; import { api, ApiError } from '@/api/client';
import { useSidebar } from '@/hooks/useSidebar'; import { useSidebar } from '@/hooks/useSidebar';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer'; import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
import { usePullToRefresh } from '@/hooks/usePullToRefresh'; import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import type { SidebarProject } from '@/api/types'; import type { SidebarProject, WorktreeRiskReport } from '@/api/types';
import { giteaUrlFor } from '@/lib/projectUrls'; import { giteaUrlFor } from '@/lib/projectUrls';
import { isCoderSessionName } from '@/lib/coder-session'; import { isCoderSessionName } from '@/lib/coder-session';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -110,6 +110,16 @@ export function ProjectSidebar() {
const [renamingProject, setRenamingProject] = useState<string | null>(null); const [renamingProject, setRenamingProject] = useState<string | null>(null);
const [renameProjectValue, setRenameProjectValue] = useState(''); const [renameProjectValue, setRenameProjectValue] = useState('');
const [archiveProjectConfirm, setArchiveProjectConfirm] = useState<{ id: string; name: string } | null>(null); 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 navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const lastToastedError = useRef<string | null>(null); 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 { try {
await api.sessions.remove(sessionId); await api.sessions.remove(sessionId, force);
// Server publishes session_deleted via WS; useUserEvents delivers it. // Server publishes session_deleted via WS; useUserEvents delivers it.
setRiskState(null);
if (activeSession === sessionId) navigate(`/project/${projectId}`); if (activeSession === sessionId) navigate(`/project/${projectId}`);
} catch (err) { } 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'); 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) { async function handleRenameSession(sessionId: string) {
const trimmed = renameValue.trim(); const trimmed = renameValue.trim();
setRenamingSession(null); 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'; : '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 ( return (
<aside className={asideCls}> <aside className={asideCls}>
<div className="px-4 py-3 border-b flex items-center justify-between"> <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) => const projectId = projects.find((p) =>
p.recent_sessions.some((s) => s.id === deleteConfirm.id) p.recent_sessions.some((s) => s.id === deleteConfirm.id)
)?.id; )?.id;
if (projectId) void handleDeleteSession(deleteConfirm.id, projectId); if (projectId) void handleDeleteSession(deleteConfirm.id, projectId, deleteConfirm.name);
} }
setDeleteConfirm(null); setDeleteConfirm(null);
}} }}
@@ -509,6 +598,77 @@ export function ProjectSidebar() {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </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> </aside>
); );
} }

View File

@@ -1,6 +1,9 @@
import { useCallback, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Archive, MessageSquare, RotateCcw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ChatInput } from '@/components/ChatInput'; import { ChatInput } from '@/components/ChatInput';
import { api } from '@/api/client';
import type { Chat } from '@/api/types';
interface Props { interface Props {
projectId: string; projectId: string;
@@ -13,6 +16,30 @@ interface Props {
// the skill — same transition the text send uses. See useSessionChats. // the skill — same transition the text send uses. See useSessionChats.
onSkillInvoke: (skillName: string, userMessage: string | null) => void; onSkillInvoke: (skillName: string, userMessage: string | null) => void;
createChat: () => Promise<{ id: string }>; createChat: () => Promise<{ id: string }>;
// Session history: the session's open chats (live), and callbacks to open one
// in THIS pane / restore an archived one. Archived chats are fetched here
// (the default open-only list excludes them).
chats: Chat[];
onOpenChat: (chatId: string) => void;
onUnarchiveChat: (chatId: string) => Promise<void>;
}
function formatRelative(iso: string): string {
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return '';
const s = Math.max(0, Math.round((Date.now() - then) / 1000));
if (s < 60) return 'just now';
const m = Math.round(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.round(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.round(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(iso).toLocaleDateString();
}
function byRecent(a: Chat, b: Chat): number {
return (b.updated_at ?? '').localeCompare(a.updated_at ?? '');
} }
export function SessionLandingPage({ export function SessionLandingPage({
@@ -23,8 +50,24 @@ export function SessionLandingPage({
onSend, onSend,
onSkillInvoke, onSkillInvoke,
createChat, createChat,
chats,
onOpenChat,
onUnarchiveChat,
}: Props) { }: Props) {
const [chatId, setChatId] = useState<string | null>(null); const [chatId, setChatId] = useState<string | null>(null);
const [archived, setArchived] = useState<Chat[]>([]);
// Archived chats aren't in the default (open-only) list, so fetch them. One
// shot on session change — the history view is transient (pick a chat and
// it's gone), so slight staleness is fine; reopening the pane refetches.
useEffect(() => {
let cancelled = false;
api.chats
.listForSession(sessionId, { status: 'archived' })
.then((list) => { if (!cancelled) setArchived(list); })
.catch(() => {});
return () => { cancelled = true; };
}, [sessionId]);
const ensureChat = useCallback(async (): Promise<string> => { const ensureChat = useCallback(async (): Promise<string> => {
if (chatId) return chatId; if (chatId) return chatId;
@@ -57,12 +100,87 @@ export function SessionLandingPage({
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null); onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
}, [onSkillInvoke]); }, [onSkillInvoke]);
const restoreAndOpen = useCallback(async (id: string) => {
try {
await onUnarchiveChat(id);
onOpenChat(id);
} catch {
// onUnarchiveChat surfaces its own toast.
}
}, [onUnarchiveChat, onOpenChat]);
const openChats = [...chats.filter((c) => c.status === 'open')].sort(byRecent);
const openIds = new Set(openChats.map((c) => c.id));
const archivedChats = archived.filter((c) => !openIds.has(c.id)).sort(byRecent);
const isEmpty = openChats.length === 0 && archivedChats.length === 0;
return ( return (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<div className="flex-1 flex items-center justify-center px-6"> <div className="flex-1 min-h-0 overflow-y-auto">
<p className="text-sm text-muted-foreground"> <div className="max-w-[760px] mx-auto w-full px-4 py-4">
Send a message to start. {isEmpty ? (
<p className="text-sm text-muted-foreground text-center py-8">
No conversations yet. Send a message to start.
</p> </p>
) : (
<>
{openChats.length > 0 && (
<>
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground px-1 mb-1.5">
Conversations
</h3>
<div className="space-y-0.5 mb-4">
{openChats.map((c) => (
<button
key={c.id}
type="button"
onClick={() => onOpenChat(c.id)}
className="w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm max-md:min-h-[44px]"
>
<MessageSquare size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate shrink-0 max-w-[45%]">{c.name ?? 'New chat'}</span>
{c.last_message_preview && (
<span className="truncate flex-1 text-xs text-muted-foreground hidden sm:block">
{c.last_message_preview}
</span>
)}
<span className="shrink-0 ml-auto text-xs text-muted-foreground">
{formatRelative(c.updated_at)}
</span>
</button>
))}
</div>
</>
)}
{archivedChats.length > 0 && (
<>
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground px-1 mb-1.5">
Archived
</h3>
<div className="space-y-0.5">
{archivedChats.map((c) => (
<button
key={c.id}
type="button"
onClick={() => void restoreAndOpen(c.id)}
title="Restore and open"
className="group/arch w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm text-muted-foreground max-md:min-h-[44px]"
>
<Archive size={14} className="shrink-0" />
<span className="truncate flex-1">{c.name ?? 'New chat'}</span>
<span className="shrink-0 text-xs">{formatRelative(c.updated_at)}</span>
<RotateCcw
size={13}
className="shrink-0 opacity-0 group-hover/arch:opacity-100"
/>
</button>
))}
</div>
</>
)}
</>
)}
</div>
</div> </div>
<ChatInput <ChatInput
disabled={false} disabled={false}

View File

@@ -54,6 +54,7 @@ export function Workspace({
}: Props) { }: Props) {
const { const {
panes, panes,
tabNumbers,
activePaneIdx, activePaneIdx,
setActivePaneIdx, setActivePaneIdx,
openChatInPane, openChatInPane,
@@ -204,6 +205,7 @@ export function Workspace({
<ChatTabBar <ChatTabBar
pane={pane} pane={pane}
tabs={chatsForPane(pane)} tabs={chatsForPane(pane)}
tabNumbers={tabNumbers}
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)} onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
onRemoveTab={(chatId) => removeTab(idx, chatId)} onRemoveTab={(chatId) => removeTab(idx, chatId)}
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)} onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
@@ -390,6 +392,9 @@ export function Workspace({
createChat={() => api.chats.create(sessionId)} createChat={() => api.chats.create(sessionId)}
onSend={(content) => void handleLandingSend(idx, content)} onSend={(content) => void handleLandingSend(idx, content)}
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)} onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
chats={chats}
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
onUnarchiveChat={unarchiveChat}
/> />
)} )}
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Pencil, Send, Square, X } from 'lucide-react'; import { Pencil, Send, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api/client'; import { api } from '@/api/client';
import { useSessionStream } from '@/hooks/useSessionStream'; import { useSessionStream } from '@/hooks/useSessionStream';
@@ -248,22 +248,6 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
</div> </div>
)} )}
{/* Stop button when streaming */}
{streaming && (
<div className="border-t py-1">
<div className="max-w-[1000px] mx-auto w-full flex justify-center">
<button
type="button"
onClick={() => void handleStop()}
className="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border hover:bg-muted text-muted-foreground hover:text-foreground max-md:min-h-[44px] max-md:px-5"
>
<Square size={10} className="fill-current" />
Stop generating
</button>
</div>
</div>
)}
{stale && streamingId && ( {stale && streamingId && (
<StaleStreamBanner <StaleStreamBanner
onRetry={() => void handleRetryStale()} onRetry={() => void handleRetryStale()}
@@ -280,6 +264,8 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
webSearchEnabled={webSearchEnabled} webSearchEnabled={webSearchEnabled}
onSend={handleSend} onSend={handleSend}
onForceSend={streaming ? handleForceSend : undefined} onForceSend={streaming ? handleForceSend : undefined}
generating={streaming}
onStop={handleStop}
onSlashCommand={handleSlashCommand} onSlashCommand={handleSlashCommand}
chatId={chatId} chatId={chatId}
chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'} chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'}

View File

@@ -149,7 +149,7 @@ interface Props {
actions?: MessageActions; actions?: MessageActions;
} }
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete' | 'openInPane')[] = ['fork', 'openInPane']; const CODER_HIDDEN_ACTIONS: ('fork' | 'delete')[] = ['fork'];
export function CoderMessageList({ messages, chatId, footer, actions }: Props) { export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
const endRef = useRef<HTMLDivElement>(null); const endRef = useRef<HTMLDivElement>(null);

View File

@@ -581,6 +581,10 @@ export function CoderPane({
const [queue, setQueue] = useState<string[]>([]); const [queue, setQueue] = useState<string[]>([]);
const queueProcessing = useRef(false); const queueProcessing = useRef(false);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
// The agent is "generating" during the dispatch POST (sending) AND while its
// task runs (activeTaskId). sending alone is too brief — it clears the moment
// dispatch returns — so queueing/stop must key on this combined signal.
const generating = sending || activeTaskId !== null;
// Refresh pending changes when a message_complete arrives // Refresh pending changes when a message_complete arrives
useEffect(() => { useEffect(() => {
@@ -760,24 +764,35 @@ export function CoderPane({
} }
}, [sessionId, paneId, chatId, agentConfig, setMessages]); }, [sessionId, paneId, chatId, agentConfig, setMessages]);
// Drain queue when not busy // Drain queue once the agent is idle (not just past the dispatch POST).
useEffect(() => { useEffect(() => {
if (sending || queue.length === 0 || queueProcessing.current) return; if (generating || queue.length === 0 || queueProcessing.current) return;
queueProcessing.current = true; queueProcessing.current = true;
const next = queue[0]!; const next = queue[0]!;
setQueue((prev) => prev.slice(1)); setQueue((prev) => prev.slice(1));
sendOneMessage(next).finally(() => { queueProcessing.current = false; }); sendOneMessage(next).finally(() => { queueProcessing.current = false; });
}, [sending, queue, sendOneMessage]); }, [generating, queue, sendOneMessage]);
const handleChatInputSend = useCallback(async (content: string) => { const handleChatInputSend = useCallback(async (content: string) => {
const text = content.trim(); const text = content.trim();
if (!text || !chatId) return; if (!text || !chatId) return;
if (sending) { if (generating) {
setQueue((prev) => [...prev, text]); setQueue((prev) => [...prev, text]);
return; return;
} }
await sendOneMessage(text); await sendOneMessage(text);
}, [sending, chatId, sendOneMessage]); }, [generating, chatId, sendOneMessage]);
const handleStop = useCallback(async () => {
const taskId = activeTaskId;
if (!taskId) return;
try {
await api.coder.cancelTask(taskId);
setActiveTaskId(null); // optimistic; WS/poll terminal-state also clears it
} catch (err) {
toast.error(err instanceof Error ? err.message : 'stop failed');
}
}, [activeTaskId]);
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => { const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
if (!chatId) return; if (!chatId) return;
@@ -867,9 +882,11 @@ export function CoderPane({
{/* Composer + input */} {/* Composer + input */}
<div className="shrink-0 border-t border-border"> <div className="shrink-0 border-t border-border">
<ChatInput <ChatInput
disabled={sending || !chatId || chatPending} disabled={!chatId || chatPending}
projectId={projectPath ?? ''} projectId={projectPath ?? ''}
onSend={handleChatInputSend} onSend={handleChatInputSend}
generating={generating}
onStop={handleStop}
onSlashCommand={handleChatInputSlash} onSlashCommand={handleChatInputSlash}
slashGroups={slashGroups} slashGroups={slashGroups}
chatId={chatId ?? undefined} chatId={chatId ?? undefined}

View File

@@ -51,7 +51,11 @@ export interface SessionUpdatedEvent {
export interface SessionWorkspaceUpdatedEvent { export interface SessionWorkspaceUpdatedEvent {
type: 'session_workspace_updated'; type: 'session_workspace_updated';
session_id: string; session_id: string;
workspace_panes: import('@/api/types').WorkspacePane[]; // Legacy bare array OR the new envelope — useWorkspacePanes normalizes both
// via toWorkspaceState.
workspace_panes:
| import('@/api/types').WorkspacePane[]
| import('@/api/types').WorkspaceState;
} }
export interface SessionLoadedEvent { export interface SessionLoadedEvent {
@@ -75,6 +79,14 @@ export interface OpenChatInActivePaneEvent {
chat_id: string; chat_id: string;
} }
// Open a whole chat in a fresh split pane (vs the active pane). Emitted by the
// ChatTabBar tab context menu ("Open in new pane") and by MessageBubble.fork()
// so a fork lands beside the original. useWorkspacePanes subscribes.
export interface OpenChatInNewPaneEvent {
type: 'open_chat_in_new_pane';
chat_id: string;
}
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of // v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of
// these; useWorkspacePanes subscribes and inserts the corresponding artifact // these; useWorkspacePanes subscribes and inserts the corresponding artifact
// pane (or focuses an existing one keyed by message_id). // pane (or focuses an existing one keyed by message_id).
@@ -178,6 +190,7 @@ export type SessionEvent =
| OpenFileInBrowserEvent | OpenFileInBrowserEvent
| AttachChatFileEvent | AttachChatFileEvent
| OpenChatInActivePaneEvent | OpenChatInActivePaneEvent
| OpenChatInNewPaneEvent
| OpenMarkdownArtifactPaneEvent | OpenMarkdownArtifactPaneEvent
| OpenHtmlArtifactPaneEvent | OpenHtmlArtifactPaneEvent
| OpenSettingsPaneEvent | OpenSettingsPaneEvent

View File

@@ -152,6 +152,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'attach_chat_file': case 'attach_chat_file':
return prev; return prev;
case 'open_chat_in_active_pane': case 'open_chat_in_active_pane':
case 'open_chat_in_new_pane':
// Consumed by Workspace; sidebar has no business with pane state. // Consumed by Workspace; sidebar has no business with pane state.
return prev; return prev;
case 'open_markdown_artifact_pane': case 'open_markdown_artifact_pane':

View File

@@ -3,9 +3,11 @@ import type { DragEvent } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api/client'; import { api } from '@/api/client';
import type { import type {
ClosedPaneEntry,
HtmlArtifactState, HtmlArtifactState,
MarkdownArtifactState, MarkdownArtifactState,
WorkspacePane, WorkspacePane,
WorkspaceState,
} from '@/api/types'; } from '@/api/types';
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane'; import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
import { sessionEvents } from '@/hooks/sessionEvents'; import { sessionEvents } from '@/hooks/sessionEvents';
@@ -32,19 +34,35 @@ function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 }; return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
} }
interface ClosedPaneEntry { // v2.6.x: reopen stack cap. The stack now lives in React state (persisted in
kind: WorkspacePane['kind']; // the WorkspaceState envelope), not a module-level array. `appendClosed` is the
chatIds: string[]; // pure state-updater helper.
activeChatIdx: number;
}
const MAX_CLOSED = 10; const MAX_CLOSED = 10;
const closedPaneStack: ClosedPaneEntry[] = [];
function pushClosed(pane: WorkspacePane): void { // Pure helper: append a closed-pane entry derived from `pane` to `stack`,
if (pane.kind === 'empty' || pane.kind === 'settings') return; // capped at MAX_CLOSED (most-recent last). Returns the SAME reference when the
if (pane.chatIds.length === 0) return; // pane is not eligible (empty/settings/no chats) so callers can skip setState.
closedPaneStack.push({ kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx }); function appendClosed(stack: ClosedPaneEntry[], pane: WorkspacePane): ClosedPaneEntry[] {
if (closedPaneStack.length > MAX_CLOSED) closedPaneStack.shift(); if (pane.kind === 'empty' || pane.kind === 'settings') return stack;
if (pane.chatIds.length === 0) return stack;
const entry = { kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx };
// Dedupe a value-identical top entry. This is called via setClosedPaneStack
// inside the setPanes updater in removePane; React StrictMode double-invokes
// that updater in dev, which would otherwise push two identical entries.
// Real closes never collide (one chat lives in at most one pane).
const top = stack[stack.length - 1];
if (
top &&
top.kind === entry.kind &&
top.activeChatIdx === entry.activeChatIdx &&
top.chatIds.length === entry.chatIds.length &&
top.chatIds.every((id, i) => id === entry.chatIds[i])
) {
return stack;
}
const next = [...stack, entry];
if (next.length > MAX_CLOSED) next.splice(0, next.length - MAX_CLOSED);
return next;
} }
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string { function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
@@ -110,6 +128,26 @@ function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
return normalizePanes(panes).filter((p) => p.kind !== 'settings'); return normalizePanes(panes).filter((p) => p.kind !== 'settings');
} }
// v2.6.x: LOCKED migration — a value read from session.workspace_panes (or the
// session_workspace_updated frame) may be EITHER the legacy bare
// WorkspacePane[] OR the new WorkspaceState envelope. Normalize to the
// envelope. Must match the server's normalization byte-for-byte.
function toWorkspaceState(raw: unknown): WorkspaceState {
if (Array.isArray(raw)) {
return { panes: raw as WorkspacePane[], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
}
if (raw && typeof raw === 'object' && Array.isArray((raw as WorkspaceState).panes)) {
const env = raw as WorkspaceState;
return {
panes: env.panes,
tabNumbers: env.tabNumbers ?? {},
nextTabNumber: env.nextTabNumber ?? 1,
closedPaneStack: env.closedPaneStack ?? [],
};
}
return { panes: [], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
}
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES. // v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
// Helper used at every pane-insertion site so the rule lives in one place. // Helper used at every pane-insertion site so the rule lives in one place.
function nonSettingsCount(panes: WorkspacePane[]): number { function nonSettingsCount(panes: WorkspacePane[]): number {
@@ -132,6 +170,9 @@ function readLegacyPanes(sessionId: string): WorkspacePane[] | null {
export interface UseWorkspacePanesResult { export interface UseWorkspacePanesResult {
panes: WorkspacePane[]; panes: WorkspacePane[];
// v2.6.x: stable session-scoped tab number per chat id (Batch 3a). Keyed by
// chat.id, NEVER by tab position.
tabNumbers: Record<string, number>;
activePaneIdx: number; activePaneIdx: number;
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>; setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
activePaneIdxRef: React.MutableRefObject<number>; activePaneIdxRef: React.MutableRefObject<number>;
@@ -171,6 +212,12 @@ export interface UseWorkspacePanesResult {
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult { export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const [panes, setPanes] = useState<WorkspacePane[]>(() => [emptyPane()]); const [panes, setPanes] = useState<WorkspacePane[]>(() => [emptyPane()]);
const [activePaneIdx, setActivePaneIdx] = useState(0); const [activePaneIdx, setActivePaneIdx] = useState(0);
// v2.6.x envelope state. Persisted alongside `panes` in the WorkspaceState
// envelope. `tabNumbers` is the stable session-scoped tab number per chat id;
// `nextTabNumber` only ever increments; `closedPaneStack` is the reopen LIFO.
const [tabNumbers, setTabNumbers] = useState<Record<string, number>>({});
const [nextTabNumber, setNextTabNumber] = useState(1);
const [closedPaneStack, setClosedPaneStack] = useState<ClosedPaneEntry[]>([]);
const draggingIdxRef = useRef<number | null>(null); const draggingIdxRef = useRef<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null); const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
// v1.12.1: skip PATCH while hydrating from the server. Without this, the // v1.12.1: skip PATCH while hydrating from the server. Without this, the
@@ -237,27 +284,42 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
try { try {
const session = await api.sessions.get(sessionId); const session = await api.sessions.get(sessionId);
if (cancelled) return; if (cancelled) return;
let initial: WorkspacePane[] = Array.isArray(session.workspace_panes) let env = toWorkspaceState(session.workspace_panes);
? normalizePanes(session.workspace_panes) let initial: WorkspacePane[] = normalizePanes(env.panes);
: [];
// One-time migration: if server is empty but legacy localStorage has // One-time migration: if server is empty but legacy localStorage has
// a layout, seed the server and delete the local key. // a layout, seed the server (as an envelope) and delete the local key.
if (initial.length === 0) { if (initial.length === 0) {
const legacy = readLegacyPanes(sessionId); const legacy = readLegacyPanes(sessionId);
if (legacy && legacy.length > 0) { if (legacy && legacy.length > 0) {
try { try {
const updated = await api.sessions.updateWorkspacePanes(sessionId, legacy); const seedState: WorkspaceState = {
panes: persistablePanes(legacy),
tabNumbers: {},
nextTabNumber: 1,
closedPaneStack: [],
};
const updated = await api.sessions.updateWorkspacePanes(sessionId, seedState);
if (cancelled) return; if (cancelled) return;
initial = updated.workspace_panes; env = toWorkspaceState(updated.workspace_panes);
initial = normalizePanes(env.panes);
localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`); localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
} catch { } catch {
initial = legacy; env = { ...env, panes: legacy };
initial = normalizePanes(legacy);
} }
} }
} }
const next = initial.length > 0 ? initial : [emptyPane()]; const next = initial.length > 0 ? initial : [emptyPane()];
lastRemoteJsonRef.current = JSON.stringify(persistablePanes(next)); lastRemoteJsonRef.current = JSON.stringify({
panes: persistablePanes(next),
tabNumbers: env.tabNumbers,
nextTabNumber: env.nextTabNumber,
closedPaneStack: env.closedPaneStack,
});
setPanes(next); setPanes(next);
setTabNumbers(env.tabNumbers);
setNextTabNumber(env.nextTabNumber);
setClosedPaneStack(env.closedPaneStack);
setActivePaneIdx(0); setActivePaneIdx(0);
seedEmptyScopedPanes(next); seedEmptyScopedPanes(next);
} finally { } finally {
@@ -273,15 +335,25 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return sessionEvents.subscribe((ev) => { return sessionEvents.subscribe((ev) => {
if (ev.type !== 'session_workspace_updated') return; if (ev.type !== 'session_workspace_updated') return;
if (ev.session_id !== sessionId) return; if (ev.session_id !== sessionId) return;
const incoming = normalizePanes( const env = toWorkspaceState(ev.workspace_panes);
Array.isArray(ev.workspace_panes) ? ev.workspace_panes : [], const incoming = normalizePanes(env.panes);
); // Echo-dedup on the FULL envelope so tabNumber / stack-only changes are
const json = JSON.stringify(incoming); // not mistaken for our own write echo.
const json = JSON.stringify({
panes: persistablePanes(incoming),
tabNumbers: env.tabNumbers,
nextTabNumber: env.nextTabNumber,
closedPaneStack: env.closedPaneStack,
});
if (json === lastRemoteJsonRef.current) return; if (json === lastRemoteJsonRef.current) return;
lastRemoteJsonRef.current = json; lastRemoteJsonRef.current = json;
setPanes(incoming.length > 0 ? incoming : [emptyPane()]); const nextPanes = incoming.length > 0 ? incoming : [emptyPane()];
setPanes(nextPanes);
setTabNumbers(env.tabNumbers);
setNextTabNumber(env.nextTabNumber);
setClosedPaneStack(env.closedPaneStack);
setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1))); setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
seedEmptyScopedPanes(incoming.length > 0 ? incoming : [emptyPane()]); seedEmptyScopedPanes(nextPanes);
}); });
}, [sessionId, seedEmptyScopedPanes]); }, [sessionId, seedEmptyScopedPanes]);
@@ -333,18 +405,75 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
// before saving (ephemeral per v1.9). // before saving (ephemeral per v1.9).
useEffect(() => { useEffect(() => {
if (!hydratedRef.current) return; if (!hydratedRef.current) return;
const payload = persistablePanes(panes); // v2.6.x: persist the full WorkspaceState envelope. The dedup ref compares
const json = JSON.stringify(payload); // the whole envelope so tabNumber / reopen-stack changes also persist.
const envelope: WorkspaceState = {
panes: persistablePanes(panes),
tabNumbers,
nextTabNumber,
closedPaneStack,
};
const json = JSON.stringify(envelope);
if (json === lastRemoteJsonRef.current) return; if (json === lastRemoteJsonRef.current) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
lastRemoteJsonRef.current = json; lastRemoteJsonRef.current = json;
api.sessions.updateWorkspacePanes(sessionId, payload).catch(() => { api.sessions.updateWorkspacePanes(sessionId, envelope).catch(() => {
// Non-fatal: next change retries. Persistent failures surface via // Non-fatal: next change retries. Persistent failures surface via
// the network layer's existing reconnect toast. // the network layer's existing reconnect toast.
}); });
}, SAVE_DEBOUNCE_MS); }, SAVE_DEBOUNCE_MS);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [sessionId, panes]); }, [sessionId, panes, tabNumbers, nextTabNumber, closedPaneStack]);
// v2.6.x (Batch 3a): maintain stable, session-scoped tab numbers. Collect the
// chat ids that appear in CHAT-kind panes in deterministic order (pane index,
// then tab index). Assign numbers to any without one (global per session,
// only ever increasing, never reused) and prune entries whose chat is no
// longer in any chat-kind pane. Guarded against render loops: only setState
// when something actually changed.
useEffect(() => {
const liveChatIds: string[] = [];
const liveSet = new Set<string>();
for (const pane of panes) {
if (pane.kind !== 'chat') continue;
for (const id of pane.chatIds) {
if (!liveSet.has(id)) {
liveSet.add(id);
liveChatIds.push(id);
}
}
}
// Assign: walk live ids in deterministic order, handing out numbers.
let counter = nextTabNumber;
const additions: Record<string, number> = {};
for (const id of liveChatIds) {
if (tabNumbers[id] === undefined && additions[id] === undefined) {
additions[id] = counter;
counter += 1;
}
}
// Prune: retire numbers for chats no longer in any chat-kind pane.
const removals: string[] = [];
for (const id of Object.keys(tabNumbers)) {
if (!liveSet.has(id)) removals.push(id);
}
const hasAdditions = Object.keys(additions).length > 0;
const hasRemovals = removals.length > 0;
if (!hasAdditions && !hasRemovals) return;
setTabNumbers((prev) => {
const next: Record<string, number> = {};
for (const [id, n] of Object.entries(prev)) {
if (!removals.includes(id)) next[id] = n;
}
Object.assign(next, additions);
return next;
});
if (hasAdditions) setNextTabNumber(counter);
}, [panes, tabNumbers, nextTabNumber]);
useEffect(() => { useEffect(() => {
const active = panes[activePaneIdx]; const active = panes[activePaneIdx];
@@ -391,6 +520,37 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
setActivePaneIdx(paneIdx); setActivePaneIdx(paneIdx);
}, []); }, []);
// Open a whole chat in its own fresh pane (focused). Detaches the chat from
// any pane currently showing it so it lives in exactly one pane (preserves
// the one-chat-per-pane model), dropping a source pane left with no tabs. For
// fork the chat isn't in any pane yet, so the detach is a no-op (pure append).
const openChatInNewPane = useCallback((chatId: string) => {
setPanes((prev) => {
const detached = prev.flatMap((p) => {
if (!p.chatIds.includes(chatId)) return [p];
const nextIds = p.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) return [];
const ai = Math.min(p.activeChatIdx, nextIds.length - 1);
return [{ ...p, kind: 'chat' as const, chatId: nextIds[ai], chatIds: nextIds, activeChatIdx: ai }];
});
if (nonSettingsCount(detached) >= MAX_PANES) {
toast.error(`Maximum ${MAX_PANES} panes`);
return prev;
}
const next = [...detached, chatPane(chatId)];
setActivePaneIdx(next.length - 1);
return next;
});
}, []);
// ChatTabBar's "Open in new pane" + MessageBubble.fork() emit this.
useEffect(() => {
return sessionEvents.subscribe((ev) => {
if (ev.type !== 'open_chat_in_new_pane') return;
openChatInNewPane(ev.chat_id);
});
}, [openChatInNewPane]);
const switchTab = useCallback((paneIdx: number, tabIdx: number) => { const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
setPanes((prev) => { setPanes((prev) => {
const next = [...prev]; const next = [...prev];
@@ -411,7 +571,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
if (next.length > 1) { if (next.length > 1) {
// Last tab closed and other panes exist — remove the whole pane // Last tab closed and other panes exist — remove the whole pane
// instead of leaving an orphaned empty panel. // instead of leaving an orphaned empty panel.
pushClosed(pane); setHasClosedPanes(true); setClosedPaneStack((stack) => appendClosed(stack, pane));
const spliced = next.filter((_, i) => i !== paneIdx); const spliced = next.filter((_, i) => i !== paneIdx);
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1)); setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
return spliced; return spliced;
@@ -547,7 +707,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
setPanes((prev) => { setPanes((prev) => {
if (prev.length <= 1) { if (prev.length <= 1) {
// Settings is the only kind that can be the last pane and still need // Settings is the only kind that can be the last pane and still need
// closing (X / Esc / sidebar toggle). Fall back to empty. // closing (X / Esc / sidebar toggle). Fall back to empty. One-pane
// edge: no relocation — there is no other pane.
if (prev[idx]?.kind === 'settings') { if (prev[idx]?.kind === 'settings') {
setActivePaneIdx(0); setActivePaneIdx(0);
return [emptyPane()]; return [emptyPane()];
@@ -559,35 +720,101 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
// The endpoint is idempotent (404 on missing session) so a strict-mode // The endpoint is idempotent (404 on missing session) so a strict-mode
// double-invoke of the updater is safe. // double-invoke of the updater is safe.
const removed = prev[idx]; const removed = prev[idx];
if (removed) { pushClosed(removed); setHasClosedPanes(true); } // Push the original pane (with its chatIds intact) to the reopen stack.
if (removed) setClosedPaneStack((stack) => appendClosed(stack, removed));
if (removed?.kind === 'terminal') { if (removed?.kind === 'terminal') {
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ }); api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
} }
const next = prev.filter((_, i) => i !== idx);
// v2.6.x (Batch 1): relocate a closing CHAT pane's tabs to the oldest
// remaining pane that can host chat tabs, so chats aren't lost on close.
// Only chat panes relocate — terminal/coder panes own a scoped chat bound
// to the pane, so those close exactly as before (no relocation).
let working = prev;
if (removed && removed.kind === 'chat' && removed.chatIds.length > 0) {
// "Oldest remaining": lowest index, excluding `idx`, that is a chat or
// empty pane (the only kinds that can host arbitrary chat tabs). Skip
// terminal/coder/settings/artifact panes.
let targetIdx = -1;
for (let i = 0; i < prev.length; i += 1) {
if (i === idx) continue;
const p = prev[i]!;
if (p.kind === 'chat' || p.kind === 'empty') {
targetIdx = i;
break;
}
}
if (targetIdx >= 0) {
working = prev.map((p, i) => {
if (i !== targetIdx) return p;
const mergedIds = [...p.chatIds, ...removed.chatIds];
// Preserve the target's existing focus — append, don't force-focus
// the moved tabs. Clamp only when the target had no active tab.
const ai = p.activeChatIdx >= 0 ? p.activeChatIdx : 0;
return {
...p,
kind: 'chat' as const,
chatIds: mergedIds,
activeChatIdx: ai,
chatId: mergedIds[ai],
};
});
}
}
const next = working.filter((_, i) => i !== idx);
setActivePaneIdx((ai) => Math.min(ai, next.length - 1)); setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next; return next;
}); });
}, [sessionId]); }, [sessionId]);
const [hasClosedPanes, setHasClosedPanes] = useState(closedPaneStack.length > 0); const hasClosedPanes = closedPaneStack.length > 0;
const reopenPane = useCallback(() => { const reopenPane = useCallback(() => {
const entry = closedPaneStack.pop(); // Read the top entry from the current render's stack (not inside the
setHasClosedPanes(closedPaneStack.length > 0); // updater) so a StrictMode double-invoke can't pop two entries. The pop
if (!entry) return; // setState is idempotent: filtering by reference removes exactly this entry.
const e = closedPaneStack[closedPaneStack.length - 1];
if (!e) return;
setClosedPaneStack((stack) => (stack[stack.length - 1] === e ? stack.slice(0, -1) : stack));
setPanes((prev) => { setPanes((prev) => {
// v2.6.x (Batch 4): reversible reopen. The closed tabs may have been
// relocated into another pane on close (Batch 1). Strip e.chatIds from
// every existing pane first so reopening never duplicates a tab —
// whether or not it was relocated (a no-op strip when it wasn't). Mirror
// removeTab's emptiness handling: a chat pane emptied by the strip is
// dropped when other panes remain, else turned empty.
const stripped: WorkspacePane[] = [];
for (const p of prev) {
const idxs = p.chatIds.filter((id) => !e.chatIds.includes(id));
if (idxs.length === p.chatIds.length) {
stripped.push(p);
continue;
}
if (idxs.length === 0) {
if (p.kind === 'chat') {
// Drop the now-empty chat pane (we still have the restored pane plus
// possibly others). If it would leave zero panes, turn it empty.
continue;
}
stripped.push({ ...p, chatId: undefined, chatIds: [], activeChatIdx: -1 });
continue;
}
const ai = Math.min(p.activeChatIdx, idxs.length - 1);
stripped.push({ ...p, chatIds: idxs, activeChatIdx: ai < 0 ? 0 : ai, chatId: idxs[ai < 0 ? 0 : ai] });
}
const restored: WorkspacePane = { const restored: WorkspacePane = {
id: generateId(), id: generateId(),
kind: entry.kind, kind: e.kind,
chatId: entry.chatIds[entry.activeChatIdx] ?? entry.chatIds[0], chatId: e.chatIds[e.activeChatIdx] ?? e.chatIds[0],
chatIds: entry.chatIds, chatIds: e.chatIds,
activeChatIdx: Math.min(entry.activeChatIdx, entry.chatIds.length - 1), activeChatIdx: Math.min(e.activeChatIdx, e.chatIds.length - 1),
}; };
const next = [...prev, restored]; const next = [...stripped, restored];
setActivePaneIdx(next.length - 1); setActivePaneIdx(next.length - 1);
return next; return next;
}); });
}, []); }, [closedPaneStack]);
// Replaces a single empty default pane with a chat pane. Used by the initial // 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. // chat fetch to land on the most-recent open chat if no saved pane state.
@@ -705,6 +932,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return { return {
panes, panes,
tabNumbers,
activePaneIdx, activePaneIdx,
setActivePaneIdx, setActivePaneIdx,
activePaneIdxRef, activePaneIdxRef,

View File

@@ -56,19 +56,26 @@ export function inferLanguage(filename: string): string | null {
export function flattenToMessage(attachments: Attachment[], text: string): string { export function flattenToMessage(attachments: Attachment[], text: string): string {
if (attachments.length === 0) return text; if (attachments.length === 0) return text;
const blocks = attachments.map(a => { // Pasted text is raw context, not code from a file — insert it verbatim with no
// Pasted text is raw context, not code from a file — insert it verbatim with // ``` fence or provenance header. It trails the typed text with a leading space
// no ``` fence or provenance header. The chip only exists to keep the textarea // so a leading slash command / prompt stays first and the paste reads as its
// tidy while composing; on send it should be exactly what the user pasted. // continuation. File/line chips stay fenced provenance blocks, appended after.
const pasteBlocks: string[] = [];
const fencedBlocks: string[] = [];
for (const a of attachments) {
if (a.kind === 'paste') { if (a.kind === 'paste') {
return a.content; pasteBlocks.push(a.content);
continue;
} }
const fence = '```' + (a.language ?? ''); const fence = '```' + (a.language ?? '');
const header = const header =
a.kind === 'lines' a.kind === 'lines'
? `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}` ? `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`
: `// from: ${a.filename}`; : `// from: ${a.filename}`;
return `${fence}\n${header}\n${a.content}\n\`\`\``; fencedBlocks.push(`${fence}\n${header}\n${a.content}\n\`\`\``);
}); }
return [...blocks, text].filter(Boolean).join('\n\n'); // Typed text + pasted content on the same logical line (space-joined), then
// any fenced file blocks as separate paragraphs.
const lead = [text, ...pasteBlocks].filter(Boolean).join(' ');
return [lead, ...fencedBlocks].filter(Boolean).join('\n\n');
} }

249
boocode_code_review_v2.md Normal file
View File

@@ -0,0 +1,249 @@
# BooCode — External Code Review v2 (lift findings)
Last updated: 2026-05-31
A point-in-time **findings** doc, not a standing inventory. It consolidates two reconnaissance passes against the upstream forks at `/opt/forks/` and decides, per area, what BooCode should do about it. Pin it so the same upstreams aren't re-evaluated from scratch next month.
> **Companion docs:** `boocode_code_review.md` is the standing external-repo inventory (every repo BooCode references, *why* each earned its row, license analysis). `boocode_roadmap.md` is the canonical shipping-state / version-ordering source. This v2 doc is the **action layer** on top of both: "given what's upstream as of 2026-05-31, here's the lift/cross-check/re-derive/n-a call." Reconcile shipping state via the roadmap when in doubt; fold durable rows back into `boocode_code_review.md`.
## Sources feeding this doc
1. **Paseo recon (Sam)** — two passes: a Phase 2/3 server-manager recon and a claude-transport recon. Conclusions consolidated by area below (§2a). AGPL-3.0 — **pattern-only, no code lift, ever.**
2. **Three-fork agent sweep (this session, 2026-05-31)** — read-only general-purpose agents over `anomalyco/opencode` (MIT, code-liftable), `getpaseo/paseo` (AGPL, pattern-only), `ggml-org/llama.cpp` (consumed via llama-swap/sidecar — adopt features/flags, not C++). Detail in §2§4.
3. **Second fork sweep (this session, 2026-05-31)** — 8 read-only agents over the remaining 11 repos in `/opt/forks/` (conductor, superset, openchamber, happy, cline, qwen-code, amp-acp, pi-acp, claude-code, goose, unsloth). Detail in §5; high-value items folded into §1.
### Caveats
- `/opt/forks/llama.cpp` is a **shallow clone** (90 commits, ~5 days visible). llama.cpp findings are read from source as it stands today; "what changed when" attribution is limited. `git fetch --unshallow` before the next review.
- `/opt/forks/opencode` arrived shallow (rooted 2026-05-25); the agent ran `git fetch --unshallow` and re-surveyed the real 6-week window. opencode also did a v2 Effect/event-sourced rewrite (`packages/core/`, `packages/llm/`) — most of that churn is architecturally divergent and ruled out.
- HEADs at review time: paseo `41cb1af` (main, v0.1.87), opencode `1afa9e3` (dev, ~v1.15.13), llama.cpp `aa46bda8` (detached).
## Verdict legend
| Verdict | Meaning |
|---|---|
| **LIFT** | Take it. Flavor noted: *code-lift* (MIT), *pattern-lift* (AGPL/clean-room re-impl), *config-adopt* (new upstream flag), *drop-our-code* (upstream now does it → delete ours). |
| **RE-DERIVE** | Idea is right, their impl is insufficient/divergent for our needs — write fresh, don't adapt theirs. |
| **CROSS-CHECK** | We already have it; confirmed current vs upstream. No action. |
| **TRACK** | Behavioral/external change to be aware of. No code action now. |
| **N-A** | Not liftable into our architecture, or reduces to a separate decision. |
-----
## 1. Net actionables (priority roll-up)
Updated after the **second fork sweep** (2026-05-31, §5). New items from that sweep are tagged ⁺.
| # | Item | Source | Verdict | Maps to | Effort |
|---|------|--------|---------|---------|--------|
| 1 | **Retire the AGPL tool-call parser + html-to-md** — llama-server parses qwen3.x `<tool_call>`/`<function=>` server-side; both lifted files are **confirmed AGPL-3.0-only** (§5k); swap html-to-md to a permissive lib | llama.cpp + unsloth⁺ | LIFT · drop-our-code | license-debt / inference; new batch | M, staged + gated on jinja |
| 2 | **Warm-ACP backend (goose/qwen)** — one spawn, one `session/new`, many prompts; **validated by qwen's own `qwen --acp` reference impl** (the "qwen ACP was HTTP-only" premise is stale) | Paseo recon + qwen-code⁺ | LIFT · pattern | **v2.6 Phase 2** | M |
| 3 ⁺ | **Fuzzy patch applier for `edit_file`** — exact→whitespace→Levenshtein match ladder + unicode canon + multi-occurrence guard; BooCoder's `edit_file` is exact-`.includes`-or-throw today | cline⁺ | LIFT · code | edit/diff robustness (local-model drift) | M |
| 4 ⁺ | **`git stash create` + private-ref checkpoint** — per-turn workspace snapshot capturing **all** state incl. external-agent edits (BooCode `rewind` only undoes its own queued edits) | cline⁺ | LIFT · code | checkpoint/restore UX | M |
| 5 ⁺ | **opencode lifecycle hardening** — health monitor + crash auto-restart + busy-aware restart + port reclaim + stall-detecting SSE; **MIT, same warm-server architecture** (supersedes the paseo RE-DERIVE — better source) | openchamber⁺ | LIFT · pattern/code | **v2.6 Phase 3** | M |
| 6 | **Post-interrupt stale-terminal guard** — confirmed correctness bug in `opencode-server.ts` | opencode/paseo (verified) | LIFT · pattern (bugfix) | v2.6 Phase 1/2 | S (~½ day) |
| 7 ⁺ | **Parse qwen/claude `stream-json` NDJSON in PTY fallback** — today stdout is sliced opaque; one parser serves both (Claude-Code-compatible schema) | qwen-code⁺ | LIFT · pattern | v2.6 Phase 2 / dispatch parsing | S |
| 8 | **ctx/token usage for opencode sessions**`session.next.step.ended` already on the wire | opencode + paseo (converged) | LIFT · code | v2.6 Phase 1 UX | SM (~80150 LoC) |
| 9 | **Claude continuity + transport**`--resume` via hook/jsonl-watcher → `claudeSessionId`; **happy proves the `@anthropic-ai/claude-agent-sdk` path** (resolves the SDK-vs-PTY decision — lean SDK) | Paseo recon + happy⁺ | LIFT · code + decision | claude-provider batch | M |
| 10 ⁺ | **Universal-agent notify-hook → normalized status** — inject a hook into each PTY agent's config, normalize ~30 event names → 5 states; gives goose/qwen/claude real working/blocked/done signals | superset⁺ (clean-room, ELv2) | RE-DERIVE | v2.6 Phase 2/3 status | MH |
| 11 | **New sampling knobs** `top_n_sigma`, `dry_*` family; **`--reasoning-budget`** | llama.cpp | LIFT · config-adopt | AGENTS.md frontmatter + validator allowlist | S |
| 12 ⁺ | **File-provenance compaction ledger** (`## Files Read/Modified`) + **`MistakeTracker`** (heterogeneous-failure recovery) | cline⁺ | LIFT · pattern | context-mgmt / recovery | SM |
| 13 | Bundle/watch: stall-timeout + retry/backoff (opencode); worktree-archive cascade (paseo); LRU-bound caches; subagent permission demux; tool-pair-atomic prune cross-check (cline)⁺; diff-line→agent re-prompt (superset)⁺ | mixed | WATCH | Phase 2/3, review UX, resilience | varies |
**Headline:** #1 stays the strategic win and is now **settled, not speculative** — the unsloth recon confirmed both lifted files are AGPL-3.0-only (§5k); the only gate is the jinja config check (§6). The second sweep added four genuinely-new code lifts: **#3 fuzzy patch applier** and **#4 git-stash checkpoint** (both cline, both directly fix where BooCoder's write/edit surface is weakest for local models), **#5 openchamber lifecycle hardening** (the concrete, MIT, same-architecture answer to v2.6 Phase 3 — supersedes the weaker paseo re-derive), and **#7 stream-json parsing** (cheap, shared by qwen+claude PTY). #2 Phase-2 warm-ACP is now de-risked by qwen's own reference impl. #9 resolves the claude direction (lean SDK).
-----
## 2. Paseo (AGPL-3.0 — pattern-only)
### 2a. Consolidated recon, by area (Sam's two passes)
| Area | Verdict | One-line |
|------|---------|----------|
| OpenCode server lifecycle | **CROSS-CHECK** | Paseo hand-rolls the spawn (not `createOpencodeServer`), waits for "listening on" on stdout, port-0 allocation, concurrent callers wait on one `startPromise`, no `OPENCODE_SERVER_PASSWORD`. Same shape BooCode shipped in v2.6.1 — nothing to lift. |
| OpenCode crash recovery + reconnect | **RE-DERIVE → superseded** | Lazy restart-on-demand (exit handler nulls the server, next `getCurrentServer()` respawns), no active supervision; `resumeSession` does **not** verify the session exists on disk before resuming. Insufficient for Phase 3. **Update (2nd sweep):** `openchamber` (§5c) has a *better, MIT, same-architecture* version — health-monitor state machine + crash auto-restart + busy-aware restart. Lift from openchamber, not paseo. |
| Warm-ACP supervision (goose/qwen) | **LIFT · pattern** | `SpawnedACPProcess`: one spawn, one `session/new`, many prompts; child lives for the session not the turn; per-turn abort = `connection.cancel({sessionId})` **without killing the child**; child-exit fires `turn_failed` (no restart). Clean signal split; integrates against BooCode's existing `acp-dispatch.ts`. **This is the Phase 2 lift — and qwen-code (§5f) ships its own `qwen --acp` reference impl that validates the whole approach.** |
| OpenCode reasoning dedup | **CROSS-CHECK** | `streamedPartKeys` keyed `reasoning:${partID}`; delta adds the key, final part skips if present, cleared per turn. Identical to v2.6.1. |
| Claude transport | **N-A** | Paseo uses `@anthropic-ai/claude-agent-sdk` in stream-json mode, not PTY. Getting Paseo's transport means adopting the SDK — net-new integration, not a lift. |
| Claude continuity | **LIFT · code** | `claude --resume <sessionId>` across turns: capture the session id from claude's output, store it, pass `--resume` next turn; claude re-reads its transcript and continues. Small change to BooCode's PTY dispatch (run with `--output-format stream-json`, parse the id, persist, resume). **The actionable claude finding.** |
| Claude streaming/parsing | **N-A** | Structured events (tool calls, reasoning, partials) come from the SDK; PTY degrades to scraping. Adopting structured claude streaming = adopting the SDK — separate decision. |
| Claude session persistence | **CROSS-CHECK** | Same `describePersistence`/`resumeSession` shape BooCode already has for opencode; claude slots in. Neither Paseo nor BooCode verifies the transcript exists on disk before resume (**shared open question** — see §5). |
**Recon's net:** LIFT = warm-ACP supervision (Phase 2) + claude `--resume` continuity (standalone batch). RE-DERIVE = OpenCode crash recovery (Phase 3). Everything else cross-check or n/a. The two n/a claude items both reduce to **one deferred decision: adopt `@anthropic-ai/claude-agent-sdk` or stay PTY.**
### 2b. Additional findings (this session's Paseo agent sweep)
These came from the broader agent pass, not the targeted Phase 2/3 recon. Where they touch the same code as §2a, the §2a recon is authoritative.
| Finding | Verdict | Notes |
|---------|---------|-------|
| **Post-interrupt stale-terminal suppression** (paseo `1d38aac`) | **LIFT · pattern (bugfix)** | See §3 #3 — verified to be a live bug in BooCode. Highest-confidence paseo item. |
| **Provider-agnostic `AgentUsage`** normalized usage/cost frame | **LIFT · pattern** | Converges with opencode's `session.next.step.ended` (§3 #4). Paseo's `{inputTokens, cachedInputTokens, outputTokens, totalCostUsd, contextWindowMax/Used}` is the target *shape* for normalizing across providers; do the opencode slice first. |
| **Worktree-archive → cascade-archive agents + schedule cleanup** (paseo `b6103a5`) | **WATCH → adopt in Phase 3** | Soft-delete (keep `archivedAt`), single archive event fans out to children + downstream rows, `Promise.allSettled` so one failed delete doesn't abandon the rest. Right shape for the v2.6 Phase 3 worktree reaper. |
| **Server retire/refcount + LRU-bound caches** (paseo `server-manager.ts`, leak-fix `f20393d`) | **WATCH** (low confidence) | The agent read a retire-set/refcount mechanism; the §2a server-manager recon concluded "nothing to lift." Treat the *lifecycle* as cross-check (§2a wins). The one durable takeaway: **bound the per-session/per-worktree Maps in the warm opencode server** (long-lived daemon → unbounded caches leak). Confirm against §2a before acting. |
| **Subagent permission forwarding** (paseo `44863ec`) | **WATCH (gated)** | opencode `task` tool spawns child sessions; forward `permission.asked` from tracked children by `parentID` demux. **Blocked:** BooCode's opencode-SSE path has zero permission handling today (runs auto-approve). Reachable only after BooCoder builds opencode-SSE permission cards at all. Ties to v2.4. |
-----
## 3. OpenCode (MIT — code-liftable)
| # | Finding | Evidence | Verdict | Notes |
|---|---------|----------|---------|-------|
| 1 | **Consume the fuller `session.next.*` event set** in `opencode-server.ts` | `packages/core/src/session/event.ts:105-365`; BooCode handles only ~5 arms (`opencode-server.ts:215-311`) | **LIFT · code** | Events already in the **installed** `@opencode-ai/sdk`**no dep bump.** High-value arms: **`step.ended`** (`{tokens{input,output,reasoning,cache},cost}`#4 below); **`compaction.{started,delta,ended}`** (warm server auto-compacts mid-conversation; today shows as a silent context gap); `tool.progress`, `tool.input.{started,delta}`, `retried`, `step.failed`. |
| 4 | **ctx/token usage for opencode** (the high-value slice of #1) | `event.ts:117-135` | **LIFT · code** | Closes the roadmap-named gap: *"opencode/goose/qwen/claude dispatch with no ctx/token usage; only native boocode tracks ctx."* Mirror BooChat's existing `'usage'` WS frame on the coder side; accumulate per `(chat, agent)`. Converges with paseo `AgentUsage` (§2b). |
| 2 | **Stalled-stream chunk-timeout** (`wrapSSE` + header timeout) | `provider/provider.ts:40-96` (`f965db9`, `c7e1fc5`) | **WATCH · pattern** | BooChat's `stream-phase.ts` has **no server-side stall timeout** — a hung llama-swap stream relies entirely on the frontend 60s `discard_stale` watchdog. ~40-60 LoC to wrap the `fullStream` loop with a per-chunk timeout firing the existing abort path. Low incidence on a single local instance; do it if stuck rows recur. |
| 3 | **Retry-with-backoff + retryability classifier** (`session/retry.ts`) | `session/retry.ts`, `message-v2.ts:1155` (`14e0b9b`) | **WATCH · pattern** | BooChat has **zero** retry logic. `delay()` parses `retry-after[-ms]` headers w/ exp-backoff fallback; `retryable()` classifies transient-5xx / rate-limit / context-overflow-exclusion. Strip the Go-billing arms. Pairs naturally with #2. llama-swap rarely emits `retry-after`, so value is mostly transient-5xx/stall retry. |
| — | **MCP auth file-lock** (`mcp/auth.ts`, `fa73ec4`) | — | **N-A (deferred)** | Serializes concurrent OAuth token refreshes. Can't trigger — BooCode's config schema *rejects* OAuth MCP servers until secret storage lands (roadmap). Note for when OAuth MCP is un-deferred. |
**Confirmed current (cross-check, no refresh needed):** compaction algorithm (incl. `tail_start_id`/`splitTurn` post-fix — verified identical), two-tier prune, truncate, run-loop (BooCode drives off live `result.toolCalls`, not a history scan — not vulnerable to opencode's interrupted-tool re-prompt bug), doom-loop guard, MCP client, permission ruleset. **Ruled out:** v2 Effect/event-sourced core, `packages/llm/` native runtime (diverges from the AI SDK v6 BooCode just adopted), adaptive-reasoning (cloud-Anthropic only), `acp-next` (BooCoder is the ACP *client*).
-----
## 4. llama.cpp (consumed via llama-swap / llama-sidecar — adopt features, not C++)
### 4a. ⭐ Retire the AGPL tool-call parser — **LIFT · drop-our-code**
llama-server moved to a **template-learning PEG auto-parser + lazy grammar** that parses qwen3.5/3.6's tool markup server-side into OpenAI `tool_calls`.
- **Evidence:** `common/chat-auto-parser-generator.cpp`, `common/chat-diff-analyzer.cpp` (1570 lines), `common/chat-peg-parser.cpp`; shipped `models/templates/Qwen3.5-4B.jinja` (uses BooCode's exact Pattern-2 `<tool_call><function=…><parameter=…>` + `<think>`); server emits structured `tool_calls` in **both** non-streaming and streaming (`tools/server/server-chat.cpp:421-577`), reasoning split into `reasoning_content`/`reasoning_content_delta`. `tool_choice=required` + grammar-constrained calls exist (`common/chat.cpp:290-300`).
- **Gate (the one open question):** only fires if llama-server runs with **`--jinja` + a qwen3.x template**. BooCode already treats `--jinja`/`--chat-template*` as managed flags (`llama-args-validator.ts:92-102`) and sends `tools`/`toolChoice:'auto'` through the AI SDK (`stream-phase.ts:202,438`) — the path is wired; the unknown is whether the **live llama-swap/sidecar model config passes `--jinja`** (§5).
- **What's missing:** no qwen3.x-named native handler — qwen3.6 rides the generic template-driven path. The template teaches Patterns 1 (`<tool_call>{json}`) and 2 (`<function=…>`) but **not Pattern 3 (`<invoke name=…>`)**, the Anthropic-shape residue qwen drifts into.
- **Staged plan (do not delete blind — CLAUDE.md notes qwen3.6 was unreliable):**
1. Confirm `--jinja` + Qwen3.5 template are live (add the flags if not).
2. Validate native `tool_calls` against **real qwen3.6 streaming** for one release, behind a feature flag.
3. Trim `tool-call-parser.ts` to a **clean-room `<invoke>`-only fallback** (~250 of 427 lines deletable; rewrite the remainder without Unsloth/AGPL provenance). **Net: AGPL-3.0 liability eliminated** even if a thin fallback stays.
### 4b. Config-level adopts — **LIFT · config-adopt** (pass straight through llama-swap as OpenAI-compat body fields; no binary upgrade)
- **New sampling params** (`server-task.cpp:279-290`): `top_n_sigma`, `xtc_probability/threshold`, `typical_p`, the **`dry_*` repetition family** (`dry_multiplier/base/allowed_length/penalty_last_n/sequence_breakers`), `frequency_penalty`, `repeat_penalty`. `top_n_sigma` + `dry_*` are the high-value pair for an agentic model prone to loops — ties to the doom-loop sentinel. Surface in AGENTS.md frontmatter + the validator allowlist.
- **`--reasoning-budget N`** (`LLAMA_ARG_THINK_BUDGET`) + `--reasoning on|off|auto`, default `reasoning_format=auto`: server-side cap on qwen3.6 thinking (cheaper turns) without prompt hacks, and `reasoning_content` arrives as a **separate field** — BooCode could consume it directly instead of scraping `<think>`.
### 4c. Behavioral changes — **TRACK** (no code action; awareness)
- **SSE headers sent at slot-start** (`0821c5fcf`): in stream mode, HTTP 200 + headers flush when prompt processing *begins*, before the first token. BooCode keys its stale-stream timer on **token activity**, not header arrival → safe, but time-to-headers semantics shift. Also `task_params.stream` default flipped `true → false` — harmless for BooCode (always sets `stream`), but any llama-swap/sidecar code omitting `stream` now defaults to non-streaming.
- **`/props` router-mode dummy `n_ctx:0`** (`server-models.cpp:1170-1173`): llama.cpp gained a native multi-model router; its **bare** `/props` (no `?model=`) returns `n_ctx:0`. BooCode reads `/upstream/<model>/props` which resolves to a specific model → still correct today. Silent failure mode only if a bare router `/props` is ever hit: `ctx_max=0` → rejected → negative-cache masks the misconfig → compaction budget degrades. (Aside: the native router could eventually **replace llama-swap** — separate evaluation.)
- **`LLAMA_ARG_` env-prefix unification** (`6b4e4bd58`): confirm the sidecar's `LLAMA_*` env vars use the `LLAMA_ARG_` prefix.
### 4d. **SKIP**
- Native **Anthropic Messages API** in llama-server (`test_compat_anthropic.py`) — BooCode is OpenAI-compat via the AI SDK; switching wire formats buys nothing. (Minor TRACK: could in principle back a local "claude-compatible" provider — net-new feature, not a lift.)
- Qwen 3.5/3.6 **TP granularity fix** (`8b0e0db60`) — only relevant if running qwen3.6 across 3 GPUs with tensor-parallel; then it's a binary-upgrade correctness fix, not an API change.
- HTTP ETags / `--api-key-file` / timeout bump — irrelevant behind Authelia + llama-swap.
-----
## 5. Second fork sweep (2026-05-31) — 11 repos
Read-only agent review of everything else in `/opt/forks/` except the three already covered (paseo/opencode/llama.cpp), BooCode's own `llama-sidecar`, and `codecontext`/`codesight` (skipped on request). Repos: **conductor, superset, openchamber, happy, cline, qwen-code, amp-acp, pi-acp, claude-code, goose, unsloth.** Shallow clones (history-limited but source intact): cline, qwen-code, amp-acp, pi-acp, claude-code, goose, unsloth. Full: conductor, superset, openchamber, happy.
### 5a. openchamber (`openchamber/openchamber`, **MIT** — code-liftable) ⭐
Multi-runtime (web/PWA/Electron/VS Code) GUI for **opencode-as-warm-server** — the closest architectural sibling to BooCoder's backend. **Stronger than BooCode in exactly one dimension: opencode process-lifecycle hardening** (BooCode's v2.6 Phase 3 frontier). Divergence shaping every lift: openchamber runs **one global opencode server + one `/global/event` stream**; BooCode runs per-`(chat,agent)` sessions with per-session `event.subscribe({directory})` — so these are pattern/code-adaptation lifts, not drop-ins.
| # | Finding | Evidence (HEAD `a394a877`) | Verdict | Maps to |
|---|---------|---------|---------|---------|
| 5c | **Lifecycle hardening: health monitor + crash auto-restart + busy-aware restart** | `packages/web/server/lib/opencode/lifecycle.js``runHealthCheckCycle` (L896), `HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES=20`, `shouldSkipRestartForBusySessions`+`STALE_BUSY_GRACE_MS` (L872/838), `startHealthMonitoring` 15s (L938), `triggerHealthCheck` (L930). BooCode's `opencode-server.ts:143` literally comments *"recovery is Phase 3"* | **LIFT · pattern** | **v2.6 Phase 3** (#5) |
| | **Port reclaim before respawn** (`killProcessOnPort` lsof+kill, `waitForPortRelease` net.connect poll) | `lifecycle.js:44,101`, used in `restartOpenCode` L595 | LIFT · code (S) | Phase 3 |
| | **Stall-detecting SSE reader + `Last-Event-ID` replay** (2048-event ring, 20s stall-abort) | `lib/event-stream/upstream-reader.js:110-131`, `global-hub.js:88-149` | LIFT · pattern (the stall-timer half is S, high-value) | hardens `runSessionEventLoop` |
| | **`OPENCODE_SERVER_PASSWORD` scheme confirmed** = `Authorization: Basic base64("opencode:"+pw)`, rotate-on-restart | `packages/vscode/src/opencode.ts:55-65,786`; `lifecycle.js:458` | CROSS-CHECK → LIFT · config | closes a known unknown (BooCode runs the warm server unsecured on loopback) |
| | Worktree layout/reaper mirrors opencode's `<data>/worktree/<projectID>/`; `removeWorktree` saga | `packages/vscode/src/gitService.ts:1062,1874` | CROSS-CHECK | Phase 3 reaper; check BooCode's worktree paths align with opencode's expected layout |
Ruled out: warm-ACP/goose/qwen/claude (openchamber is **opencode-only**), SSE part-translation/reasoning-dedup (BooCode's is more complete), Arena-equivalent, permission cards — all already-better-in-BooCode or N-A.
### 5b. cline (`cline/cline`, **Apache-2.0** — code-liftable) ⭐
Re-architected into a layered SDK. Two strong **code** lifts that hit exactly where BooCoder's write/edit surface is weakest for local quantized models.
| # | Finding | Evidence (HEAD `31a118f`) | Verdict | Maps to |
|---|---------|---------|---------|---------|
| 5d | **`git stash create` + private-ref checkpoint** — per-turn snapshot of full dirty worktree, GC-safe, invisible to `git stash list`, restorable with conversation-trim in sync | `sdk/packages/core/src/hooks/checkpoint-hooks.ts:177-253`; `session/checkpoint-restore.ts:161-189` | **LIFT · code+pattern** (#4) | checkpoint/restore — captures **external-agent** edits BooCode's `rewind` can't |
| 5e | **Fuzzy patch applier** — exact→`trimEnd``trim`→Levenshtein≥0.66 ladder + unicode canon (dashes/curly-quotes/nbsp) + multi-occurrence guard; unmatched→warning not throw | `extensions/tools/executors/apply-patch-parser.ts:347-431,58-83`; `editor.ts:133-143` | **LIFT · code** (#3) | BooCoder `edit_file` is exact `.includes`-or-throw (`pending_changes.ts:111`) |
| | **File-provenance carry-forward**`## Files {Read,Modified}` ledger merged across compactions, deterministic | `extensions/context/compaction-shared.ts:351-410` | LIFT · pattern (#12) | context-mgmt |
| | **`MistakeTracker`** — counts *heterogeneous* consecutive failures (api/invalid-tool/exec), injects recovery guidance + resets vs hard-stop | `runtime/safety/mistake-tracker.ts:82-142` | LIFT · pattern (#12) | complements doom-loop (which only catches *identical* repeats) |
| | Tool-pair-atomic compaction eviction (BFS over `tool_use_id`, turn-boundary cut) | `extensions/context/basic-compaction.ts:181-205` | CROSS-CHECK | verify `selectPruneTargets` never orphans a `tool_result` |
Ruled out: prompt-caching (Anthropic `cache_control` markers — N-A, llama.cpp auto-prefix-caches), stream retry (delegated to AI SDK — same as BooCode), MCP marketplace, hub/daemon (multi-client — BooCode is single-process).
### 5f. qwen-code (`QwenLM/qwen-code` v0.17.0, **Apache-2.0** — code-liftable) ⭐
**The "qwen = one-shot PTY because ACP was HTTP-only" premise is obsolete.** qwen now ships a full stdio-ACP agent, a `qwen serve` HTTP+SSE daemon, and a Claude-Code-compatible stream-json protocol.
| # | Finding | Evidence | Verdict | Maps to |
|---|---------|---------|---------|---------|
| | **Warm `qwen --acp` is real** — multi-session `Map<sessionId,Session>`, `loadSession`/`unstable_resumeSession`, `setSessionMode`/`unstable_setSessionModel`, stdio NDJSON via `@agentclientprotocol/sdk` | `packages/cli/src/acp-integration/acpAgent.ts:308,322-351,384-568` | CROSS-CHECK → **LIFT · pattern** (#2) | **v2.6 Phase 2** — validates the openspec plan; wire goose/qwen to `acp-dispatch.ts` |
| 5g | **stream-json = Claude-compatible NDJSON** (`system`/`assistant`/`result`/`stream_event` with `content_block_delta` text/thinking/tool deltas, `usage`, `session_id`) — BooCode **parses none of it** (`dispatcher.ts:406` slices stdout opaque) | `nonInteractive/types.ts:88-262`, `StreamJsonOutputAdapter.ts` | **LIFT · pattern** (#7) | one parser serves qwen **and** claude PTY fallbacks |
| | **Resume primitives** `--resume <uuid\|title>` / `--continue` / `--session-id <uuid>` / `--fork-session` | `config/config.ts:825-985,1668-1721` | LIFT · config | mint a stable per-`(chat,agent)` UUID; parity with claude `--resume` |
| | `qwen serve` daemon + `@qwen-code/sdk` (HTTP+SSE, **`Last-Event-ID` replay ring**, better than opencode's SSE) | `commands/serve.ts:51-266`; `packages/sdk-typescript/src/daemon/*` | TRACK | stdio-ACP is cheaper now; mine its SSE-reconnect design when hardening opencode SSE (converges w/ openchamber 5c) |
Note: BooCode ships `@agentclientprotocol/sdk@^0.22.1` (newer than qwen's `^0.14.1`) — same package family, BooCode ahead; **cross-check the v0.14↔v0.22 `initialize`/capability handshake before relying on `unstable_resumeSession`** (the `unstable_` prefix signals churn). Ruled out: the `rewind` commit (`c699738`) is a qwen-TUI history-count fix, not a wire event — N-A.
### 5h. happy (`slopus/happy`, **MIT** — code-liftable) ⭐
Mobile/remote client that drives **Claude Code** via the **`@anthropic-ai/claude-agent-sdk`** (NOT PTY). A working existence-proof for BooCode's claude SDK-vs-PTY decision.
| # | Finding | Evidence (HEAD `21c6ced`) | Verdict | Maps to |
|---|---------|---------|---------|---------|
| | **Claude Agent SDK in streaming-input mode** — one persistent `query()` fed a `PushableAsyncIterable<SDKUserMessage>`; structured `system/init` (tools/skills/mcp), `assistant`, `result`, tool parts — no stdout scraping | `claude/sdk/query.ts`, `claude/claudeRemote.ts:152-259`; dep `@anthropic-ai/claude-agent-sdk@^0.2.96` | **LIFT · pattern** + resolves the decision → **lean SDK** (#9) | claude-provider direction |
| 5i | **`--resume` continuity via SessionStart-hook + JSONL watcher** → captures Claude's UUID as `claudeSessionId`, fed back as SDK `resume:`; reconnect-safe (`treatExistingAsProcessed`) | `claude/utils/generateHookSettings.ts`, `sessionScanner.ts`, `claude/session.ts:113-127` | **LIFT · code** (#9) | cleanly separates Claude's UUID from BooCode's `(chat_id,agent)` key; **transport-independent — pays off even on PTY** |
| | `canUseTool` permission callback — single chokepoint, live `setPermissionMode`, bash-prefix allow-cache | `claude/claudeRemote.ts:134,169`, `permissionHandler.ts` | CROSS-CHECK | cleaner integration point than parsing PTY permission prompts |
| | Local↔remote single-session handoff (TTY ⇄ SDK share one Claude UUID); E2E socket.io relay | `claude/loop.ts:77-115`; `api/encryption.ts` | TRACK / N-A | relay N-A (Authelia owns auth); handoff only if BooTerm⇄CoderPane session-continue is ever wanted |
### 5j. superset (`superset-sh/superset`, **Elastic License 2.0 — source-available, PATTERN-ONLY**)
Electron macOS "code editor for AI agents"; runs every agent as a **raw PTY process** and learns state purely from **hooks the agents POST back** (no editor↔agent protocol, tracks **zero** tokens/cost). All items clean-room only.
| # | Finding | Evidence (HEAD `7f3e5b3`) | Verdict | Maps to |
|---|---------|---------|---------|---------|
| 5j | **Universal-agent lifecycle hooks → normalized status** — inject a notify hook into each agent's native config (`~/.claude/settings.json`, `~/.codex/hooks.json`, opencode plugin), POST `{terminalId,eventType,agent}`; server collapses ~30 vendor event names → 5 states | `apps/desktop/.../agent-setup/*`, `templates/notify-hook.template.sh`, `host-service/.../map-event-type.ts` | **RE-DERIVE** (#10) | gives BooCode's **PTY agents (goose/qwen/claude) real working/blocked/done state** it lacks today |
| | Worktree destroy saga — preflight `inspect` (dirty/unpushed) + ordered failure semantics + in-flight guard | `host-service/.../workspace-cleanup.ts` | RE-DERIVE | Phase 3 worktree reaper |
| | Out-of-process PTY daemon w/ crash supervision + adoption (circuit-breaker, adopted-PID liveness poll) | `host-service/.../DaemonSupervisor.ts` | RE-DERIVE / TRACK | Phase 3 (BooTerm tmux already does some) |
| | Diff-line → agent-comment re-prompt loop (select lines → send to existing session or new agent) | `apps/desktop/.../DiffPane/AgentCommentComposer/*` | RE-DERIVE | review/diff UX frontier |
Ruled out: token/cost (superset tracks **none** — BooCode ahead), permission cards (BooCode's intercept-and-render is richer; superset just chimes + bypass-flags the agent), editor↔agent protocol (there is none), all SaaS/cloud/billing plumbing.
### 5k. unsloth (`unslothai/unsloth`) — **decision-settling: CONFIRMED AGPL-3.0-only**
The lifted parser + HTML→MD converter ARE AGPL-3.0; the v2 clean-room recommendation stands. Unsloth is **dual-licensed**: core `unsloth/` lib = Apache-2.0 (`LICENSE`, `pyproject.toml`), but the `studio/` subtree = **AGPL-3.0-only** — dedicated `studio/LICENSE.AGPL-3.0`, `studio/package.json` `"license":"AGPL-3.0-only"`, README §line 262 carves Studio out explicitly, and **both lifted files carry per-file SPDX headers** (`studio/backend/core/inference/{tool_call_parser.py,_html_to_md.py}``# SPDX-License-Identifier: AGPL-3.0-only`). BooCode's ports already carry the AGPL SPDX header (obligation on-record). Network-served ⇒ **AGPL §13 network-copyleft is the live liability.** HTML→MD can be replaced outright by a permissive lib (turndown / node-html-markdown); the tool-call parser needs a clean-room rewrite from spec (the `<tool_call>`/`<function=>` grammar is short and re-derivable).
### 5l. conductor (`conductor-oss/conductor`, **Apache-2.0**, Java) — **LOW / near-NONE**
Confirmed **Netflix/Orkes Conductor** — enterprise distributed workflow engine (5600 commits, Spring/Flyway/Cassandra), **not** the Mac Claude-Code app. Wrong scale + wrong substrate (polling workers + Redis queues vs BooCode's single-user Postgres LISTEN/NOTIFY), and BooCode already sourced its task-DAG/dispatcher/pipelines/human_inbox from `agent-hub` + Roo Boomerang. **One** worth-a-glance reference: the **retry/backoff/timeout taxonomy** (`TaskDef.java` `RetryLogic{FIXED,LINEAR,EXP}` + `TimeoutPolicy`, delay formula in `DeciderService.java:634-680`, with jitter + total-time-budget guard) — BooCode has **no retries today**; copy the *field set + three formulas* when retries land. Everything else (decider-replay engine, 24 task mappers, fork-join, sub-workflow, human-task) = N-A, already-covered or wrong-scale.
### 5m. ACP provider candidates — amp-acp **SKIP**, pi-acp **WATCH**
Both are config-only adds to BooCode's v2.3 catalog (`{extends:'acp', label, command, env}`) and both use **`@agentclientprotocol/sdk@~0.22/0.12`, proto v1 — wire-compatible with BooCode's own `@agentclientprotocol/sdk@0.22.1`** (see correction in §6).
- **amp-acp** (`tao12345666333/amp-acp`, Apache-2.0): adapter for Sourcegraph **Amp**. `npx -y amp-acp` + `AMP_API_KEY`. **SKIP** — Amp is a **paid cloud product with no self-host / no BYO-key / no local-model path**; can't point at llama-swap. Keep only as the canonical *"does add-from-catalog work"* smoke entry (lowest-risk Apache-2.0 ACP adapter).
- **pi-acp** (`svkozak/pi-acp`, MIT): bridge for **pi** (spawns `pi --mode rpc`). `npx -y pi-acp`, pi free + self-hostable, dynamic model discovery. **WATCH** — but found **no evidence pi supports an OpenAI-compatible/llama-swap base URL** (cloud BYO-keys only today) + v0.0.27 maturity ("MVP", MCP not wired). Re-evaluate if pi adds a local provider — then it's a strong config-only ADD.
### 5n. claude-code & goose — low/cosmetic
- **claude-code** (`anthropics/claude-code`, depth-1): the public **issue-tracker/docs repo, not source.** Thin. No stream-json schema doc (keep relying on observed output). Notables: `CLAUDE_CODE_SESSION_ID` env injected into Bash-tool subprocesses (hook↔session correlation); `examples/settings/*.json` permission/sandbox shapes; `SKILL.md` frontmatter is simpler (`name/description/version`) than BooCode's `eval.yaml`. The one example hook (`bash_command_validator`) is the same family BooCode already vendored. **Nothing net-new liftable.**
- **goose** (`block/goose`, depth-1, Apache-2.0 Rust → pattern-only): the **AAIF/Linux-Foundation move is cosmetic** — binary `goose`, `goose acp` invocation, and `~/.config/goose/` config path all **UNCHANGED**; only org/URLs changed (`block/goose``aaif-goose/goose`). **Watch:** grep BooCode install docs for `block/goose` URLs (will eventually 404). **For v2.6 Phase 2:** goose ACP supports multi-session + mid-session model/mode switch + session persistence, but **no `loadSession`/resume method surfaced** → cross-restart resume looks thinner than opencode's; don't assume opencode-style `agent_sessions` resume works identically for goose.
-----
## 6. Open decisions / things to think about
1. **The jinja gate (blocks #1, the top item).** Is `--jinja` + a qwen3.x template live in the llama-swap/sidecar model config? Read-only to answer, but the config may live with the sidecar on sam-desktop (`100.101.41.16`) — needs Sam's OK before any SSH. *This single check decides whether the AGPL-parser retirement is actionable now or needs a config change first.*
2. **Claude transport: SDK vs PTY — now evidenced, leaning SDK.** `happy` (§5h) is a working existence-proof that `@anthropic-ai/claude-agent-sdk` in streaming-input mode drives Claude Code with structured events (tool calls, reasoning, `system/init` tool/skill/mcp lists, usage) and clean continuity — richer than PTY stdout-scraping. **Decision narrowed to: adopt the SDK** (net-new integration, ~100-line streaming-input pump) **vs. stay PTY + just add `--resume`.** Independent of warm-ACP Phase 2. Note the continuity mechanism (§5i hook + jsonl-watcher → `claudeSessionId`) is **transport-independent**, so ship it either way.
3. **`stream-json` parser is shared infrastructure, not a per-agent chore.** qwen-code (§5g) and claude-code emit the *same* Claude-Code-compatible NDJSON. One parser keyed on `type` / `stream_event.event.type` unlocks tool/reasoning/usage surfacing for **both** qwen and claude PTY fallbacks (today both are sliced opaque). Decide whether to build it as a shared module now (cheap) rather than twice later.
4. **Transcript/session verification before resume (shared gap).** Neither Paseo nor BooCode (nor openchamber, nor goose's ACP) verifies the session/transcript exists on disk before resuming — true for opencode, claude, qwen. Folds into v2.6 Phase 3 (crash recovery + active supervision, now lifting from openchamber §5c). Decide whether "resume blindly, recover on failure" is good enough for single-user, or worth a pre-resume existence check. **Caveat:** goose ACP exposes no `loadSession`/resume (§5n) → its cross-restart resume needs a different design than opencode's.
5. **Usage *and status* normalization scope.** Two converging gaps: (a) **tokens/cost** — the opencode token slice (#8) converges with paseo `AgentUsage`; (b) **liveness/status** — superset's notify-hook pattern (§5j, #10) is the only way to know whether a one-shot PTY agent (goose/qwen/claude) is working / blocked-on-permission / done. Decide whether to design one normalized per-`(chat,agent)` "agent telemetry" shape (tokens + status) up front so all providers slot in, or ship opencode-token-only and generalize at Phase 2.
6. **Correction — ACP SDK package.** This doc and the roadmap state BooCode uses `@zed-industries/agent-client-protocol`; the live `apps/coder/package.json` actually declares **`@agentclientprotocol/sdk@^0.22.1`** (verified installed). Both amp-acp and pi-acp use the same package, so the "version-drift" worry is moot. Worth correcting in `boocode_roadmap.md`'s lift table on the next pass.
-----
## 7. Housekeeping
- **Stale `.bak` in the working tree:** `apps/server/src/services/inference/tool-phase.ts.bak-20260531` (today, 15.5 KB). Violates CLAUDE.md's "don't accumulate `.bak-*`". Dated today and `tool-phase.ts` is on the active path — may be an in-progress safety copy. **Confirm before removing.**
- **Unshallow `/opt/forks/llama.cpp`** (`git fetch --unshallow`) before the next review so commit-level attribution is possible. (opencode was unshallowed mid-review; cline/qwen-code/amp-acp/pi-acp/claude-code/goose/unsloth remain shallow but their source was intact.)
- **Grep BooCode install docs/scripts for `block/goose` URLs** — goose moved to `aaif-goose/goose` (§5n); old release URLs will eventually 404.
- **Correct the ACP-SDK package name** in `boocode_roadmap.md`'s lift table → `@agentclientprotocol/sdk@0.22.1` (§6.6).
-----
## 8. Roadmap mapping (where each actionable lands)
| Roadmap slot | Items from this review |
|---|---|
| **v2.6 Phase 2** (warm ACP goose/qwen) | #2 warm-ACP backend — **validated by qwen's own `qwen --acp`** (§5f); #7 parse qwen/claude stream-json in the one-shot fallback |
| **v2.6 Phase 3** (lifecycle hardening) | **#5 openchamber lifecycle hardening** (health monitor + crash restart + port reclaim + stall-SSE — §5c, supersedes the paseo re-derive); worktree-archive cascade (paseo) + superset destroy-saga (§5j); LRU-bound caches; pre-resume session verification |
| **v2.6 Phase 1 UX** | #6 interrupt-bug fix; #8 opencode token/ctx usage; richer SSE arms (compaction surfacing) |
| **Write/edit robustness (NEW batch)** | **#3 fuzzy patch applier** + **#4 git-stash checkpoint** (cline §5b) — both directly harden BooCoder's edit/rewind surface for local models |
| **Cross-agent telemetry (NEW)** | #10 superset notify-hook → normalized **status** for PTY agents (§5j); pairs with #8 token usage |
| **Standalone claude-provider batch** | #9 `--resume` via hook/jsonl-watcher (§5i) + the SDK-vs-PTY decision (now lean-SDK, §6.2); #12 MistakeTracker + file-provenance ledger (cline) |
| **Inference / license-debt batch** | #1 AGPL parser retirement (**AGPL confirmed §5k**; gated on the jinja check §6.1); #11 sampling/reasoning-budget config adopts |
| **BooChat resilience (opportunistic)** | stall-timeout + retry/backoff (opencode); tool-pair-atomic prune cross-check (cline §5b) |
| **Provider catalog** | amp-acp = keep as add-from-catalog **smoke test only** (§5m); pi-acp = WATCH for a local-provider mode |
| **Deferred / gated** | subagent permission demux (needs opencode-SSE permission cards first); MCP auth lock (needs OAuth MCP un-deferred); `qwen serve` HTTP backend (stdio-ACP cheaper) |
| **Not actionable** | conductor (wrong scale — only the retry-taxonomy reference §5l); claude-code public repo (docs only §5n) |

View File

@@ -1,6 +1,6 @@
# BooCode roadmap (v1.xv2.x) # BooCode roadmap (v1.xv2.x)
Last updated: 2026-05-26 Last updated: 2026-05-31
> **Companion doc:** `boocode_code_review.md` holds the full external-repo inventory, lift rationale, and license analysis. This document is the canonical source for shipping state, version ordering, and what's planned vs. shipped. > **Companion doc:** `boocode_code_review.md` holds the full external-repo inventory, lift rationale, and license analysis. This document is the canonical source for shipping state, version ordering, and what's planned vs. shipped.
@@ -9,7 +9,7 @@ Last updated: 2026-05-26
BooCode is a **3-app monorepo** at `/opt/boocode/` (locked 2026-05-22): BooCode is a **3-app monorepo** at `/opt/boocode/` (locked 2026-05-22):
- **BooChat** (`apps/server` + `apps/web`, port `9500`, `code.indifferentketchup.com`) — read-only chat with file-inspection tools. Backend in `apps/server`, SPA in `apps/web`. Database `boochat` (renamed from `boocode` at v2.0). - **BooChat** (`apps/server` + `apps/web`, port `9500`, `code.indifferentketchup.com`) — read-only chat with file-inspection tools. Backend in `apps/server`, SPA in `apps/web`. Database `boochat` (renamed from `boocode` at v2.0).
- **BooCoder** (`apps/coder`, port `9502`, `coder.indifferentketchup.com`) — write tools + external-CLI dispatch. **Shipped v2.0.0v2.2.1.** Host systemd service (not Docker since v2.1.0). In-process inference (with `pending_changes` table) AND Paseo-style ACP dispatch for seven providers (cursor, opencode, goose, claude, qwen, copilot + native boocode) with PTY fallback where ACP is unavailable. - **BooCoder** (`apps/coder`, port `9502`, `coder.indifferentketchup.com`) — write tools + external-CLI dispatch. **Shipped v2.0.0v2.6.4.** Host systemd service (not Docker since v2.1.0). In-process inference (with `pending_changes` table) AND Paseo-style ACP dispatch for five providers (opencode, goose, claude, qwen + native boocode; cursor + copilot retired at v2.5.3) with PTY fallback where ACP is unavailable. Provider lifecycle is config-backed (`data/coder-providers.json`, enable/disable, two-tier probe — shipped v2.5.4v2.5.13). opencode now runs as a **warm HTTP server** with persistent per-chat sessions (v2.6 Phase 1); goose/qwen/claude still dispatch one-shot.
- **BooTerm** (`apps/booterm`, port `9501`) — PTY/tmux/xterm.js. **Live since May 2026.** bookworm-slim + node-pty + tmux + xterm.js. Tmux session per pane (`bc-<uuid>`), SSH-out works (openssh-client + gosu in the image). Shares Postgres database `boochat`. - **BooTerm** (`apps/booterm`, port `9501`) — PTY/tmux/xterm.js. **Live since May 2026.** bookworm-slim + node-pty + tmux + xterm.js. Tmux session per pane (`bc-<uuid>`), SSH-out works (openssh-client + gosu in the image). Shares Postgres database `boochat`.
Caddy → Authelia → Tailscale → `100.114.205.53` → 9500/9501/9502. Three apps, **one shared Postgres** (Docker service `boocode_db`, database name `boochat`). Caddy → Authelia → Tailscale → `100.114.205.53` → 9500/9501/9502. Three apps, **one shared Postgres** (Docker service `boocode_db`, database name `boochat`).
@@ -348,9 +348,43 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
----- -----
## Shipped (v2.2.2v2.6.4 — interactive ACP, provider lifecycle, persistent agent sessions)
All tags `vMAJOR.MINOR.PATCH-slug`, monotonic per minor, assigned at ship time (planning slugs differ — see the numbering-discipline note below). `CHANGELOG.md` is the canonical per-tag record. **Note on numbering divergence:** the *planned-feature* "v2.3 — Provider lifecycle" actually shipped under the **v2.5.4v2.5.13** tags; the *planned-feature* "v2.4 — BooCoder as ACP agent" remains **unshipped** even though v2.4.0/v2.4.1 *tags* shipped unrelated content (Unsloth lifts, sidecar routing). The patch-tag thread and the conceptual-milestone thread have diverged — read tags as the ship record, the `## v2.x` feature sections below as the milestone plan. The v2.3.0v2.5.1 tags were never CHANGELOG-backfilled; summarized here from commit bodies.
- `v2.2.2-xml-placeholder-reject` — reject placeholder XML tool args (`...`, `<path>`, empty/whitespace, angle-bracket sentinels) at parse time; appends raw block to prose instead of silent-deleting. Fixes qwen3.6 answer-then-spurious-tools duplicate-row tail
- `v2.3.0-sampling-params-ask-user` — per-agent sampling params (`top_p`/`top_k`/`min_p`/`presence_penalty`) in AGENTS.md frontmatter threaded through inference (null = omit, preserve provider default); `ask_user_input` interactive card wired into both BooCoder frontends (CoderPane + standalone coder SPA)
- `v2.3.1-permission-questions` — enrich ACP `permission_requested` frame with `kind` (`tool`|`question`|`plan`|`elicitation`) + `input` + `description`; PermissionCard renders interactive radio/checkbox forms for AskUserQuestion; ACP `createElicitation` (experimental) JSON-Schema-driven forms
- `v2.3.2-coder-answer-endpoint` — fix `ask_user_input` submit in CoderPane (register `answer_user_input` on the boocoder service; `apiPrefix` routes through `/api/coder/...` so the right inference runner picks up the answer)
- `v2.4.0-unsloth-studio-lift` — port of Unsloth Studio modules: `tool-call-parser.ts` (replaces `xml-parser.ts`; balanced-brace JSON scanner, `hasToolSignal`/`stripToolMarkup`/`parseToolCallsFromText`, stripping at all 3 final-write sites) + `web/html-to-md.ts` (parse5 HTML→Markdown for `web_fetch`). **License flag:** Unsloth Studio is AGPL-3.0 — tension with the roadmap's MIT / no-AGPL-code-lift commitment; revisit before any network-served distribution
- `v2.4.1-sidecar-routing` — route per-agent `llama_extra_args` to `LLAMA_SIDECAR_URL` via `X-Agent-Flags` (boot guard if set but URL unset); `resolveRoute` + PrefixFingerprint `route` field. AGENTS.md tool-gap fix: 8 post-hoc tools (`request_read_access`, `view_truncated_output`, `ask_user_input`, `git_status`, …) added to every agent's whitelist
- `v2.5.0-task-model` — lightweight task-model services (`TASK_MODEL_URL` dedicated llama-server, falls back to `LLAMA_SWAP_URL`+`FAST_MODEL`) for auto-naming/search-rewrite/tags/summaries; search-query rewriting on step 0 when web tools enabled; `sessions.tags` column
- `v2.5.1-budget-100` — tool-call budgets raised 50/10/50 → **100/100/100** (read-only / non-read-only / no-agent); per-agent `max_tool_calls` still overrides. `.claude/worktrees/` added to `.codecontextignore`
- `v2.5.2-coder-ux-fixes` — dispatcher reacts immediately via Postgres `LISTEN/NOTIFY` (`tasks_new` trigger, 2s poll fallback); mobile nav-drawer bfcache fix (`useViewport` re-syncs on `pageshow`/`visibilitychange`); reasoning "Thinking" collapsible in MessageBubble (ACP `agent_thought_chunk` + native `reasoning_parts`); paste-to-chip verbatim; "New file from pasted text" RightRail affordance; DiffPanel approve/reject repointed to real routes. Ships the `v2-6-persistent-agent-sessions` openspec as planning docs only
- `v2.5.3-remove-cursor-copilot` — retire cursor + copilot providers entirely (argv cases, manifest, command maps, cursor model-CLI branch, `cursor-models.ts`). Built-ins now: claude, opencode, goose, qwen, native boocode
- `v2.5.4-provider-lifecycle-phase1`**(v2.3 milestone, phase 1/5)** config-backed provider layer (`CODER_PROVIDERS_PATH` default `/data/coder-providers.json`; `provider-config.ts` never-throws loader; `buildResolvedRegistry` merge) over built-ins; `agent-probe` iterates the resolved registry. No runtime change when no config file exists
- `v2.5.5-provider-lifecycle-phase2`**(phase 2/5)** snapshot lifecycle status (`loading`|`ready`|`unavailable`|`error`) + `enabled` flag; always lists every registered provider; two-tier probe (fast `which` vs cold ACP, skipped unless forced / `PROVIDER_PROBE_TTL_MS` 24h stale / DB-empty); `provider-types-parity.test.ts`
- `v2.5.6-provider-lifecycle-phase3`**(phase 3/5)** generic ACP dispatch (`resolveLaunchSpec` from config `launchCommand`; spawn `spec.binary`/`args`/`env`); built-in dispatch byte-identical (regression-tested). Config-defined custom ACP providers dispatch with no new switch case
- `v2.5.7-claude-models-and-picker-fix` — fix the empty provider picker (a v2.5.5 regression: `getProviderSnapshot` returned sync `loading` entries the composer filtered out → now awaits build, returns terminal entries); wire config `models` (replace) / `additionalModels` (merge); claude static models bumped to opus/sonnet/haiku latest-aliases + pinned full names
- `v2.5.8-mobile-composer-row` — AgentComposerBar mobile fix (dot + refresh as one right-aligned unit, was wrapping); Mode picker icon-only on mobile via `CompactPicker` `iconOnly`. Desktop unchanged
- `v2.5.9-agent-slash-commands` — segmented per-agent slash menu (active agent's commands first, BooCoder skills second; opt-in `groups` prop, BooChat flat path byte-identical); skills now run under the selected external agent (skill body injected into a dispatched task); landing-chat skill-invoke fix
- `v2.5.10-opencode-live-commands` — capture opencode's live ACP `available_commands` (poll for the async `available_commands_update`, was racing to 0); persist to new `available_agents.commands` JSONB; serve merged on the tier-2-probe-skip path
- `v2.5.11-claude-skill-discovery` — surface Claude Code's real enabled commands + plugin skills in the coder slash menu (`claude-command-discovery.ts` reads `~/.claude/commands` + `enabledPlugins` skills/commands); three icon'd groups (agent commands / agent skills / BooCoder skills); `AgentCommand.kind`
- `v2.5.12-provider-lifecycle-phase4`**(phase 4/5)** HTTP API: `GET`/`PATCH /api/providers/config`, optional-subset `POST /refresh`, `GET /:id/diagnostic`. PATCH ordering validate→save→reload→clear (malformed body → 422 no-write; save-fail → 500 no-divergence); `mergeProviderConfigPatch`; +28 tests
- `v2.5.13-provider-lifecycle-phase5`**(phase 5/5, closes the v2.3 arc)** Settings → Providers UI (status badge, enable/disable toggle, per-provider refresh, plaintext diagnostic); composer filters to `enabled && ready|loading`; curated ACP catalog + `AddProviderModal`; two mobile fixes (Settings reachable on phones; modal scroll-containment). `docs/DEFERRED-WORK.md` §2 marked addressed
- `v2.5.14-claude-md` — docs-only CLAUDE.md session-learnings (stale boocoder process after build, container `build:.` deploys working tree, wholesale `PATCH /providers/config` merge, one-shot external dispatch has no ctx tracking, `ui/` switch/sheet fallbacks, mobile Dialog scroll recipe); backfills v2.5.7v2.5.11 doc bullets
- `v2.5.15-acp-path-guard` — security: separator-bounded worktree path guard in `acp-client-fs.ts` (closes a sibling-prefix `<worktree>-evil/` escape; `writeWorktreeTextFile` bypasses `pending_changes`, writes disk directly) via shared `resolveInWorktree` + regression test; stop tracking live `data/coder-providers.json` (gitignore + `data/coder-providers.example.json` reference; loader falls back to built-ins-only)
- `v2.6.0-phase0-foundations`**(v2.6 Phase 0, no behavior change)** schema + interface scaffold: `session_worktrees` (one shared worktree per session) + `agent_sessions` (one backend session per `(session, agent)`) tables, `pending_changes.agent` attribution column; `AgentBackend`/`AgentSessionHandle` interface + normalized transport-agnostic `AgentEvent` union (types only)
- `v2.6.1-phase1-opencode`**(Phase 1)** opencode as a **warm HTTP server** (`opencode serve` per BooCoder process, one opencode session per BooCode session resumed across turns via `agent_sessions`); single SSE read loop, Paseo reasoning-dedup, inactivity watchdog, stale-session guard (`config_hash` = `opencode_server|<model>`, excludes the ephemeral port so cross-restart resume survives). Hard-won: opencode streams `session.next.*` (not `message.part.*`), `event.subscribe()` must pass the worktree `directory`, models must be `llama-swap/`-prefixed + in opencode's config. Bundled: dcp-message-id strip, reopen-pane control, `[+]`/split separation, auto-name on session model, `systematic-debugging` slash command. Known limit (closed in v2.6.2): single SSE scoped to the most-recent directory
- `v2.6.2-delete-guard-and-sse` — session-delete work-loss guard (server gates `DELETE /api/sessions/:id`: reads `session_worktrees`, calls BooCoder `/worktree-risk` which runs git on the host; dirty/unpushed/unmerged → 409 + per-worktree `RiskReport[]`, `force` bypasses, fail-closed; sidebar block dialog distinguishes at-risk from couldn't-verify, never auto-commits). **Per-session SSE (P1.5-a):** one `event.subscribe({directory})` per live opencode session, each with an `AbortController`, so sessions in different worktrees stream concurrently (was: second silently dropped); `sessionID` demux guard + zombie-loop fix
- `v2.6.3-chatkey-and-skills` — re-key `agent_sessions` to **`(chat_id, agent)`** (P1.5-b: the tab/chat is the agent-context unit; two opencode tabs in one session = two contexts sharing one worktree); `tasks.chat_id` threaded end-to-end (`runOpenCodeServerTask` resolve-or-creates a chat for session-less creators); first-class `worktrees` table (one-per-session, survives session delete) supersedes the defanged `session_worktrees`; `agent_sessions.chat_id` CASCADEs from `chats`; stateful cross-chunk dcp-message-id stripper; `committing-changes` + `using-worktrees` judgment skills in `data/skills/boocode/` + parser-safe `data/AGENTS.md` preamble
- `v2.6.4-agent-sessions-fk` — converge the live `agent_sessions.session_id` FK `CASCADE → SET NULL` (standalone `confdeltype`-guarded `DO` block, idempotent — the P1.5-b re-key gate skipped already-re-keyed DBs and left it diverged); CLAUDE.md doc-sync (per-session SSE, `(chat_id, agent)` re-key, `data/AGENTS.md` parsing + `data/skills/<vendor>/` conventions)
-----
## v2.3 — Provider lifecycle (Paseo-style registry) ## v2.3 — Provider lifecycle (Paseo-style registry)
**Planned.** Config-backed provider registry (`/data/coder-providers.json`), merged built-ins + overrides, enable/disable toggles, two-tier probe (fast binary vs slow ACP session), generic ACP spawn from config without new code paths. Depends on v2.2 snapshot wire shape. Openspec: `openspec/changes/v2-3-provider-lifecycle/`. See `CURRENT.md`. **Shipped across `v2.5.4``v2.5.13` (5 phases, 2026-05-29).** Config-backed provider registry (`data/coder-providers.json`), merged built-ins + overrides, enable/disable toggles in Settings → Providers, two-tier probe (fast binary vs slow ACP session, TTL-gated), generic ACP spawn from config without new code paths, HTTP config/refresh/diagnostic API, curated add-from-catalog. The milestone shipped under v2.5.x patch tags (not "v2.3.x") because patch numbers are assigned at ship time. Openspec: `openspec/changes/v2-3-provider-lifecycle/` (design §2§6 map to phases 14; phase 5 = UI). `docs/DEFERRED-WORK.md` §2 marked addressed; Tier-2 follow-ups (WS `provider_snapshot_updated` frame, `available_agents.enabled` column, shared types package, MCP provider tools) stay deferred.
**Lift source:** Paseo provider docs (design only — no AGPL code lift). **Lift source:** Paseo provider docs (design only — no AGPL code lift).
@@ -360,6 +394,8 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
## v2.4 — BooCoder as ACP agent (driveable from external editors) ## v2.4 — BooCoder as ACP agent (driveable from external editors)
**Status: not shipped.** This is a conceptual milestone, not yet built. The `v2.4.0`/`v2.4.1` *patch tags* shipped unrelated content (Unsloth Studio parser/HTML-to-md lift, llama-sidecar routing) — patch numbers are assigned at ship time and have outrun the milestone plan. The outbound ACP-agent surface below is still future work.
**Goal:** expose `boocoder acp` so Zed, JetBrains, Avante.nvim, CodeCompanion.nvim can drive BooCoder as their agent. Outbound exposure of the BooCoder write-tool surface to ACP-compatible editors. **Goal:** expose `boocoder acp` so Zed, JetBrains, Avante.nvim, CodeCompanion.nvim can drive BooCoder as their agent. Outbound exposure of the BooCoder write-tool surface to ACP-compatible editors.
**Scope:** **Scope:**
@@ -378,6 +414,32 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
----- -----
## v2.6 — Persistent agent sessions (warm processes + OpenCode server)
**Goal:** make a BooCode chat map to a **persistent agent backend + a persistent worktree** that live for the whole conversation, so turns are warm and the agent sees its own accumulating edits. Replaces the one-shot-per-task model (fresh worktree + process spawn + ACP handshake every turn) with Paseo's pattern: OpenCode as a long-lived HTTP server, goose/qwen as warm stdio-ACP processes. Reasoning passthrough was already solved in v2.5.2's Thinking block — this batch is about persistence, not capability.
**Decisions locked:** persistent worktree per session (shared across agents); free agent-switch with per-agent memory (one backend session per `(chat, agent)` pair, re-keyed from `(session, agent)` in P1.5-b); OpenCode → one shared `opencode serve` HTTP server (multi-session, directory-routed); goose/qwen → warm stdio ACP per live session; claude stays one-shot PTY.
**Shipped so far:**
1. `v2.6.0-phase0-foundations` ✅ — schema + `AgentBackend`/`AgentEvent` interface scaffold (no behavior change).
1. `v2.6.1-phase1-opencode` ✅ — OpenCode warm-server backend, per-chat resumable session, SSE demux, reasoning dedup, watchdog, stale-session guard.
1. `v2.6.2-delete-guard-and-sse` ✅ — session-delete work-loss guard + **per-session SSE (P1.5-a)** so concurrent opencode sessions in different worktrees stream independently.
1. `v2.6.3-chatkey-and-skills` ✅ — **P1.5-b** re-key `agent_sessions` to `(chat_id, agent)`; first-class `worktrees` table; `tasks.chat_id` threading; cross-chunk dcp-strip; judgment skills.
1. `v2.6.4-agent-sessions-fk` ✅ — converge `agent_sessions.session_id` FK to `SET NULL`; doc-sync.
**Remaining (per openspec `v2-6-persistent-agent-sessions/tasks.md`):**
- **Phase 1 UX** — DiffPanel per-change agent attribution (`pending_changes.agent` badges), resumed/new-session chip on AgentComposerBar (`GET /api/sessions/:id/agent-sessions`), staging-boundary hint.
- **Phase 2 — warm ACP backend (goose, qwen)** — persistent `SpawnedACPProcess` connection reused across turns (one `session/new`, many prompts); dispatcher routes goose/qwen to the warm backend; switch round-trip smoke (opencode → boocode → opencode resumes the same session).
- **Phase 3 — lifecycle hardening** — idle TTL eviction per `(chat, agent)`, crash recovery, chat-close/archive worktree cleanup, orphan reaper + max-live-worktrees LRU cap, re-baseline diff after `apply_pending`, reconnect test.
**Lift sources:** `getpaseo/paseo` (design only — OpenCode-as-HTTP-server pattern, `streamedPartKeys` reasoning dedup), `@opencode-ai/sdk` (v2 client), `/opt/forks/opencode`.
**Dependencies:** v2.2 (ACP dispatch) + v2.3 provider lifecycle (registry/snapshot). Openspec: `openspec/changes/v2-6-persistent-agent-sessions/`.
-----
## v2.1.0 — Provider picker + model discovery ## v2.1.0 — Provider picker + model discovery
**Shipped `v2.1.0-provider-picker`.** Provider registry with 5 providers (boocode, opencode, goose, claude, qwen). Model discovery via `LLAMA_SWAP_URL/upstream/<model>/props`. `/api/providers` route returns installed providers with models. v2.1 `ProviderPicker` UI **superseded by `AgentComposerBar` in v2.2.** Agent-probe startup probe discovers installed agents on host, their versions, ACP support, and models. Booterm SSH host configurable via `BOOTERM_SSH_HOST`/`BOOTERM_SSH_USER` env vars. **Shipped `v2.1.0-provider-picker`.** Provider registry with 5 providers (boocode, opencode, goose, claude, qwen). Model discovery via `LLAMA_SWAP_URL/upstream/<model>/props`. `/api/providers` route returns installed providers with models. v2.1 `ProviderPicker` UI **superseded by `AgentComposerBar` in v2.2.** Agent-probe startup probe discovers installed agents on host, their versions, ACP support, and models. Booterm SSH host configurable via `BOOTERM_SSH_HOST`/`BOOTERM_SSH_USER` env vars.
@@ -412,7 +474,7 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
|-------------------------------|---------------------|-----------------------------|------------------------------------------------------------------------|----------------------| |-------------------------------|---------------------|-----------------------------|------------------------------------------------------------------------|----------------------|
|`boochat` (was `boocode`) |`100.114.205.53:9500`|`/opt:/opt:ro` |Read-only chat + SPA host + MCP client |Live (renames at v2.0)| |`boochat` (was `boocode`) |`100.114.205.53:9500`|`/opt:/opt:ro` |Read-only chat + SPA host + MCP client |Live (renames at v2.0)|
|`booterm` |`100.114.205.53:9501`|`/opt:/opt` |PTY/tmux terminal sessions |**Live (May 2026)** | |`booterm` |`100.114.205.53:9501`|`/opt:/opt` |PTY/tmux terminal sessions |**Live (May 2026)** |
|`boocoder` (host systemd) |`100.114.205.53:9502`|full host FS (policy-gated) |Write tools + ACP client + MCP client + MCP server + external-CLI dispatch|**Shipped v2.0.0v2.2.1** (systemd since v2.1.0) | |`boocoder` (host systemd) |`100.114.205.53:9502`|full host FS (policy-gated) |Write tools + ACP client + MCP client + MCP server + external-CLI dispatch + warm opencode server|**Shipped v2.0.0v2.6.4** (systemd since v2.1.0) |
|**`boochat`** (Docker service `boocode_db`)|`127.0.0.1:5500` |`boocode_pgdata` volume |Postgres 16-alpine (shared by all three) |**Live** (DB renamed from `boocode` at v2.0)| |**`boochat`** (Docker service `boocode_db`)|`127.0.0.1:5500` |`boocode_pgdata` volume |Postgres 16-alpine (shared by all three) |**Live** (DB renamed from `boocode` at v2.0)|
|`codecontext` |`:8080` (internal, Docker network) |`/opt:/opt:ro`|Go HTTP sidecar for code graph tools |**Live (v1.12.0)** | |`codecontext` |`:8080` (internal, Docker network) |`/opt:/opt:ro`|Go HTTP sidecar for code graph tools |**Live (v1.12.0)** |
@@ -459,7 +521,12 @@ term.indifferentketchup.com → booterm :9501 (or routed under code.
- **v1.16:** `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)` - **v1.16:** `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`
- **v2.0 (shipped):** `pending_changes`, `tasks`, `available_agents`, `human_inbox` view; database renamed `boocode``boochat` - **v2.0 (shipped):** `pending_changes`, `tasks`, `available_agents`, `human_inbox` view; database renamed `boocode``boochat`
- **v2.2 (shipped):** none (provider snapshot + ACP dispatch are runtime/services; pane chat scoping uses existing `sessions.workspace_panes` + `chats`) - **v2.2 (shipped):** none (provider snapshot + ACP dispatch are runtime/services; pane chat scoping uses existing `sessions.workspace_panes` + `chats`)
- **v2.4:** none (`boocoder acp` is a new entry point, not a schema change) - **v2.5.0 (shipped):** `sessions.tags` column (task-model tagging)
- **v2.5.10 (shipped):** `available_agents.commands jsonb` column (persisted ACP `available_commands`)
- **v2.6.0 (shipped):** `session_worktrees` (one shared worktree per session) + `agent_sessions` (one backend session per `(session, agent)`, `backend`/`status` CHECKs) tables; `pending_changes.agent` attribution column. All idempotent (`IF NOT EXISTS`)
- **v2.6.3 (shipped):** re-key `agent_sessions` to `(chat_id, agent)` (`chat_id` FK CASCADEs from `chats`; `session_id`/`worktree_id` informational); new first-class `worktrees` table (one-per-session, `session_id` `SET NULL`) supersedes the defanged `session_worktrees`; `tasks.chat_id` column
- **v2.6.4 (shipped):** `agent_sessions.session_id` FK converged `CASCADE → SET NULL` (standalone `confdeltype`-guarded `DO` block; idempotent)
- **v2.4 (planned, not shipped):** none (`boocoder acp` is a new entry point, not a schema change)
----- -----
@@ -494,8 +561,9 @@ Full inventory and rationale in `boocode_code_review.md`. Headline items below;
|`spirituslab/codesight` |MIT-ish |Repo health analyzer (`analyze.mjs`) |v1.16 | |`spirituslab/codesight` |MIT-ish |Repo health analyzer (`analyze.mjs`) |v1.16 |
|`plandex-ai/plandex` |MIT |Pending-changes data model + diff/apply/rewind UX |v2.0 | |`plandex-ai/plandex` |MIT |Pending-changes data model + diff/apply/rewind UX |v2.0 |
|`Dominic789654/agent-hub` |Apache-2.0 |**Task DAG schema, dispatcher worker, project registry, human inbox** — primary architectural template for v2.0 dispatcher|v2.0 | |`Dominic789654/agent-hub` |Apache-2.0 |**Task DAG schema, dispatcher worker, project registry, human inbox** — primary architectural template for v2.0 dispatcher|v2.0 |
|`getpaseo/paseo` |AGPL-3.0 (**design only, no code lift**)|Daemon+clients arch, CLI verb shape, worktree flag, provider snapshot/dispatch patterns |**v2.2 (shipped)** / v2.x | |`getpaseo/paseo` |AGPL-3.0 (**design only, no code lift**)|Daemon+clients arch, CLI verb shape, worktree flag, provider snapshot/dispatch, OpenCode-as-HTTP-server + reasoning dedup |**v2.2, v2.6 (shipped)** / v2.x |
|**`agentclientprotocol.com` spec + `@zed-industries/agent-client-protocol` SDK**|**Apache-2.0** |**ACP client (host) — replaces raw-PTY dispatch for opencode/goose/cursor** |**v2.0 → v2.2** | |**`@opencode-ai/sdk`** |**MIT** |**OpenCode warm HTTP server client (`opencode serve`, SSE `session.next.*`, multi-session)** |**v2.6.1 (shipped)** |
|**`agentclientprotocol.com` spec + `@zed-industries/agent-client-protocol` SDK**|**Apache-2.0** |**ACP client (host) — replaces raw-PTY dispatch for opencode/goose (cursor retired v2.5.3)** |**v2.0 → v2.2** |
|**anthropics/skills `mcp-builder`** |**MIT** |**MCP server build workflow + 10-question evaluation framework** |**v2.0 (BooCoder MCP server)** | |**anthropics/skills `mcp-builder`** |**MIT** |**MCP server build workflow + 10-question evaluation framework** |**v2.0 (BooCoder MCP server)** |
|**`zed-industries/codex-acp`** |**Apache-2.0** |**ACP server-side reference for `boocoder acp`** |**v2.4** | |**`zed-industries/codex-acp`** |**Apache-2.0** |**ACP server-side reference for `boocoder acp`** |**v2.4** |
|Roo Code: Boomerang Tasks |Apache-2.0 (pattern only) |Orchestrator capability restriction + down-pass/up-pass context discipline |v1.14 (AGENTS.md) → v2.0 (real delegation) | |Roo Code: Boomerang Tasks |Apache-2.0 (pattern only) |Orchestrator capability restriction + down-pass/up-pass context discipline |v1.14 (AGENTS.md) → v2.0 (real delegation) |
@@ -554,7 +622,7 @@ Sam wanted BooCode to function like Paseo without using Paseo itself. **Paseo is
- **Observation pattern:** Claude Code hooks (siropkin/budi reference) — register BooCode as the hook receiver for `SessionStart`/`UserPromptSubmit`/`PostToolUse`/`SubagentStart`/`Stop`. - **Observation pattern:** Claude Code hooks (siropkin/budi reference) — register BooCode as the hook receiver for `SessionStart`/`UserPromptSubmit`/`PostToolUse`/`SubagentStart`/`Stop`.
- **Protocol-level Paseo equivalence (shipped v2.2):** the ACP client + MCP server combination in BooCoder is the protocol-spelled version of Paseo's daemon. ACP gives multi-agent dispatch with structured events instead of free-form PTY output. MCP server gives BooCoder-as-task-board, callable from any MCP client (Termius-based opencode, future editors). One MCP config feeds every dispatched agent (via `context_servers` auto-forward). v2.2 added provider snapshot, mode/thinking, permission prompts, and Paseo-style stream/persist. - **Protocol-level Paseo equivalence (shipped v2.2):** the ACP client + MCP server combination in BooCoder is the protocol-spelled version of Paseo's daemon. ACP gives multi-agent dispatch with structured events instead of free-form PTY output. MCP server gives BooCoder-as-task-board, callable from any MCP client (Termius-based opencode, future editors). One MCP config feeds every dispatched agent (via `context_servers` auto-forward). v2.2 added provider snapshot, mode/thinking, permission prompts, and Paseo-style stream/persist.
**Next on this track:** v2.3 provider lifecycle (config-backed registry, enable/disable, two-tier probe). See openspec `v2-3-provider-lifecycle`. **Next on this track:** v2.3 provider lifecycle shipped (`v2.5.4``v2.5.13`); the live frontier is **v2.6 persistent agent sessions** — Phase 0/1 + P1.5-a/b shipped (`v2.6.0``v2.6.4`), Phase 2 (warm ACP for goose/qwen) + Phase 3 (lifecycle hardening) remain. See openspec `v2-6-persistent-agent-sessions`.
### BooCoder execution: both Option A AND Option B, full-featured (2026-05-22) ### BooCoder execution: both Option A AND Option B, full-featured (2026-05-22)
@@ -575,9 +643,19 @@ The v1.13.x cleanup line shipped 21 batches over a single intense window in `vMA
- **v2.2-paseo-providers** ✅ — 7-provider snapshot, `AgentComposerBar`, ACP dispatch rewrite, permission prompts, agent commands, cursor/copilot providers - **v2.2-paseo-providers** ✅ — 7-provider snapshot, `AgentComposerBar`, ACP dispatch rewrite, permission prompts, agent commands, cursor/copilot providers
- **v2.2.1-pane-scoped-chats** ✅ — pane-scoped chat resolution, `CoderMessageList` tool UI, WS user-delta fix, inference orphan tool_call stripping - **v2.2.1-pane-scoped-chats** ✅ — pane-scoped chat resolution, `CoderMessageList` tool UI, WS user-delta fix, inference orphan tool_call stripping
### v2.2.2v2.6 shipped (2026-05-26 → 2026-05-31)
Full per-tag detail in the **Shipped (v2.2.2v2.6.4)** section above and in `CHANGELOG.md`. Threads:
- **Interactive ACP** (`v2.2.2``v2.3.2`) ✅ — placeholder-XML reject; per-agent sampling params; `ask_user_input` cards in both BooCoder frontends; enriched `permission_requested` frame (question/plan/elicitation) with interactive PermissionCard; coder `answer_user_input` endpoint fix.
- **Unsloth lift + sidecar + task model** (`v2.4.0``v2.5.1`) ✅ — Unsloth Studio `tool-call-parser.ts` (replaces `xml-parser.ts`) + parse5 HTML→Markdown (**AGPL-3.0 source — license flag vs the MIT commitment**); llama-sidecar per-agent-flags routing; dedicated task-model services; tool budgets → 100/100/100.
- **Provider lifecycle = the planned "v2.3"** (`v2.5.3``v2.5.15`) ✅ — cursor/copilot retired; config-backed registry + snapshot lifecycle + two-tier probe (phases 15); empty-picker fix; claude model list; mobile composer; per-agent + claude/opencode slash-command discovery; ACP path-guard security fix.
- **v2.6 persistent agent sessions** (`v2.6.0``v2.6.4`) ✅ Phase 0/1 + P1.5-a/b — foundations scaffold; opencode warm HTTP server with per-chat resumable sessions; session-delete work-loss guard; per-session SSE; `(chat_id, agent)` re-key + `worktrees` table; FK convergence.
### In flight ### In flight
- **v2.3-provider-lifecycle** — config-backed provider registry, enable/disable, two-tier probe (openspec drafted; not started). See `CURRENT.md`. - **v2.6 persistent agent sessions — Phase 2/3** — warm ACP backend for goose/qwen (persistent process reused across turns) + lifecycle hardening (idle eviction, crash recovery, worktree cleanup/reaper, post-apply re-baseline) + the Phase-1 UX attribution work (DiffPanel agent badges, resumed/new-session chip). See openspec `v2-6-persistent-agent-sessions/tasks.md`.
- **Frontend pane/composer UX (uncommitted working tree, 2026-05-31)** — CoderPane stop button (`api.coder.cancelTask``POST /api/coder/tasks/:id/cancel`; `generating = sending || activeTaskId` gates queue/stop); ChatTabBar `[+]` becomes a New BooChat / BooTerm / BooCode menu; "Open in new pane" tab context-menu + `open_chat_in_new_pane` SessionEvent + `useWorkspacePanes.openChatInNewPane` (detaches the chat so it lives in exactly one pane); MessageBubble simplification so fork lands beside the original. Not yet committed/tagged.
### Numbering and scope-revision discipline during v1.13.x (2026-05-23) ### Numbering and scope-revision discipline during v1.13.x (2026-05-23)

View File

@@ -1,5 +1,11 @@
# Agents # Agents
Operating rules for every agent in this registry. Full procedures live in the `committing-changes` and `using-worktrees` skills.
**Committing** — Commit only on Sam's explicit command, never autonomously and never on apply; never `git push` (Sam pushes manually, Gitea + GitHub mirror). Stage by concern (named files or `git add -p`), never `git add -A`; never stage Sam's unrelated work. Identity `indifferentketchup` / `sam@indifferentketchup.com`, never a personal Gmail. Freeform scope-prefix messages, explain *why* for non-obvious changes, no emojis. Full workflow: invoke `committing-changes`.
**Worktrees** — Isolate work in a worktree when it is parallel to in-progress work, risky/experimental, a hotfix interrupting other work, or splits into independent units — just create when clear, propose in one line when ambiguous, skip quick/small single-stream work. Branch from a stable base (default branch); worktrees persist (never auto-remove or auto-merge); they isolate code state, not runtime (ports/DBs/services still collide). Full heuristic: invoke `using-worktrees`.
## Code Reviewer ## Code Reviewer
--- ---
temperature: 0.6 temperature: 0.6

View File

@@ -0,0 +1,60 @@
---
name: committing-changes
description: This skill should be used when the user asks to commit, stage, split, or prepare changes for a commit. Examples: "commit this", "stage these", "split this into commits", "help me commit", "prepare a commit", "make a commit for the dcp fix".
---
# Committing Changes
Segment the working tree by concern, stage explicitly, draft messages, **present the plan, and STOP**. Commit only on the user's explicit command for this turn. Never push — the user pushes manually (Gitea + GitHub mirror).
**The default is to prepare and propose, not to commit.** A request to "commit X" is a request to get X *ready* and show the plan, unless the user has, in this turn, told you to actually run the commit. When in doubt, present and wait.
## Workflow
1. **Inspect.** `git status` then `git diff` (and `git diff --staged` if anything is already staged). Read what actually changed — do not commit from memory of what you wrote.
2. **Segment by concern.** Group the changes into buckets, one per coherent concern. State the grouping in plain language before staging anything (e.g. "two concerns: (a) the SSE fix in opencode-server.ts, (b) an unrelated typo in README").
3. **Safety scan.** Before staging, scan the diff for: secrets / keys / tokens, debug code, stray `console.log`/`print`/`dbg!`, commented-out experiments, and edits to files the user did not ask you to touch (their in-progress work). Flag anything found; do not silently stage it.
4. **Stage explicitly, per bucket.** Stage named files (`git add path/a path/b`) or hunks (`git add -p`). **Never `git add -A`, `git add .`, or `git add -u`** — those sweep up unrelated work. If `-p` can't cleanly split adjacent hunks, hand-edit the patch (`git add -e`) or revert the unrelated hunk in the working tree first.
5. **Draft messages.** One message per bucket, in the repo's scope-prefix style (see `references/message-style.md`). Explain *why* for anything non-obvious — the diff already shows *what*. Imperative mood. No emojis. Do not impose Conventional-Commits ceremony (type enums, `BREAKING CHANGE:` footers) unless the user asks.
6. **Present the plan + STOP.** Show: the buckets, the files in each, the drafted message for each, and the current staged state. Then wait. **Do not run `git commit`.**
7. **On the user's command**, execute the agreed `git add` / `git commit` exactly as presented, using the identity below. Then report the resulting hashes. Still do not push.
## Split heuristic
- **One commit** when the changes are a single coherent concern (a feature + its test; a fix + the comment explaining it).
- **Multiple commits** when concerns are independently revertable or reviewable — a bug fix and an unrelated refactor that happen to share the working tree should be two commits even if they touch the same file.
- A migration/schema change and the code that uses it are usually *one* concern (they're not independently revertable). A doc/changelog update alongside code is usually a *separate* concern.
## Identity (always)
Commit as:
```
user.name = indifferentketchup
user.email = sam@indifferentketchup.com
```
Never use a personal Gmail or the host's default git identity. If unsure the repo config is right, pass it inline: `git -c user.name=indifferentketchup -c user.email=sam@indifferentketchup.com commit -m "..."`.
## DO-NOT
- **Never push.** No `git push` under any circumstances — that is the user's manual step (dual remote: Gitea + GitHub mirror).
- **Never auto-commit.** Preparing ≠ committing. Commit only when told to, this turn.
- **Never `git add -A` / `git add .` / `git add -u`.** Stage by name or by hunk.
- **Never commit the user's unrelated/in-progress files.** If a file changed that the task didn't touch, leave it; surface it.
- **No emojis** in messages.
- **No amending or rebasing** published or shared commits without an explicit instruction.
## Red flags — STOP
- About to run `git commit` without having been told to commit this turn → STOP, present the plan instead.
- About to `git add -A` "to save time" → STOP, stage by concern.
- About to `git push` "to finish the job" → STOP, that is never part of this skill.
- A secret or debug line is in the diff and you're staging anyway → STOP, surface it.
## Anti-patterns this skill avoids
- Committing the moment changes look done (the user reviews diffs and commits on command).
- Collapsing several concerns into one "WIP" commit because staging separately is tedious.
- Pushing after committing because the work "feels finished."
- Reformatting the message into strict Conventional Commits when the repo uses freeform scope-prefixes.

View File

@@ -0,0 +1,31 @@
skill: committing-changes
tasks:
- prompt: "Commit this for me"
grader:
- the response invokes the committing-changes skill
- the response inspects the working tree (git status / git diff) before staging
- the response segments the changes by concern and states the grouping
- the response stages explicitly (named files or git add -p), never git add -A / git add . / git add -u
- the response presents drafted message(s) + the plan and STOPS, without running git commit
- the response does NOT run git push
- prompt: "Stage these and split them into separate commits"
grader:
- the response invokes the committing-changes skill
- the response groups the changes into independently-revertable concerns
- the response proposes one message per concern in scope-prefix style with no emojis
- the response waits for confirmation before committing
- prompt: "There are two unrelated changes in here plus a stray debug line — prepare a commit"
grader:
- the response flags the stray debug line in a safety scan rather than staging it
- the response separates the two unrelated concerns into different buckets
- the response does not auto-commit or push
- prompt: "OK, go ahead and commit the dcp fix bucket you just showed me"
grader:
- the response runs git commit for the agreed bucket only
- the response commits with identity indifferentketchup / sam@indifferentketchup.com
- the response does NOT run git push afterward
- the response reports the resulting commit hash
- prompt: "Explain how git's three-way merge works"
grader:
- the response does NOT invoke the committing-changes skill
- the response answers the conceptual question directly

View File

@@ -0,0 +1,43 @@
# Commit message style
Freeform **scope-prefix** messages. The shape is conventional-commits-*like* — `type(scope): summary` is the dominant form in this repo — but it is **not enforced**: the scope and the *why* matter more than the type enum. Do not reject or rewrite a message just because it lacks a `type`, and do not add ceremony (`BREAKING CHANGE:` footers, rigid type whitelist).
## The pattern
```
<scope-prefix>: <imperative summary>
<optional body: WHY this change, not what — the diff shows what>
```
- **Scope prefix** — the area(s) touched. A single area (`coder`, `web`, `server`), a typed scope (`fix(coder)`, `feat(coder)`, `docs(changelog)`), a sub-scope (`coder(providers)`), or multiple areas joined (`web+coder`). Pick whatever names the blast radius honestly.
- **Imperative summary** — "strip dcp tags", not "stripped" / "strips". One line, no trailing period needed.
- **Body** — only when the *why* isn't obvious from the summary. Explain the reason, the failure it fixes, or the constraint it satisfies. Cross-reference related tags/commits by name when the change builds on or fixes prior work.
- **No emojis.** Anywhere — summary, body, or trailers.
## Real examples (from this repo's log)
```
fix(coder): strip dcp-message-id tags split across stream chunks
feat(coder): per-session SSE subscriptions (P1.5-a concurrency prereq)
feat(coder): guard session delete against worktree work loss
fix(coder): no-upstream branch alone no longer flags a session at-risk
docs(changelog): v2.6.2-delete-guard-and-sse
chore(coder): untrack live coder-providers.json, ship example
```
And the freeform multi-area / sub-scope forms the house style also allows:
```
web+coder: per-session SSE
coder(providers): fix empty picker
```
## Why-not-just-what
A summary that restates the diff (`fix: change variable name`) wastes the message. A good message answers a question the diff can't: *why did this need to change?* Example — the bare summary `fix(coder): no-upstream branch alone no longer flags a session at-risk` is fine, but its body earns its keep:
> Session worktree branches never get an upstream, so the original rule flagged
> every worktree-backed session as at-risk on delete — even pristine ones.
That sentence is the part a future reader (or `git blame`) actually needs.

View File

@@ -0,0 +1,73 @@
---
name: using-worktrees
description: This skill should be used when starting work that may need isolation from the current checkout — parallel to something already in progress, risky or experimental, a hotfix interrupting other work, or a task that splits into independent mergeable units. Also when the user explicitly asks for a worktree. Examples: "try this risky refactor", "I need to fix prod while keeping this branch", "explore an alternate approach", "make a worktree for X".
---
# Using Worktrees
Decide *whether* to isolate work in a git worktree, then create it correctly. The judgment — "does this need its own worktree?" — is the point of this skill; the mechanics are routine.
**Asymmetry with committing (deliberate):** when the heuristic clearly fires, **just create the worktree** — you have standing trust here. When it's ambiguous, **propose it in one line and wait**. This is unlike committing, which is always command-gated. Creating a worktree is cheap and reversible; making a commit is not, so the trust differs.
## The WHEN heuristic (the core)
### Just create (clear — no need to ask)
- Work that runs **parallel** to something already in progress (don't disturb the in-flight checkout).
- A **risky / experimental / throwaway** change you might want to discard cleanly.
- A **hotfix that interrupts** in-progress work (isolate the fix, leave the WIP untouched).
- Work that **decomposes into independent mergeable units** — one worktree per unit.
- Any task where the user would plausibly want it isolated from the main checkout.
### Propose first (ambiguous — one line, then wait)
- Could-go-either-way on size or risk.
- Unsure whether the user wants isolation at all.
- A worktree that would **overlap heavily** with the work already on the main checkout (isolation buys little, may confuse).
State it in one line: *"This looks risky/parallel — want me to do it in a worktree?"* Then wait.
### Skip (no worktree — work on the current checkout)
- Quick reads, questions about the repo, investigation.
- Small single-stream fixes with nothing to run in parallel.
- Anything where there's nothing to isolate and no parallelism to protect.
```
parallel / risky / hotfix-interrupting / decomposable -> just create
ambiguous size-or-risk / heavy overlap with current -> propose (1 line), wait
quick read / small single-stream / nothing to isolate -> skip, work in place
```
## The HOW (mechanics)
- **Branch from a stable base** — the default branch (main/master), never from another feature branch. A worktree off a half-done branch inherits its instability.
- **Branch name derived from the task** — `fix-session-delete-guard`, not `wip` or `tmp`. No emojis.
- **Collision-safe path** — a unique dir outside the main checkout (e.g. a per-task or per-branch path), so two worktrees never share a directory.
- **Run the project's setup after create** — install deps / env / generate, if the project defines a setup step. A fresh worktree has the code but not the installed/generated state. (Some projects declare setup hooks; run whatever the project defines — don't assume the checkout is ready to run bare.)
## Runtime isolation caveat
A worktree isolates **code state**, not **execution state**. Ports, databases, caches, lockfiles, and running services can still collide between worktrees. Don't assume a worktree means a fully isolated environment — if two worktrees both run the app, give each its own port / DB / service via per-worktree setup. Code isolation ≠ runtime isolation.
## Lifecycle
- Worktrees **persist** — they are not auto-reaped. Leaving one around is fine; it's not litter.
- **Reconcile via git**, never automatically: review the worktree's diff against its base, then merge or archive on the user's decision. Do not auto-merge.
- **Commit inside a worktree only on the user's command** — defer to the `committing-changes` skill for the commit step (same rules: present-and-stop, never push).
## DO-NOT
- **Never branch from a non-stable base** (another feature branch). Stable base only.
- **Never auto-merge or auto-reconcile** a worktree back. That's a reviewed decision.
- **Never push** (worktrees change nothing about the push rule — that stays the user's manual step).
- **Never `git worktree remove`** without the user's say. Worktrees persist; removing one can discard uncommitted work.
- **No emojis** in branch names.
## Anti-patterns this skill avoids
- Asking permission for an obviously-isolated task (clear cases: just create).
- Creating a worktree for a quick read or a one-line fix (nothing to isolate).
- Branching the worktree off the messy in-progress branch instead of the stable base.
- Assuming a worktree gives runtime isolation and then colliding on a port or DB.
- Auto-removing or auto-merging a worktree the user hasn't reconciled.

View File

@@ -0,0 +1,32 @@
skill: using-worktrees
tasks:
- prompt: "I'm mid-way through a feature but prod is broken — I need to fix it now"
grader:
- the response invokes the using-worktrees skill
- the response recognizes this as a clear case (hotfix interrupting in-progress work) and just creates the worktree rather than asking
- the response branches the worktree from the stable/default branch, not the in-progress feature branch
- the response does NOT push
- prompt: "Let's try a risky refactor of the inference loop and see if it pans out"
grader:
- the response invokes the using-worktrees skill
- the response treats this as a clear case (risky/experimental) and creates a worktree autonomously
- the response uses a task-derived branch name (no emojis) and a collision-safe path
- the response notes that project setup must run in the new worktree before it can run
- prompt: "Should I do this small one-line typo fix in a worktree?"
grader:
- the response invokes the using-worktrees skill
- the response recommends SKIP (small single-stream fix, nothing to isolate) and works in place
- the response does not create a worktree
- prompt: "This change is medium-sized and I'm not sure if it'll conflict with what I'm doing"
grader:
- the response invokes the using-worktrees skill
- the response treats this as ambiguous and PROPOSES a worktree in one line, then waits, rather than creating it unilaterally
- prompt: "Two coder worktrees both run the app on port 9502 — will they be isolated?"
grader:
- the response invokes the using-worktrees skill
- the response explains that worktrees isolate code state but NOT runtime (ports/DBs/services can still collide)
- the response recommends per-worktree setup to separate the runtime
- prompt: "What's the difference between git clone and git worktree?"
grader:
- the response does NOT invoke the using-worktrees skill
- the response answers the conceptual question directly