Compare commits

...

12 Commits

Author SHA1 Message Date
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
38 changed files with 1445 additions and 255 deletions

View File

@@ -2,6 +2,18 @@
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.

View File

@@ -2,7 +2,7 @@
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
@@ -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.
- `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 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).
- **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/`)
@@ -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).
- 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.
- `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).
- **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`.

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): vo
'/api/sessions/:sessionId/worktree-risk',
async (req) => {
const rows = await sql<{ worktree_path: string }[]>`
SELECT worktree_path FROM session_worktrees WHERE session_id = ${req.params.sessionId}
SELECT path AS worktree_path FROM worktrees WHERE session_id = ${req.params.sessionId}
`;
const reports = [];
for (const row of rows) {
@@ -33,7 +33,7 @@ export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): vo
'/api/sessions/:sessionId/worktree-stash',
async (req) => {
const rows = await sql<{ worktree_path: string }[]>`
SELECT worktree_path FROM session_worktrees WHERE session_id = ${req.params.sessionId}
SELECT path AS worktree_path FROM worktrees WHERE session_id = ${req.params.sessionId}
`;
const results = [];
for (const row of rows) {

View File

@@ -83,16 +83,20 @@ CREATE TABLE IF NOT EXISTS session_worktrees (
base_commit TEXT,
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
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'session_worktrees_session_id_fkey'
AND confdeltype <> 'c'
AND confdeltype = 'c'
) THEN
ALTER TABLE session_worktrees DROP CONSTRAINT session_worktrees_session_id_fkey;
ALTER TABLE session_worktrees ADD CONSTRAINT session_worktrees_session_id_fkey
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
END IF;
END $$;
@@ -127,6 +131,101 @@ END $$;
-- v2.6: config fingerprint for stale-session detection (auto-recover on model change).
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT;
-- ─── 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).
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;
/** Resolved model id. */
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). */
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;
}
@@ -47,6 +54,10 @@ export interface AgentSessionHandle {
sessionId: string;
agent: string;
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. */
agentSessionId: string | null;
/** opencode HTTP server port; null for ACP backends. */

View File

@@ -423,9 +423,12 @@ export class OpenCodeServerBackend implements AgentBackend {
if (!this.client) throw new Error('opencode-server: client not ready after ensureServer');
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 }[]>`
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;
@@ -447,10 +450,12 @@ export class OpenCodeServerBackend implements AgentBackend {
agentSessionId = created.data.id;
await this.sql`
INSERT INTO agent_sessions
(session_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
(chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
VALUES
(${sessionId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
ON CONFLICT (session_id, agent) DO UPDATE SET
(${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
ON CONFLICT (chat_id, agent) DO UPDATE SET
session_id = EXCLUDED.session_id,
worktree_id = EXCLUDED.worktree_id,
backend = 'opencode_server',
agent_session_id = EXCLUDED.agent_session_id,
server_port = EXCLUDED.server_port,
@@ -462,7 +467,7 @@ export class OpenCodeServerBackend implements AgentBackend {
await this.sql`
UPDATE agent_sessions
SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash}
WHERE session_id = ${sessionId} AND agent = ${opts.agent}
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
`;
}
@@ -498,6 +503,8 @@ export class OpenCodeServerBackend implements AgentBackend {
sessionId,
agent: opts.agent,
backend: 'opencode_server',
chatId: opts.chatId,
worktreeId: opts.worktreeId,
agentSessionId: ocSessionId,
serverPort: this.port,
};
@@ -593,7 +600,7 @@ export class OpenCodeServerBackend implements AgentBackend {
}
await this.sql`
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(() => {});
}

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 { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
import { makeDcpStreamStripper } from './dcp-strip.js';
import { dispatchViaAcp } from './acp-dispatch.js';
import { getResolvedRegistry } from './provider-config-registry.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;
thinking_option_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
WHERE state = 'pending'
ORDER BY created_at
@@ -109,6 +111,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
chat_id: string | null;
}): Promise<void> {
const taskId = task.id;
@@ -510,6 +513,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
chat_id: string | null;
},
installPath: string | null,
): Promise<void> {
@@ -542,10 +546,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
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 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;
const chats = await sql<{ id: string }[]>`
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
// 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,
});
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 reasoningChunks: string[] = [];
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.
// This boundary is where message_id/chat_id get attached (the backend never
// owns them).
const onEvent = (e: AgentEvent): void => {
switch (e.type) {
case 'text':
textChunks.push(e.text);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: assistantId,
chat_id: chatId,
content: e.text,
} as WsFrame);
case 'text': {
const safe = dcp.push(e.text);
if (safe) {
textChunks.push(safe);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: assistantId,
chat_id: chatId,
content: safe,
} as WsFrame);
}
break;
}
case 'reasoning':
reasoningChunks.push(e.text);
broker.publishFrame(sessionId, {
@@ -670,7 +691,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
const handle = await backend.ensureSession(sessionId, {
agent,
model,
chatId,
worktreePath,
worktreeId,
projectId: task.project_id,
});
const result = await backend.prompt(handle, task.input, {
@@ -680,6 +703,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
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 reasoningText = reasoningChunks.join('').slice(0, 200_000);
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 ────────────────────────────────
export interface SessionWorktree {
/** P1.5-b: the `worktrees.id` — stored on agent_sessions informationally. */
worktreeId: string;
worktreePath: string;
baseCommit: string | null;
}
/**
* v2.6: create-or-reuse ONE worktree per BooCode session (shared across all
* agents/turns in the session), recorded in `session_worktrees`. Unlike the
* per-task `createWorktree`, this persists — it is NOT torn down per turn
* (cleanup is Phase 3). Captures the project's current HEAD as `base_commit`
* so the accumulating diff has a stable baseline across turns.
* v2.6 / P1.5-b: create-or-reuse ONE worktree per BooCode session (shared across
* all tabs/agents in the session), recorded in `worktrees` (was the superseded
* `session_worktrees`). Persists — NOT torn down per turn (cleanup is Phase 3) —
* and now survives session delete (`worktrees.session_id` is ON DELETE SET NULL).
* 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
* collides with the per-task worktrees that arena/new_task/MCP still use.
@@ -139,11 +141,13 @@ export async function ensureSessionWorktree(
sessionId: string,
opts?: { signal?: AbortSignal },
): Promise<SessionWorktree> {
const [existing] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
const [existing] = 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
`;
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}`;
@@ -167,17 +171,28 @@ export async function ensureSessionWorktree(
throw new Error(`Failed to create session worktree: ${result.stderr.trim() || result.stdout.trim()}`);
}
// Persist. ON CONFLICT keeps the first writer's row if two turns race the create.
await sql`
INSERT INTO session_worktrees (session_id, worktree_path, base_commit)
VALUES (${sessionId}, ${worktreePath}, ${baseCommit})
ON CONFLICT (session_id) DO NOTHING
// Insert-or-get: WHERE NOT EXISTS keeps the first writer's row if two turns race
// the create (the partial unique on active path also backstops it).
const [inserted] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
INSERT INTO worktrees (session_id, path, branch, base_commit, status)
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 }[]>`
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
if (inserted) {
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 {
worktreePath: row?.worktree_path ?? worktreePath,
worktreeId: row!.id,
worktreePath: row?.path ?? worktreePath,
baseCommit: row?.base_commit ?? baseCommit,
};
}

View File

@@ -28,18 +28,20 @@ const HtmlArtifactStateZ = z.object({
title: z.string().max(500),
});
const PaneKindZ = z.enum([
'chat',
'terminal',
'coder',
'agent', // legacy alias — normalized to coder on write
'empty',
'settings',
'markdown_artifact',
'html_artifact',
]);
const WorkspacePaneZ = z.object({
id: z.string().min(1).max(200),
kind: z.enum([
'chat',
'terminal',
'coder',
'agent', // legacy alias — normalized to coder on write
'empty',
'settings',
'markdown_artifact',
'html_artifact',
]),
kind: PaneKindZ,
chatId: z.string().min(1).max(200).optional(),
chatIds: z.array(z.string().min(1).max(200)).max(50),
activeChatIdx: z.number().int(),
@@ -47,8 +49,27 @@ const WorkspacePaneZ = z.object({
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({
workspace_panes: z.array(WorkspacePaneZ).max(10),
workspace_panes: z.union([z.array(WorkspacePaneZ).max(10), WorkspaceStateZ]),
});
const PatchBody = z.object({
@@ -308,12 +329,20 @@ export function registerSessionRoutes(
reply.code(400);
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,
);
const rows = await sql<Session[]>`
UPDATE sessions
SET workspace_panes = ${sql.json(workspacePanes as never)},
SET workspace_panes = ${sql.json(envelope as never)},
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
@@ -432,16 +461,18 @@ export function registerSessionRoutes(
const id = req.params.id;
const force = req.query.force === 'true' || req.query.force === '1';
// Session-delete work-loss guard. CASCADE on session_worktrees means the
// DELETE below auto-wipes the worktree row, so the safety check MUST run
// BEFORE it (paths read while the row still exists, pre-CASCADE).
// 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 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.
// 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<{ worktree_path: string }[]>`
SELECT worktree_path FROM session_worktrees WHERE session_id = ${id}
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.

View File

@@ -2,6 +2,7 @@ import type { Agent, Session, ToolCall } from '../../types/api.js';
import * as modelContext from '../model-context.js';
import { PathScopeError } from '../path_guard.js';
import { TOOLS_BY_NAME } from '../tools.js';
import type { ToolExecCtx } from '../tools.js';
import { matchToolGlob } from '../agents.js';
import { maybeFlagForCompaction } from './payload.js';
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
@@ -31,6 +32,7 @@ async function executeToolCall(
projectRoot: string,
toolCall: ToolCall,
extraRoots: readonly string[],
toolCtx?: ToolExecCtx,
): Promise<{ output: unknown; truncated: boolean; error?: string }> {
const tool = TOOLS_BY_NAME[toolCall.name];
if (!tool) {
@@ -65,7 +67,7 @@ async function executeToolCall(
};
}
try {
const output = await tool.execute(parsed.data, projectRoot, extraRoots);
const output = await tool.execute(parsed.data, projectRoot, extraRoots, toolCtx);
const truncated =
typeof output === 'object' && output !== null && 'truncated' in output
? Boolean((output as { truncated: unknown }).truncated)
@@ -289,7 +291,10 @@ export async function executeToolPhase(
});
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)) {
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 { resolve, basename, relative } from 'node:path';
import { z } from 'zod';
import type { Sql } from '../db.js';
import { pathGuard, PathScopeError } from './path_guard.js';
import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.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
// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
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 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> {
name: string;
description: string;
@@ -59,7 +73,15 @@ export interface ToolDef<TInput> {
// view_truncated_output) forward it to pathGuard; other tools accept the
// arg and ignore it. The execute signature stays compatible with
// 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({
@@ -694,6 +716,9 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
// state change is appending to sessions.allowed_read_paths via the
// grant endpoint, gated by user consent.
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));
// 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
// only with user consent). Belongs in the read-only budget tier.
'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;
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(

View File

@@ -203,7 +203,12 @@ export const SessionDeletedFrame = z.object({
export const SessionWorkspaceUpdatedFrame = z.object({
type: z.literal('session_workspace_updated'),
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({

View File

@@ -22,6 +22,7 @@ import type {
CoderTaskDetail,
PermissionPrompt,
AgentCommand,
WorkspaceState,
} from './types';
export class ApiError extends Error {
@@ -175,10 +176,10 @@ export const api = {
),
openChatsCount: (id: string) =>
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`, {
method: 'PATCH',
body: JSON.stringify({ workspace_panes: panes }),
body: JSON.stringify({ workspace_panes: state }),
}),
},
@@ -354,6 +355,10 @@ export const api = {
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
getTask: (taskId: string) =>
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) =>
request<CoderMessageWire[]>(
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,

View File

@@ -60,7 +60,10 @@ export interface Session {
// v1.9: null = inherit from project.default_web_search_enabled.
web_search_enabled: boolean | null;
// 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
// request_read_access tool. Empty by default. Settings UI surfaces the
// list with per-row revoke; the grant flow itself appends through the
@@ -511,6 +514,30 @@ export interface WorkspacePane {
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 =
| { type: 'snapshot'; messages: Message[] }
| { 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({
type: z.literal('session_workspace_updated'),
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({

View File

@@ -1,5 +1,5 @@
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 { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
@@ -51,6 +51,11 @@ interface Props {
webSearchEnabled?: boolean | null;
onSend: (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,
// ChatInput calls this with the skill name + the post-name args (possibly
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
@@ -78,7 +83,7 @@ interface Props {
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 [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
@@ -651,14 +656,38 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
rows={3}
className="resize-none min-h-[68px] max-h-[240px]"
/>
<Button
onClick={() => void submit()}
disabled={disabled || busy || (!value.trim() && attachments.length === 0)}
size="icon-lg"
aria-label="Send"
>
<Send />
</Button>
{(() => {
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
onClick={() => void submit()}
disabled={disabled || busy || !hasContent}
size="icon-lg"
variant={queueing ? 'secondary' : 'default'}
aria-label={queueing ? 'Queue message' : 'Send'}
title={queueing ? 'Queue message' : 'Send'}
>
{queueing ? <ListPlus /> : <Send />}
</Button>
);
})()}
</div>
</div>
<AttachmentPreviewModal

View File

@@ -16,11 +16,15 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useLongPress } from '@/hooks/useLongPress';
import { sessionEvents } from '@/hooks/sessionEvents';
import { cn } from '@/lib/utils';
interface Props {
pane: WorkspacePane;
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;
onRemoveTab: (chatId: string) => void;
onCloseOthers: (chatId: string) => void;
@@ -37,6 +41,7 @@ interface Props {
export function ChatTabBar({
pane,
tabs,
tabNumbers,
onSwitchTab,
onRemoveTab,
onCloseOthers,
@@ -83,6 +88,9 @@ export function ChatTabBar({
const isLast = tabIdx === tabs.length - 1;
const onlyTab = tabs.length === 1;
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 (
<ContextMenu key={chat.id}>
<ContextMenuTrigger asChild>
@@ -117,8 +125,11 @@ export function ChatTabBar({
className="bg-transparent border-b border-border text-xs outline-none w-28"
/>
) : (
<span className="truncate max-w-[140px]" title={label}>
{label}
<span
className="truncate max-w-[140px]"
title={tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
>
{tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
</span>
)}
<button
@@ -138,6 +149,13 @@ export function ChatTabBar({
<ContextMenuItem onSelect={onNewTab}>
New chat
</ContextMenuItem>
<ContextMenuItem
onSelect={() =>
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id })
}
>
Open in new pane
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
Rename
@@ -174,15 +192,31 @@ export function ChatTabBar({
)}
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
<button
type="button"
onClick={onNewTab}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New tab"
title="New tab"
>
<Plus size={12} />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New chat, terminal, or coder"
title="New chat / terminal / coder"
>
<Plus size={12} />
</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>
<DropdownMenuTrigger asChild>
<button

View File

@@ -1,9 +1,9 @@
import { useEffect, useState } 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 type { Chat, ErrorReason, Message } from '@/api/types';
import { api, ApiError } from '@/api/client';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
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
// 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 {
onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
onResend?: (chatId: string, content: string) => Promise<void>;
@@ -129,8 +117,8 @@ interface Props {
sessionChats?: Chat[];
capHitInfo?: { position: number; isLatest: boolean };
actions?: MessageActions;
/** Hide actions that don't apply (fork, delete, open-in-pane). */
hideActions?: ('fork' | 'delete' | 'openInPane')[];
/** Hide actions that don't apply (fork, delete). */
hideActions?: ('fork' | 'delete')[];
}
function StatsLine({ message }: { message: Message }) {
@@ -226,7 +214,7 @@ function ActionRow({
} else {
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
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) {
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 canFork = message.status === 'complete';
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 (
<>
@@ -330,18 +270,6 @@ function ActionRow({
<RefreshCw className="size-3" />
</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 && (
<button
type="button"

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 { ChatInput } from '@/components/ChatInput';
import { api } from '@/api/client';
import type { Chat } from '@/api/types';
interface Props {
projectId: string;
@@ -13,6 +16,30 @@ interface Props {
// the skill — same transition the text send uses. See useSessionChats.
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
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({
@@ -23,8 +50,24 @@ export function SessionLandingPage({
onSend,
onSkillInvoke,
createChat,
chats,
onOpenChat,
onUnarchiveChat,
}: Props) {
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> => {
if (chatId) return chatId;
@@ -57,12 +100,87 @@ export function SessionLandingPage({
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
}, [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 (
<div className="flex flex-col h-full min-h-0">
<div className="flex-1 flex items-center justify-center px-6">
<p className="text-sm text-muted-foreground">
Send a message to start.
</p>
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="max-w-[760px] mx-auto w-full px-4 py-4">
{isEmpty ? (
<p className="text-sm text-muted-foreground text-center py-8">
No conversations yet. Send a message to start.
</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>
<ChatInput
disabled={false}

View File

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

View File

@@ -1,5 +1,5 @@
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 { api } from '@/api/client';
import { useSessionStream } from '@/hooks/useSessionStream';
@@ -248,22 +248,6 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
</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 && (
<StaleStreamBanner
onRetry={() => void handleRetryStale()}
@@ -280,6 +264,8 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
webSearchEnabled={webSearchEnabled}
onSend={handleSend}
onForceSend={streaming ? handleForceSend : undefined}
generating={streaming}
onStop={handleStop}
onSlashCommand={handleSlashCommand}
chatId={chatId}
chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'}

View File

@@ -149,7 +149,7 @@ interface Props {
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) {
const endRef = useRef<HTMLDivElement>(null);

View File

@@ -581,6 +581,10 @@ export function CoderPane({
const [queue, setQueue] = useState<string[]>([]);
const queueProcessing = useRef(false);
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
useEffect(() => {
@@ -760,24 +764,35 @@ export function CoderPane({
}
}, [sessionId, paneId, chatId, agentConfig, setMessages]);
// Drain queue when not busy
// Drain queue once the agent is idle (not just past the dispatch POST).
useEffect(() => {
if (sending || queue.length === 0 || queueProcessing.current) return;
if (generating || queue.length === 0 || queueProcessing.current) return;
queueProcessing.current = true;
const next = queue[0]!;
setQueue((prev) => prev.slice(1));
sendOneMessage(next).finally(() => { queueProcessing.current = false; });
}, [sending, queue, sendOneMessage]);
}, [generating, queue, sendOneMessage]);
const handleChatInputSend = useCallback(async (content: string) => {
const text = content.trim();
if (!text || !chatId) return;
if (sending) {
if (generating) {
setQueue((prev) => [...prev, text]);
return;
}
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) => {
if (!chatId) return;
@@ -867,9 +882,11 @@ export function CoderPane({
{/* Composer + input */}
<div className="shrink-0 border-t border-border">
<ChatInput
disabled={sending || !chatId || chatPending}
disabled={!chatId || chatPending}
projectId={projectPath ?? ''}
onSend={handleChatInputSend}
generating={generating}
onStop={handleStop}
onSlashCommand={handleChatInputSlash}
slashGroups={slashGroups}
chatId={chatId ?? undefined}

View File

@@ -51,7 +51,11 @@ export interface SessionUpdatedEvent {
export interface SessionWorkspaceUpdatedEvent {
type: 'session_workspace_updated';
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 {
@@ -75,6 +79,14 @@ export interface OpenChatInActivePaneEvent {
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
// these; useWorkspacePanes subscribes and inserts the corresponding artifact
// pane (or focuses an existing one keyed by message_id).
@@ -178,6 +190,7 @@ export type SessionEvent =
| OpenFileInBrowserEvent
| AttachChatFileEvent
| OpenChatInActivePaneEvent
| OpenChatInNewPaneEvent
| OpenMarkdownArtifactPaneEvent
| OpenHtmlArtifactPaneEvent
| OpenSettingsPaneEvent

View File

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

View File

@@ -3,9 +3,11 @@ import type { DragEvent } from 'react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type {
ClosedPaneEntry,
HtmlArtifactState,
MarkdownArtifactState,
WorkspacePane,
WorkspaceState,
} from '@/api/types';
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
import { sessionEvents } from '@/hooks/sessionEvents';
@@ -32,19 +34,35 @@ function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}
interface ClosedPaneEntry {
kind: WorkspacePane['kind'];
chatIds: string[];
activeChatIdx: number;
}
// v2.6.x: reopen stack cap. The stack now lives in React state (persisted in
// the WorkspaceState envelope), not a module-level array. `appendClosed` is the
// pure state-updater helper.
const MAX_CLOSED = 10;
const closedPaneStack: ClosedPaneEntry[] = [];
function pushClosed(pane: WorkspacePane): void {
if (pane.kind === 'empty' || pane.kind === 'settings') return;
if (pane.chatIds.length === 0) return;
closedPaneStack.push({ kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx });
if (closedPaneStack.length > MAX_CLOSED) closedPaneStack.shift();
// Pure helper: append a closed-pane entry derived from `pane` to `stack`,
// capped at MAX_CLOSED (most-recent last). Returns the SAME reference when the
// pane is not eligible (empty/settings/no chats) so callers can skip setState.
function appendClosed(stack: ClosedPaneEntry[], pane: WorkspacePane): ClosedPaneEntry[] {
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 {
@@ -110,6 +128,26 @@ function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
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.
// Helper used at every pane-insertion site so the rule lives in one place.
function nonSettingsCount(panes: WorkspacePane[]): number {
@@ -132,6 +170,9 @@ function readLegacyPanes(sessionId: string): WorkspacePane[] | null {
export interface UseWorkspacePanesResult {
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;
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
activePaneIdxRef: React.MutableRefObject<number>;
@@ -171,6 +212,12 @@ export interface UseWorkspacePanesResult {
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const [panes, setPanes] = useState<WorkspacePane[]>(() => [emptyPane()]);
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 [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
// v1.12.1: skip PATCH while hydrating from the server. Without this, the
@@ -237,27 +284,42 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
try {
const session = await api.sessions.get(sessionId);
if (cancelled) return;
let initial: WorkspacePane[] = Array.isArray(session.workspace_panes)
? normalizePanes(session.workspace_panes)
: [];
let env = toWorkspaceState(session.workspace_panes);
let initial: WorkspacePane[] = normalizePanes(env.panes);
// 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) {
const legacy = readLegacyPanes(sessionId);
if (legacy && legacy.length > 0) {
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;
initial = updated.workspace_panes;
env = toWorkspaceState(updated.workspace_panes);
initial = normalizePanes(env.panes);
localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
} catch {
initial = legacy;
env = { ...env, panes: legacy };
initial = normalizePanes(legacy);
}
}
}
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);
setTabNumbers(env.tabNumbers);
setNextTabNumber(env.nextTabNumber);
setClosedPaneStack(env.closedPaneStack);
setActivePaneIdx(0);
seedEmptyScopedPanes(next);
} finally {
@@ -273,15 +335,25 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return sessionEvents.subscribe((ev) => {
if (ev.type !== 'session_workspace_updated') return;
if (ev.session_id !== sessionId) return;
const incoming = normalizePanes(
Array.isArray(ev.workspace_panes) ? ev.workspace_panes : [],
);
const json = JSON.stringify(incoming);
const env = toWorkspaceState(ev.workspace_panes);
const incoming = normalizePanes(env.panes);
// Echo-dedup on the FULL envelope so tabNumber / stack-only changes are
// 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;
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)));
seedEmptyScopedPanes(incoming.length > 0 ? incoming : [emptyPane()]);
seedEmptyScopedPanes(nextPanes);
});
}, [sessionId, seedEmptyScopedPanes]);
@@ -333,18 +405,75 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
// before saving (ephemeral per v1.9).
useEffect(() => {
if (!hydratedRef.current) return;
const payload = persistablePanes(panes);
const json = JSON.stringify(payload);
// v2.6.x: persist the full WorkspaceState envelope. The dedup ref compares
// 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;
const timer = setTimeout(() => {
lastRemoteJsonRef.current = json;
api.sessions.updateWorkspacePanes(sessionId, payload).catch(() => {
api.sessions.updateWorkspacePanes(sessionId, envelope).catch(() => {
// Non-fatal: next change retries. Persistent failures surface via
// the network layer's existing reconnect toast.
});
}, SAVE_DEBOUNCE_MS);
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(() => {
const active = panes[activePaneIdx];
@@ -391,6 +520,37 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
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) => {
setPanes((prev) => {
const next = [...prev];
@@ -411,7 +571,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
if (next.length > 1) {
// Last tab closed and other panes exist — remove the whole pane
// instead of leaving an orphaned empty panel.
pushClosed(pane); setHasClosedPanes(true);
setClosedPaneStack((stack) => appendClosed(stack, pane));
const spliced = next.filter((_, i) => i !== paneIdx);
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
return spliced;
@@ -547,7 +707,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
setPanes((prev) => {
if (prev.length <= 1) {
// 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') {
setActivePaneIdx(0);
return [emptyPane()];
@@ -559,35 +720,101 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
// The endpoint is idempotent (404 on missing session) so a strict-mode
// double-invoke of the updater is safe.
const removed = prev[idx];
if (removed) { pushClosed(removed); setHasClosedPanes(true); }
// Push the original pane (with its chatIds intact) to the reopen stack.
if (removed) setClosedPaneStack((stack) => appendClosed(stack, removed));
if (removed?.kind === 'terminal') {
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));
return next;
});
}, [sessionId]);
const [hasClosedPanes, setHasClosedPanes] = useState(closedPaneStack.length > 0);
const hasClosedPanes = closedPaneStack.length > 0;
const reopenPane = useCallback(() => {
const entry = closedPaneStack.pop();
setHasClosedPanes(closedPaneStack.length > 0);
if (!entry) return;
// Read the top entry from the current render's stack (not inside the
// updater) so a StrictMode double-invoke can't pop two entries. The pop
// 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) => {
// 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 = {
id: generateId(),
kind: entry.kind,
chatId: entry.chatIds[entry.activeChatIdx] ?? entry.chatIds[0],
chatIds: entry.chatIds,
activeChatIdx: Math.min(entry.activeChatIdx, entry.chatIds.length - 1),
kind: e.kind,
chatId: e.chatIds[e.activeChatIdx] ?? e.chatIds[0],
chatIds: e.chatIds,
activeChatIdx: Math.min(e.activeChatIdx, e.chatIds.length - 1),
};
const next = [...prev, restored];
const next = [...stripped, restored];
setActivePaneIdx(next.length - 1);
return next;
});
}, []);
}, [closedPaneStack]);
// 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.
@@ -705,6 +932,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return {
panes,
tabNumbers,
activePaneIdx,
setActivePaneIdx,
activePaneIdxRef,

View File

@@ -56,19 +56,26 @@ export function inferLanguage(filename: string): string | null {
export function flattenToMessage(attachments: Attachment[], text: string): string {
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 ``` fence or provenance header. The chip only exists to keep the textarea
// tidy while composing; on send it should be exactly what the user pasted.
// Pasted text is raw context, not code from a file — insert it verbatim with no
// ``` fence or provenance header. It trails the typed text with a leading space
// so a leading slash command / prompt stays first and the paste reads as its
// 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') {
return a.content;
pasteBlocks.push(a.content);
continue;
}
const fence = '```' + (a.language ?? '');
const header =
a.kind === 'lines'
? `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`
: `// from: ${a.filename}`;
return `${fence}\n${header}\n${a.content}\n\`\`\``;
});
return [...blocks, text].filter(Boolean).join('\n\n');
fencedBlocks.push(`${fence}\n${header}\n${a.content}\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');
}

View File

@@ -1,5 +1,11 @@
# 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
---
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