Compare commits

..

7 Commits

Author SHA1 Message Date
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
19 changed files with 629 additions and 55 deletions

View File

@@ -2,6 +2,14 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. 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.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 ## 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. 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. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
**Cursor agents:** start with `AGENTS.md` (navigation) and `docs/ARCHITECTURE.md` (diagram). This file is the deep engineering reference. **Cursor agents:** start with `docs/ARCHITECTURE.md` (diagram). This file is the deep engineering reference. (Note: the root navigation `AGENTS.md` was removed in v1.12; `data/AGENTS.md` is the agent *registry*, not navigation.)
## What is BooCode ## What is BooCode
@@ -90,9 +90,9 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
- **Provider snapshot lifecycle** (`apps/coder/src/services/`): `provider-config.ts` (Zod config, never-throws on bad input) → `provider-config-registry.ts` (`buildResolvedRegistry`, singleton) → `provider-snapshot.ts` (two-tier probe: tier-1 fast presence, tier-2 cold ACP probe skipped unless force / stale `PROVIDER_PROBE_TTL_MS` 24h / dbEmpty; cached). Verify live: `curl http://100.114.205.53:9502/api/providers/snapshot` — returns providers + models + commands, the exact shape `AgentComposerBar` renders. - **Provider snapshot lifecycle** (`apps/coder/src/services/`): `provider-config.ts` (Zod config, never-throws on bad input) → `provider-config-registry.ts` (`buildResolvedRegistry`, singleton) → `provider-snapshot.ts` (two-tier probe: tier-1 fast presence, tier-2 cold ACP probe skipped unless force / stale `PROVIDER_PROBE_TTL_MS` 24h / dbEmpty; cached). Verify live: `curl http://100.114.205.53:9502/api/providers/snapshot` — returns providers + models + commands, the exact shape `AgentComposerBar` renders.
- `PATCH /api/providers/config` replaces a provider id's override object **wholesale** (per-id shallow merge) — to flip one field send `{...existing, enabled}`, or a custom ACP entry's `command`/`label` is wiped and it drops out of the resolved registry. `data/coder-providers.json` is **gitignored** (it's live runtime config — the coder reads AND writes it on UI toggles); the tracked reference is `data/coder-providers.example.json`. The loader falls back to `{providers:{}}` (built-ins only) when the live file is absent, so a fresh checkout needs no copy. - `PATCH /api/providers/config` replaces a provider id's override object **wholesale** (per-id shallow merge) — to flip one field send `{...existing, enabled}`, or a custom ACP entry's `command`/`label` is wiped and it drops out of the resolved registry. `data/coder-providers.json` is **gitignored** (it's live runtime config — the coder reads AND writes it on UI toggles); the tracked reference is `data/coder-providers.example.json`. The loader falls back to `{providers:{}}` (built-ins only) when the live file is absent, so a fresh checkout needs no copy.
- **opencode** runs as a warm HTTP server (v2.6 Phase 1, `services/backends/opencode-server.ts``opencode serve` per BooCoder process, one opencode session per BooCode session, resumed via `agent_sessions`). goose/qwen/claude still dispatch **one-shot** ACP/PTY with no ctx/token usage; only native `boocode` (llama-swap engine) tracks ctx. Paseo's per-provider native clients (design §12) deliberately not ported. - **opencode** runs as a warm HTTP server (v2.6 Phase 1, `services/backends/opencode-server.ts``opencode serve` per BooCoder process, one opencode session per BooCode session, resumed via `agent_sessions`). goose/qwen/claude still dispatch **one-shot** ACP/PTY with no ctx/token usage; only native `boocode` (llama-swap engine) tracks ctx. Paseo's per-provider native clients (design §12) deliberately not ported.
- **opencode SSE** (`opencode-server.ts`): live streaming arrives as `session.next.text.delta` / `session.next.reasoning.delta` / `session.next.tool.{called,success,failed}` — NOT `message.part.*` (those are terminal/post-hoc). `client.event.subscribe({ directory })` MUST pass the session's worktree directory; omit it and opencode scopes events to the server's `process.cwd()` → zero session events (empty turns, 180s watchdog timeout). One SSE stream at a time scoped to the last session's dir — concurrent opencode sessions in different worktrees collide (known Phase 1 limit, warns). Turn completes on `session.idle`; `promptAsync` is fire-and-forget (204). - **opencode SSE** (`opencode-server.ts`): live streaming arrives as `session.next.text.delta` / `session.next.reasoning.delta` / `session.next.tool.{called,success,failed}` — NOT `message.part.*` (those are terminal/post-hoc). `client.event.subscribe({ directory })` MUST pass the session's worktree directory; omit it and opencode scopes events to the server's `process.cwd()` → zero session events (empty turns, 180s watchdog timeout). Per-session SSE (P1.5-a): each live session owns its own `event.subscribe({directory})` loop + AbortController, so concurrent sessions in different worktrees stream independently; a `sessionID` demux guard drops cross-session events when two share a dir. Turn completes on `session.idle`; `promptAsync` is fire-and-forget (204).
- **opencode model strings** must be provider-prefixed (`llama-swap/<model>`) AND exist in `~/.config/opencode/opencode.json` `provider.llama-swap.models` — not merely loadable by llama-swap. `parseModel` infers `llama-swap/` for a bare id; the dispatcher coalesces empty→DEFAULT_MODEL then prefixes. `agent-probe` populates opencode's `available_agents.models` via `mergeLlamaSwap` (fetches `/v1/models`); empty model list → frontend sends `''` → no inference (`input:0`, empty turn). - **opencode model strings** must be provider-prefixed (`llama-swap/<model>`) AND exist in `~/.config/opencode/opencode.json` `provider.llama-swap.models` — not merely loadable by llama-swap. `parseModel` infers `llama-swap/` for a bare id; the dispatcher coalesces empty→DEFAULT_MODEL then prefixes. `agent-probe` populates opencode's `available_agents.models` via `mergeLlamaSwap` (fetches `/v1/models`); empty model list → frontend sends `''` → no inference (`input:0`, empty turn).
- **agent_sessions resume**: `config_hash = sha256('opencode_server|<model>')` — must NOT include the server port (random per boot; including it breaks cross-restart resume). `session_worktrees` + `agent_sessions` FKs to `sessions(id)` are `ON DELETE CASCADE` (else DELETE /api/sessions/:id 500s on FK violation). The `@opencode-ai/sdk` v2 client takes flattened params (`{sessionID, directory, parts, model:{providerID,modelID}}`), imports `createOpencodeClient` from `@opencode-ai/sdk/v2/client`. - **agent_sessions resume**: `config_hash = sha256('opencode_server|<model>')` — must NOT include the server port (random per boot; including it breaks cross-restart resume). P1.5-b: `agent_sessions` is keyed `(chat_id, agent)` — the tab/chat is the context unit (two opencode tabs in one session = two contexts sharing one worktree). `chat_id` CASCADEs from `chats`; `session_id`/`worktree_id` are informational `SET NULL`. The `worktrees` table (one-per-session, `session_id` SET NULL so it survives session delete) supersedes the defanged `session_worktrees`. `tasks.chat_id` threads the tab id to the dispatcher; `runOpenCodeServerTask` falls back to resolve-or-create a chat when it's null (arena/MCP/new_task). The `@opencode-ai/sdk` v2 client takes flattened params (`{sessionID, directory, parts, model:{providerID,modelID}}`), imports `createOpencodeClient` from `@opencode-ai/sdk/v2/client`.
### Frontend (`apps/web/src/`) ### Frontend (`apps/web/src/`)
@@ -192,6 +192,8 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
- **ReadableStream test stubs** use `pull()` (not `start()`) so chunks are produced lazily — `start()` enqueues everything and calls `controller.close()` before the consumer reads, so a subsequent `reader.cancel()` finds the stream already closed and the `cancel()` callback never fires. Also provide MORE chunks than the test will consume so the source stays in 'readable' state when cancel runs (e.g. cap test reads ~6 chunks, stub provides 10). - **ReadableStream test stubs** use `pull()` (not `start()`) so chunks are produced lazily — `start()` enqueues everything and calls `controller.close()` before the consumer reads, so a subsequent `reader.cancel()` finds the stream already closed and the `cancel()` callback never fires. Also provide MORE chunks than the test will consume so the source stays in 'readable' state when cancel runs (e.g. cap test reads ~6 chunks, stub provides 10).
- Tool-name whitelists must derive from `ALL_TOOLS` in `services/tools.ts`, never hardcoded. `services/agents.ts` `ALL_TOOL_NAMES` had this drift class until v1.12 — same pattern applies to any future tool-aware code. - Tool-name whitelists must derive from `ALL_TOOLS` in `services/tools.ts`, never hardcoded. `services/agents.ts` `ALL_TOOL_NAMES` had this drift class until v1.12 — same pattern applies to any future tool-aware code.
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects. - Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
- `data/AGENTS.md` is PARSED (`agents.ts` `splitSections`/`parseAgentSection`): each `## <Name>` is one agent and must be followed by a `---` frontmatter fence or the block throws; content before the first `## ` is discarded. Do NOT add free-form `## ` rule sections — they break the registry. Cross-cutting agent rules go in CLAUDE.md or a parser-ignored preamble.
- Skills live in `data/skills/<vendor>/`; Sam's own namespace is `boocode/` (`committing-changes`, `using-worktrees`, `improving-boocode-guidance`) — `SKILL.md` + optional `eval.yaml` (gerund names; eval = `skill:` + `tasks:` of `prompt`+`grader`, incl. a negative-trigger task). `data/skills/` is canonical; a divergent mirror at `/opt/skills/` exists.
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports). - MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
- **Workspace dependency pattern** (`apps/coder``@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer. - **Workspace dependency pattern** (`apps/coder``@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`. - **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): vo
'/api/sessions/:sessionId/worktree-risk', '/api/sessions/:sessionId/worktree-risk',
async (req) => { async (req) => {
const rows = await sql<{ worktree_path: string }[]>` 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 = []; const reports = [];
for (const row of rows) { for (const row of rows) {
@@ -33,7 +33,7 @@ export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): vo
'/api/sessions/:sessionId/worktree-stash', '/api/sessions/:sessionId/worktree-stash',
async (req) => { async (req) => {
const rows = await sql<{ worktree_path: string }[]>` 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 = []; const results = [];
for (const row of rows) { for (const row of rows) {

View File

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

View File

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

View File

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

View File

@@ -423,9 +423,12 @@ export class OpenCodeServerBackend implements AgentBackend {
if (!this.client) throw new Error('opencode-server: client not ready after ensureServer'); if (!this.client) throw new Error('opencode-server: client not ready after ensureServer');
const configHash = sessionConfigHash(opts.model); const configHash = sessionConfigHash(opts.model);
// P1.5-b: agent_sessions is keyed (chat_id, agent) — the tab/chat is the
// context unit (two tabs in one session = two contexts sharing one worktree).
// session_id + worktree_id are retained as informational (SET NULL) columns.
const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>` const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>`
SELECT agent_session_id, status, config_hash FROM agent_sessions SELECT agent_session_id, status, config_hash FROM agent_sessions
WHERE session_id = ${sessionId} AND agent = ${opts.agent} WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
`; `;
let agentSessionId = row?.agent_session_id ?? null; let agentSessionId = row?.agent_session_id ?? null;
@@ -447,10 +450,12 @@ export class OpenCodeServerBackend implements AgentBackend {
agentSessionId = created.data.id; agentSessionId = created.data.id;
await this.sql` await this.sql`
INSERT INTO agent_sessions INSERT INTO agent_sessions
(session_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash) (chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
VALUES VALUES
(${sessionId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash}) (${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
ON CONFLICT (session_id, agent) DO UPDATE SET ON CONFLICT (chat_id, agent) DO UPDATE SET
session_id = EXCLUDED.session_id,
worktree_id = EXCLUDED.worktree_id,
backend = 'opencode_server', backend = 'opencode_server',
agent_session_id = EXCLUDED.agent_session_id, agent_session_id = EXCLUDED.agent_session_id,
server_port = EXCLUDED.server_port, server_port = EXCLUDED.server_port,
@@ -462,7 +467,7 @@ export class OpenCodeServerBackend implements AgentBackend {
await this.sql` await this.sql`
UPDATE agent_sessions UPDATE agent_sessions
SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash} SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash}
WHERE session_id = ${sessionId} AND agent = ${opts.agent} WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
`; `;
} }
@@ -498,6 +503,8 @@ export class OpenCodeServerBackend implements AgentBackend {
sessionId, sessionId,
agent: opts.agent, agent: opts.agent,
backend: 'opencode_server', backend: 'opencode_server',
chatId: opts.chatId,
worktreeId: opts.worktreeId,
agentSessionId: ocSessionId, agentSessionId: ocSessionId,
serverPort: this.port, serverPort: this.port,
}; };
@@ -593,7 +600,7 @@ export class OpenCodeServerBackend implements AgentBackend {
} }
await this.sql` await this.sql`
UPDATE agent_sessions SET status = 'closed' UPDATE agent_sessions SET status = 'closed'
WHERE session_id = ${handle.sessionId} AND agent = ${handle.agent} WHERE chat_id = ${handle.chatId} AND agent = ${handle.agent}
`.catch(() => {}); `.catch(() => {});
} }

View File

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

View File

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

View File

@@ -119,16 +119,18 @@ export async function cleanupWorktree(
// ─── v2.6: session-keyed persistent worktree ──────────────────────────────── // ─── v2.6: session-keyed persistent worktree ────────────────────────────────
export interface SessionWorktree { export interface SessionWorktree {
/** P1.5-b: the `worktrees.id` — stored on agent_sessions informationally. */
worktreeId: string;
worktreePath: string; worktreePath: string;
baseCommit: string | null; baseCommit: string | null;
} }
/** /**
* v2.6: create-or-reuse ONE worktree per BooCode session (shared across all * v2.6 / P1.5-b: create-or-reuse ONE worktree per BooCode session (shared across
* agents/turns in the session), recorded in `session_worktrees`. Unlike the * all tabs/agents in the session), recorded in `worktrees` (was the superseded
* per-task `createWorktree`, this persists — it is NOT torn down per turn * `session_worktrees`). Persists — NOT torn down per turn (cleanup is Phase 3) —
* (cleanup is Phase 3). Captures the project's current HEAD as `base_commit` * and now survives session delete (`worktrees.session_id` is ON DELETE SET NULL).
* so the accumulating diff has a stable baseline across turns. * Captures the project's current HEAD as `base_commit` for a stable diff baseline.
* *
* Distinct path namespace (`session-<id>` branch, `/sess-<id>` dir) so it never * Distinct path namespace (`session-<id>` branch, `/sess-<id>` dir) so it never
* collides with the per-task worktrees that arena/new_task/MCP still use. * collides with the per-task worktrees that arena/new_task/MCP still use.
@@ -139,11 +141,13 @@ export async function ensureSessionWorktree(
sessionId: string, sessionId: string,
opts?: { signal?: AbortSignal }, opts?: { signal?: AbortSignal },
): Promise<SessionWorktree> { ): Promise<SessionWorktree> {
const [existing] = await sql<{ worktree_path: string; base_commit: string | null }[]>` const [existing] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId} SELECT id, path, base_commit FROM worktrees
WHERE session_id = ${sessionId} AND status = 'active'
LIMIT 1
`; `;
if (existing) { if (existing) {
return { worktreePath: existing.worktree_path, baseCommit: existing.base_commit }; return { worktreeId: existing.id, worktreePath: existing.path, baseCommit: existing.base_commit };
} }
const worktreePath = `${WORKTREE_BASE}/sess-${sessionId}`; const worktreePath = `${WORKTREE_BASE}/sess-${sessionId}`;
@@ -167,17 +171,28 @@ export async function ensureSessionWorktree(
throw new Error(`Failed to create session worktree: ${result.stderr.trim() || result.stdout.trim()}`); throw new Error(`Failed to create session worktree: ${result.stderr.trim() || result.stdout.trim()}`);
} }
// Persist. ON CONFLICT keeps the first writer's row if two turns race the create. // Insert-or-get: WHERE NOT EXISTS keeps the first writer's row if two turns race
await sql` // the create (the partial unique on active path also backstops it).
INSERT INTO session_worktrees (session_id, worktree_path, base_commit) const [inserted] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
VALUES (${sessionId}, ${worktreePath}, ${baseCommit}) INSERT INTO worktrees (session_id, path, branch, base_commit, status)
ON CONFLICT (session_id) DO NOTHING SELECT ${sessionId}, ${worktreePath}, ${branchName}, ${baseCommit}, 'active'
WHERE NOT EXISTS (
SELECT 1 FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'
)
RETURNING id, path, base_commit
`; `;
const [row] = await sql<{ worktree_path: string; base_commit: string | null }[]>` if (inserted) {
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId} return { worktreeId: inserted.id, worktreePath: inserted.path, baseCommit: inserted.base_commit };
}
// Lost the race — another turn inserted first; read its row.
const [row] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
SELECT id, path, base_commit FROM worktrees
WHERE session_id = ${sessionId} AND status = 'active'
LIMIT 1
`; `;
return { return {
worktreePath: row?.worktree_path ?? worktreePath, worktreeId: row!.id,
worktreePath: row?.path ?? worktreePath,
baseCommit: row?.base_commit ?? baseCommit, baseCommit: row?.base_commit ?? baseCommit,
}; };
} }

View File

@@ -432,16 +432,18 @@ export function registerSessionRoutes(
const id = req.params.id; const id = req.params.id;
const force = req.query.force === 'true' || req.query.force === '1'; const force = req.query.force === 'true' || req.query.force === '1';
// Session-delete work-loss guard. CASCADE on session_worktrees means the // Session-delete work-loss guard. The check MUST run BEFORE the DELETE:
// DELETE below auto-wipes the worktree row, so the safety check MUST run // worktrees.session_id is ON DELETE SET NULL (P1.5-b), so once the session
// BEFORE it (paths read while the row still exists, pre-CASCADE). // 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. // Optimization: read worktrees (P1.5-b — was session_worktrees) from our
// No row => chat-only session => nothing on disk => delete immediately, // own (shared) DB first. No row => chat-only session => nothing on disk =>
// zero round-trip. Only worktree-backed sessions pay the host git check. // delete immediately, zero round-trip. Only worktree-backed sessions pay
// the host git check.
if (!force) { if (!force) {
const worktrees = await sql<{ worktree_path: string }[]>` const worktrees = await sql<{ path: string }[]>`
SELECT worktree_path FROM session_worktrees WHERE session_id = ${id} SELECT path FROM worktrees WHERE session_id = ${id}
`; `;
if (worktrees.length > 0) { if (worktrees.length > 0) {
// Worktree dirs live on the host; only BooCoder can run git on them. // Worktree dirs live on the host; only BooCoder can run git on them.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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