Compare commits
13 Commits
c65daba5dd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b4f41b26f | |||
| 5527e7a5e8 | |||
| 08d6a8fa40 | |||
| 2fd7e5bf97 | |||
| d05f73be26 | |||
| e857815d79 | |||
| 12d31a81a0 | |||
| 5da6eb2447 | |||
| 7f6c4780e2 | |||
| 30b6f70f95 | |||
| c2b3e0a013 | |||
| cb1846c0d5 | |||
| f1a85627e4 |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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.
|
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
|
## 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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
73
apps/coder/src/services/__tests__/dcp-strip.test.ts
Normal file
73
apps/coder/src/services/__tests__/dcp-strip.test.ts
Normal 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>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
77
apps/coder/src/services/dcp-strip.ts
Normal file
77
apps/coder/src/services/dcp-strip.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { Broker } from '@boocode/server/broker';
|
|||||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
|
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
|
||||||
|
import { makeDcpStreamStripper } from './dcp-strip.js';
|
||||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||||
import { getResolvedRegistry } from './provider-config-registry.js';
|
import { getResolvedRegistry } from './provider-config-registry.js';
|
||||||
import { dispatchViaPty } from './pty-dispatch.js';
|
import { dispatchViaPty } from './pty-dispatch.js';
|
||||||
@@ -77,8 +78,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
mode_id: string | null;
|
mode_id: string | null;
|
||||||
thinking_option_id: string | null;
|
thinking_option_id: string | null;
|
||||||
session_id: string | null;
|
session_id: string | null;
|
||||||
|
chat_id: string | null;
|
||||||
}[]>`
|
}[]>`
|
||||||
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
|
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id
|
||||||
FROM tasks
|
FROM tasks
|
||||||
WHERE state = 'pending'
|
WHERE state = 'pending'
|
||||||
ORDER BY created_at
|
ORDER BY created_at
|
||||||
@@ -109,6 +111,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
mode_id: string | null;
|
mode_id: string | null;
|
||||||
thinking_option_id: string | null;
|
thinking_option_id: string | null;
|
||||||
session_id: string | null;
|
session_id: string | null;
|
||||||
|
chat_id: string | null;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const taskId = task.id;
|
const taskId = task.id;
|
||||||
|
|
||||||
@@ -510,6 +513,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
mode_id: string | null;
|
mode_id: string | null;
|
||||||
thinking_option_id: string | null;
|
thinking_option_id: string | null;
|
||||||
session_id: string | null;
|
session_id: string | null;
|
||||||
|
chat_id: string | null;
|
||||||
},
|
},
|
||||||
installPath: string | null,
|
installPath: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -542,10 +546,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Resolve session + chat (mirrors runExternalAgent).
|
// Resolve session + chat. P1.5-b: the chat (tab) is the context key, so the
|
||||||
|
// chat_id MUST be non-null and stable before ensureSession. The coder message
|
||||||
|
// route + skills route stamp task.chat_id with the frontend tab's chat — use
|
||||||
|
// it directly. Session-less creators (arena, MCP, new_task, generic
|
||||||
|
// /api/tasks) leave it null; fall back to resolving/creating a real chat so
|
||||||
|
// ensureSession never receives a degenerate (null, agent) key.
|
||||||
let sessionId: string;
|
let sessionId: string;
|
||||||
let chatId: string;
|
let chatId: string;
|
||||||
if (task.session_id) {
|
if (task.chat_id && task.session_id) {
|
||||||
|
sessionId = task.session_id;
|
||||||
|
chatId = task.chat_id;
|
||||||
|
} else if (task.session_id) {
|
||||||
sessionId = task.session_id;
|
sessionId = task.session_id;
|
||||||
const chats = await sql<{ id: string }[]>`
|
const chats = await sql<{ id: string }[]>`
|
||||||
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
|
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
|
||||||
@@ -586,7 +598,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
|
|
||||||
// Persistent, session-keyed worktree (shared across turns; NOT torn down
|
// Persistent, session-keyed worktree (shared across turns; NOT torn down
|
||||||
// per turn — Phase 3 reaps it). Captures base_commit for a stable diff.
|
// per turn — Phase 3 reaps it). Captures base_commit for a stable diff.
|
||||||
const { worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
|
const { worktreeId, worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
|
||||||
signal: ac.signal,
|
signal: ac.signal,
|
||||||
});
|
});
|
||||||
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
|
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
|
||||||
@@ -620,21 +632,30 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
const textChunks: string[] = [];
|
const textChunks: string[] = [];
|
||||||
const reasoningChunks: string[] = [];
|
const reasoningChunks: string[] = [];
|
||||||
const toolSnaps = new Map<string, AcpToolSnapshot>();
|
const toolSnaps = new Map<string, AcpToolSnapshot>();
|
||||||
|
// opencode's dcp plugin appends <dcp-message-id>…</dcp-message-id> to the
|
||||||
|
// text, streamed split across deltas — a per-chunk regex misses it (see
|
||||||
|
// dcp-strip.ts). Buffer text through a cross-chunk stripper so neither the
|
||||||
|
// live `delta` frames nor the persisted content ever carry the tag.
|
||||||
|
const dcp = makeDcpStreamStripper();
|
||||||
|
|
||||||
// Map transport-agnostic AgentEvents → the SAME WS frames the ACP path emits.
|
// Map transport-agnostic AgentEvents → the SAME WS frames the ACP path emits.
|
||||||
// This boundary is where message_id/chat_id get attached (the backend never
|
// This boundary is where message_id/chat_id get attached (the backend never
|
||||||
// owns them).
|
// owns them).
|
||||||
const onEvent = (e: AgentEvent): void => {
|
const onEvent = (e: AgentEvent): void => {
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
case 'text':
|
case 'text': {
|
||||||
textChunks.push(e.text);
|
const safe = dcp.push(e.text);
|
||||||
|
if (safe) {
|
||||||
|
textChunks.push(safe);
|
||||||
broker.publishFrame(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'delta',
|
type: 'delta',
|
||||||
message_id: assistantId,
|
message_id: assistantId,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
content: e.text,
|
content: safe,
|
||||||
} as WsFrame);
|
} as WsFrame);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'reasoning':
|
case 'reasoning':
|
||||||
reasoningChunks.push(e.text);
|
reasoningChunks.push(e.text);
|
||||||
broker.publishFrame(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
@@ -670,7 +691,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
const handle = await backend.ensureSession(sessionId, {
|
const handle = await backend.ensureSession(sessionId, {
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
|
chatId,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
|
worktreeId,
|
||||||
projectId: task.project_id,
|
projectId: task.project_id,
|
||||||
});
|
});
|
||||||
const result = await backend.prompt(handle, task.input, {
|
const result = await backend.prompt(handle, task.input, {
|
||||||
@@ -680,6 +703,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
onEvent,
|
onEvent,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Flush any text held back mid-tag at stream end (complete tags stripped).
|
||||||
|
const dcpTail = dcp.flush();
|
||||||
|
if (dcpTail) {
|
||||||
|
textChunks.push(dcpTail);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'delta',
|
||||||
|
message_id: assistantId,
|
||||||
|
chat_id: chatId,
|
||||||
|
content: dcpTail,
|
||||||
|
} as WsFrame);
|
||||||
|
}
|
||||||
|
|
||||||
const assistantContent = textChunks.join('').slice(0, 50_000);
|
const assistantContent = textChunks.join('').slice(0, 50_000);
|
||||||
const reasoningText = reasoningChunks.join('').slice(0, 200_000);
|
const reasoningText = reasoningChunks.join('').slice(0, 200_000);
|
||||||
const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'opencode turn failed').slice(0, 500);
|
const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'opencode turn failed').slice(0, 500);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,7 @@ const HtmlArtifactStateZ = z.object({
|
|||||||
title: z.string().max(500),
|
title: z.string().max(500),
|
||||||
});
|
});
|
||||||
|
|
||||||
const WorkspacePaneZ = z.object({
|
const PaneKindZ = z.enum([
|
||||||
id: z.string().min(1).max(200),
|
|
||||||
kind: z.enum([
|
|
||||||
'chat',
|
'chat',
|
||||||
'terminal',
|
'terminal',
|
||||||
'coder',
|
'coder',
|
||||||
@@ -39,7 +37,11 @@ const WorkspacePaneZ = z.object({
|
|||||||
'settings',
|
'settings',
|
||||||
'markdown_artifact',
|
'markdown_artifact',
|
||||||
'html_artifact',
|
'html_artifact',
|
||||||
]),
|
]);
|
||||||
|
|
||||||
|
const WorkspacePaneZ = z.object({
|
||||||
|
id: z.string().min(1).max(200),
|
||||||
|
kind: PaneKindZ,
|
||||||
chatId: z.string().min(1).max(200).optional(),
|
chatId: z.string().min(1).max(200).optional(),
|
||||||
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||||
activeChatIdx: z.number().int(),
|
activeChatIdx: z.number().int(),
|
||||||
@@ -47,8 +49,27 @@ const WorkspacePaneZ = z.object({
|
|||||||
html_artifact_state: HtmlArtifactStateZ.optional(),
|
html_artifact_state: HtmlArtifactStateZ.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v2.6.x: workspace_panes column widened from a bare WorkspacePane[] to a
|
||||||
|
// WorkspaceState envelope (panes + stable session-scoped tab numbering +
|
||||||
|
// reopen stack). closedPaneStack entries are lighter than full panes — just
|
||||||
|
// the kind + chat ids needed to recreate a closed pane on reopen.
|
||||||
|
const ClosedPaneEntryZ = z.object({
|
||||||
|
kind: PaneKindZ,
|
||||||
|
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||||
|
activeChatIdx: z.number().int(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const WorkspaceStateZ = z.object({
|
||||||
|
panes: z.array(WorkspacePaneZ).max(10),
|
||||||
|
tabNumbers: z.record(z.string(), z.number().int()).default({}),
|
||||||
|
nextTabNumber: z.number().int().default(1),
|
||||||
|
closedPaneStack: z.array(ClosedPaneEntryZ).max(10).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accept either the legacy bare array OR the envelope. The handler normalizes
|
||||||
|
// to a full envelope before storing (see MIGRATION rule in the PATCH handler).
|
||||||
const WorkspacePanesBody = z.object({
|
const WorkspacePanesBody = z.object({
|
||||||
workspace_panes: z.array(WorkspacePaneZ).max(10),
|
workspace_panes: z.union([z.array(WorkspacePaneZ).max(10), WorkspaceStateZ]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const PatchBody = z.object({
|
const PatchBody = z.object({
|
||||||
@@ -308,12 +329,20 @@ export function registerSessionRoutes(
|
|||||||
reply.code(400);
|
reply.code(400);
|
||||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||||
}
|
}
|
||||||
const workspacePanes = parsed.data.workspace_panes.map((pane) =>
|
// v2.6.x MIGRATION: the body is either a legacy bare WorkspacePane[] or
|
||||||
|
// the WorkspaceState envelope. Normalize to a full envelope so the column
|
||||||
|
// always stores the envelope shape going forward.
|
||||||
|
const body = parsed.data.workspace_panes;
|
||||||
|
const envelope = Array.isArray(body)
|
||||||
|
? { panes: body, tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] }
|
||||||
|
: body;
|
||||||
|
// agent → coder normalization on the panes array (unchanged write rule).
|
||||||
|
envelope.panes = envelope.panes.map((pane) =>
|
||||||
pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane,
|
pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane,
|
||||||
);
|
);
|
||||||
const rows = await sql<Session[]>`
|
const rows = await sql<Session[]>`
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET workspace_panes = ${sql.json(workspacePanes as never)},
|
SET workspace_panes = ${sql.json(envelope as never)},
|
||||||
updated_at = clock_timestamp()
|
updated_at = clock_timestamp()
|
||||||
WHERE id = ${req.params.id}
|
WHERE id = ${req.params.id}
|
||||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||||
@@ -432,16 +461,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.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Agent, Session, ToolCall } from '../../types/api.js';
|
|||||||
import * as modelContext from '../model-context.js';
|
import * as modelContext from '../model-context.js';
|
||||||
import { PathScopeError } from '../path_guard.js';
|
import { PathScopeError } from '../path_guard.js';
|
||||||
import { TOOLS_BY_NAME } from '../tools.js';
|
import { TOOLS_BY_NAME } from '../tools.js';
|
||||||
|
import type { ToolExecCtx } from '../tools.js';
|
||||||
import { matchToolGlob } from '../agents.js';
|
import { matchToolGlob } from '../agents.js';
|
||||||
import { maybeFlagForCompaction } from './payload.js';
|
import { maybeFlagForCompaction } from './payload.js';
|
||||||
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
||||||
@@ -31,6 +32,7 @@ async function executeToolCall(
|
|||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
toolCall: ToolCall,
|
toolCall: ToolCall,
|
||||||
extraRoots: readonly string[],
|
extraRoots: readonly string[],
|
||||||
|
toolCtx?: ToolExecCtx,
|
||||||
): Promise<{ output: unknown; truncated: boolean; error?: string }> {
|
): Promise<{ output: unknown; truncated: boolean; error?: string }> {
|
||||||
const tool = TOOLS_BY_NAME[toolCall.name];
|
const tool = TOOLS_BY_NAME[toolCall.name];
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
@@ -65,7 +67,7 @@ async function executeToolCall(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const output = await tool.execute(parsed.data, projectRoot, extraRoots);
|
const output = await tool.execute(parsed.data, projectRoot, extraRoots, toolCtx);
|
||||||
const truncated =
|
const truncated =
|
||||||
typeof output === 'object' && output !== null && 'truncated' in output
|
typeof output === 'object' && output !== null && 'truncated' in output
|
||||||
? Boolean((output as { truncated: unknown }).truncated)
|
? Boolean((output as { truncated: unknown }).truncated)
|
||||||
@@ -289,7 +291,10 @@ export async function executeToolPhase(
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
|
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths, {
|
||||||
|
sql: ctx.sql,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
||||||
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
||||||
}
|
}
|
||||||
|
|||||||
142
apps/server/src/services/read_tab_by_number.ts
Normal file
142
apps/server/src/services/read_tab_by_number.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||||
import { resolve, basename, relative } from 'node:path';
|
import { resolve, basename, relative } from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
import { pathGuard, PathScopeError } from './path_guard.js';
|
import { pathGuard, PathScopeError } from './path_guard.js';
|
||||||
import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.js';
|
import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.js';
|
||||||
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
||||||
@@ -30,6 +31,9 @@ import {
|
|||||||
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||||
// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
|
// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
|
||||||
import { requestReadAccess } from './request_read_access.js';
|
import { requestReadAccess } from './request_read_access.js';
|
||||||
|
// v2.6.x: read-only tool that reads a tab's transcript by its session-scoped
|
||||||
|
// tab number. Needs DB/session context (ToolExecCtx 4th arg).
|
||||||
|
import { readTabByNumber } from './read_tab_by_number.js';
|
||||||
|
|
||||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
const DEFAULT_VIEW_LINES = 200;
|
const DEFAULT_VIEW_LINES = 200;
|
||||||
@@ -48,6 +52,16 @@ export interface ToolJsonSchema {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2.6.x: optional DB/session context threaded into a tool's execute(). Only
|
||||||
|
// tools that need to read session-scoped DB state (e.g. read_tab_by_number)
|
||||||
|
// use it; every other tool ignores the 4th arg. Kept optional so existing
|
||||||
|
// 3-arg execute() implementations stay assignable (apps/coder consumes this
|
||||||
|
// type from the compiled dist — the optional param keeps it backward-compatible).
|
||||||
|
export interface ToolExecCtx {
|
||||||
|
sql: Sql;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ToolDef<TInput> {
|
export interface ToolDef<TInput> {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -59,7 +73,15 @@ export interface ToolDef<TInput> {
|
|||||||
// view_truncated_output) forward it to pathGuard; other tools accept the
|
// view_truncated_output) forward it to pathGuard; other tools accept the
|
||||||
// arg and ignore it. The execute signature stays compatible with
|
// arg and ignore it. The execute signature stays compatible with
|
||||||
// pre-v1.13.17 callsites because the parameter is optional.
|
// pre-v1.13.17 callsites because the parameter is optional.
|
||||||
execute(input: TInput, projectRoot: string, extraRoots?: readonly string[]): Promise<unknown>;
|
// v2.6.x: optional 4th param toolCtx carries DB/session context for tools
|
||||||
|
// that read session-scoped state (read_tab_by_number). Optional so 3-arg
|
||||||
|
// implementations remain assignable.
|
||||||
|
execute(
|
||||||
|
input: TInput,
|
||||||
|
projectRoot: string,
|
||||||
|
extraRoots?: readonly string[],
|
||||||
|
toolCtx?: ToolExecCtx,
|
||||||
|
): Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ViewFileInput = z.object({
|
const ViewFileInput = z.object({
|
||||||
@@ -694,6 +716,9 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
|
|||||||
// state change is appending to sessions.allowed_read_paths via the
|
// state change is appending to sessions.allowed_read_paths via the
|
||||||
// grant endpoint, gated by user consent.
|
// grant endpoint, gated by user consent.
|
||||||
requestReadAccess as ToolDef<unknown>,
|
requestReadAccess as ToolDef<unknown>,
|
||||||
|
// v2.6.x: read a tab's transcript by its session-scoped tab number.
|
||||||
|
// Read-only; uses the ToolExecCtx 4th arg for DB/session access.
|
||||||
|
readTabByNumber as ToolDef<unknown>,
|
||||||
].sort((a, b) => a.name.localeCompare(b.name));
|
].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
||||||
@@ -734,6 +759,9 @@ export const READ_ONLY_TOOL_NAMES = [
|
|||||||
// state directly (the grant endpoint appends to sessions.allowed_read_paths
|
// state directly (the grant endpoint appends to sessions.allowed_read_paths
|
||||||
// only with user consent). Belongs in the read-only budget tier.
|
// only with user consent). Belongs in the read-only budget tier.
|
||||||
'request_read_access',
|
'request_read_access',
|
||||||
|
// v2.6.x: reads a tab's transcript from session-scoped DB state; never
|
||||||
|
// writes. Belongs in the read-only budget tier.
|
||||||
|
'read_tab_by_number',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||||
|
|||||||
@@ -203,7 +203,12 @@ export const SessionDeletedFrame = z.object({
|
|||||||
export const SessionWorkspaceUpdatedFrame = z.object({
|
export const SessionWorkspaceUpdatedFrame = z.object({
|
||||||
type: z.literal('session_workspace_updated'),
|
type: z.literal('session_workspace_updated'),
|
||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
workspace_panes: z.array(OpaqueObject),
|
// v2.6.x: widened from z.array — the payload is now either the legacy bare
|
||||||
|
// WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
|
||||||
|
// nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
|
||||||
|
// every envelope frame at validation. MUST be mirrored in the server's
|
||||||
|
// byte-identical copy (parity test).
|
||||||
|
workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ChatCreatedFrame = z.object({
|
export const ChatCreatedFrame = z.object({
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type {
|
|||||||
CoderTaskDetail,
|
CoderTaskDetail,
|
||||||
PermissionPrompt,
|
PermissionPrompt,
|
||||||
AgentCommand,
|
AgentCommand,
|
||||||
|
WorkspaceState,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
@@ -175,10 +176,10 @@ export const api = {
|
|||||||
),
|
),
|
||||||
openChatsCount: (id: string) =>
|
openChatsCount: (id: string) =>
|
||||||
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
|
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
|
||||||
updateWorkspacePanes: (id: string, panes: Session['workspace_panes']) =>
|
updateWorkspacePanes: (id: string, state: WorkspaceState) =>
|
||||||
request<Session>(`/api/sessions/${id}/workspace`, {
|
request<Session>(`/api/sessions/${id}/workspace`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ workspace_panes: panes }),
|
body: JSON.stringify({ workspace_panes: state }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -354,6 +355,10 @@ export const api = {
|
|||||||
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
|
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
|
||||||
getTask: (taskId: string) =>
|
getTask: (taskId: string) =>
|
||||||
request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`),
|
request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`),
|
||||||
|
// Cancel a pending/running coder task (cancels permission wait + inference;
|
||||||
|
// server sets state='cancelled'). Used by CoderPane's stop button.
|
||||||
|
cancelTask: (taskId: string) =>
|
||||||
|
request<{ cancelled: boolean }>(`/api/coder/tasks/${taskId}/cancel`, { method: 'POST' }),
|
||||||
listMessages: (sessionId: string, chatId?: string) =>
|
listMessages: (sessionId: string, chatId?: string) =>
|
||||||
request<CoderMessageWire[]>(
|
request<CoderMessageWire[]>(
|
||||||
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ export interface Session {
|
|||||||
// v1.9: null = inherit from project.default_web_search_enabled.
|
// v1.9: null = inherit from project.default_web_search_enabled.
|
||||||
web_search_enabled: boolean | null;
|
web_search_enabled: boolean | null;
|
||||||
// v1.12.1: server-authoritative pane layout, replaces localStorage.
|
// v1.12.1: server-authoritative pane layout, replaces localStorage.
|
||||||
workspace_panes: WorkspacePane[];
|
// A value may be the legacy bare WorkspacePane[] (older rows) OR the new
|
||||||
|
// WorkspaceState envelope (panes + tab numbering + reopen stack). Normalize
|
||||||
|
// on read via useWorkspacePanes' toWorkspaceState.
|
||||||
|
workspace_panes: WorkspacePane[] | WorkspaceState;
|
||||||
// v1.13.17: paths the agent has been granted read access to via the
|
// v1.13.17: paths the agent has been granted read access to via the
|
||||||
// request_read_access tool. Empty by default. Settings UI surfaces the
|
// request_read_access tool. Empty by default. Settings UI surfaces the
|
||||||
// list with per-row revoke; the grant flow itself appends through the
|
// list with per-row revoke; the grant flow itself appends through the
|
||||||
@@ -511,6 +514,30 @@ export interface WorkspacePane {
|
|||||||
html_artifact_state?: HtmlArtifactState;
|
html_artifact_state?: HtmlArtifactState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reopen LIFO stack entry. Shape unchanged from the prior module-level stack;
|
||||||
|
// now persisted inside the WorkspaceState envelope so the reopen-pane stack
|
||||||
|
// survives a reload / cross-device sync.
|
||||||
|
export interface ClosedPaneEntry {
|
||||||
|
kind: WorkspacePane['kind'];
|
||||||
|
chatIds: string[];
|
||||||
|
activeChatIdx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envelope persisted to sessions.workspace_panes. Supersedes the bare
|
||||||
|
// WorkspacePane[] shape (still accepted on read for legacy rows — see the
|
||||||
|
// migration in useWorkspacePanes.toWorkspaceState). The server accepts either
|
||||||
|
// shape; the frontend always emits this envelope going forward.
|
||||||
|
export interface WorkspaceState {
|
||||||
|
panes: WorkspacePane[];
|
||||||
|
// Stable, session-scoped tab number per chat id. Numbers only ever increase
|
||||||
|
// and are never reused (retired entries are pruned on tab close).
|
||||||
|
tabNumbers: { [chatId: string]: number };
|
||||||
|
// Next number to hand out; starts at 1; ONLY increments.
|
||||||
|
nextTabNumber: number;
|
||||||
|
// Reopen LIFO stack, max 10, most-recent last.
|
||||||
|
closedPaneStack: ClosedPaneEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export type WsFrame =
|
export type WsFrame =
|
||||||
| { type: 'snapshot'; messages: Message[] }
|
| { type: 'snapshot'; messages: Message[] }
|
||||||
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole }
|
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole }
|
||||||
|
|||||||
@@ -203,7 +203,12 @@ export const SessionDeletedFrame = z.object({
|
|||||||
export const SessionWorkspaceUpdatedFrame = z.object({
|
export const SessionWorkspaceUpdatedFrame = z.object({
|
||||||
type: z.literal('session_workspace_updated'),
|
type: z.literal('session_workspace_updated'),
|
||||||
session_id: Uuid,
|
session_id: Uuid,
|
||||||
workspace_panes: z.array(OpaqueObject),
|
// v2.6.x: widened from z.array — the payload is now either the legacy bare
|
||||||
|
// WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
|
||||||
|
// nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
|
||||||
|
// every envelope frame at validation. MUST be mirrored in the server's
|
||||||
|
// byte-identical copy (parity test).
|
||||||
|
workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ChatCreatedFrame = z.object({
|
export const ChatCreatedFrame = z.object({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
||||||
import { Check, Plus, Send } from 'lucide-react';
|
import { Check, ListPlus, Plus, Send, Square } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -51,6 +51,11 @@ interface Props {
|
|||||||
webSearchEnabled?: boolean | null;
|
webSearchEnabled?: boolean | null;
|
||||||
onSend: (content: string) => void | Promise<void>;
|
onSend: (content: string) => void | Promise<void>;
|
||||||
onForceSend?: (content: string) => void | Promise<void>;
|
onForceSend?: (content: string) => void | Promise<void>;
|
||||||
|
// When the assistant/agent is generating, the send button morphs: empty draft
|
||||||
|
// → Stop (calls onStop); non-empty draft → Queue (submits, which the caller
|
||||||
|
// queues while busy). Omitting onStop falls back to a (disabled) Send button.
|
||||||
|
generating?: boolean;
|
||||||
|
onStop?: () => void | Promise<void>;
|
||||||
// Batch 9.6: slash-command dispatch. When the input parses to a known skill,
|
// Batch 9.6: slash-command dispatch. When the input parses to a known skill,
|
||||||
// ChatInput calls this with the skill name + the post-name args (possibly
|
// ChatInput calls this with the skill name + the post-name args (possibly
|
||||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||||
@@ -78,7 +83,7 @@ interface Props {
|
|||||||
modelContextLimit?: number | null;
|
modelContextLimit?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -651,14 +656,38 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
rows={3}
|
rows={3}
|
||||||
className="resize-none min-h-[68px] max-h-[240px]"
|
className="resize-none min-h-[68px] max-h-[240px]"
|
||||||
/>
|
/>
|
||||||
|
{(() => {
|
||||||
|
const hasContent = value.trim().length > 0 || attachments.length > 0;
|
||||||
|
// While generating with an empty draft, the button stops generation.
|
||||||
|
if (generating && onStop && !hasContent) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={() => void onStop()}
|
||||||
|
size="icon-lg"
|
||||||
|
variant="outline"
|
||||||
|
aria-label="Stop generating"
|
||||||
|
title="Stop generating"
|
||||||
|
>
|
||||||
|
<Square className="fill-current size-3.5" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// With a draft, submit. While generating the caller queues it, so the
|
||||||
|
// button reads as Queue; otherwise it's a normal Send.
|
||||||
|
const queueing = !!generating && hasContent;
|
||||||
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => void submit()}
|
onClick={() => void submit()}
|
||||||
disabled={disabled || busy || (!value.trim() && attachments.length === 0)}
|
disabled={disabled || busy || !hasContent}
|
||||||
size="icon-lg"
|
size="icon-lg"
|
||||||
aria-label="Send"
|
variant={queueing ? 'secondary' : 'default'}
|
||||||
|
aria-label={queueing ? 'Queue message' : 'Send'}
|
||||||
|
title={queueing ? 'Queue message' : 'Send'}
|
||||||
>
|
>
|
||||||
<Send />
|
{queueing ? <ListPlus /> : <Send />}
|
||||||
</Button>
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AttachmentPreviewModal
|
<AttachmentPreviewModal
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { useLongPress } from '@/hooks/useLongPress';
|
import { useLongPress } from '@/hooks/useLongPress';
|
||||||
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pane: WorkspacePane;
|
pane: WorkspacePane;
|
||||||
tabs: Chat[];
|
tabs: Chat[];
|
||||||
|
// v2.6.x (Batch 3a): stable session-scoped tab number per chat id. Keyed by
|
||||||
|
// chat.id, NEVER by tab position.
|
||||||
|
tabNumbers: Record<string, number>;
|
||||||
onSwitchTab: (tabIdx: number) => void;
|
onSwitchTab: (tabIdx: number) => void;
|
||||||
onRemoveTab: (chatId: string) => void;
|
onRemoveTab: (chatId: string) => void;
|
||||||
onCloseOthers: (chatId: string) => void;
|
onCloseOthers: (chatId: string) => void;
|
||||||
@@ -37,6 +41,7 @@ interface Props {
|
|||||||
export function ChatTabBar({
|
export function ChatTabBar({
|
||||||
pane,
|
pane,
|
||||||
tabs,
|
tabs,
|
||||||
|
tabNumbers,
|
||||||
onSwitchTab,
|
onSwitchTab,
|
||||||
onRemoveTab,
|
onRemoveTab,
|
||||||
onCloseOthers,
|
onCloseOthers,
|
||||||
@@ -83,6 +88,9 @@ export function ChatTabBar({
|
|||||||
const isLast = tabIdx === tabs.length - 1;
|
const isLast = tabIdx === tabs.length - 1;
|
||||||
const onlyTab = tabs.length === 1;
|
const onlyTab = tabs.length === 1;
|
||||||
const label = chat.name ?? 'New chat';
|
const label = chat.name ?? 'New chat';
|
||||||
|
// v2.6.x: stable tab number keyed by chat.id (NOT tab position).
|
||||||
|
// Omit gracefully when not yet assigned.
|
||||||
|
const tabNumber = tabNumbers[chat.id];
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={chat.id}>
|
<ContextMenu key={chat.id}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
@@ -117,8 +125,11 @@ export function ChatTabBar({
|
|||||||
className="bg-transparent border-b border-border text-xs outline-none w-28"
|
className="bg-transparent border-b border-border text-xs outline-none w-28"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="truncate max-w-[140px]" title={label}>
|
<span
|
||||||
{label}
|
className="truncate max-w-[140px]"
|
||||||
|
title={tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
|
||||||
|
>
|
||||||
|
{tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -138,6 +149,13 @@ export function ChatTabBar({
|
|||||||
<ContextMenuItem onSelect={onNewTab}>
|
<ContextMenuItem onSelect={onNewTab}>
|
||||||
New chat
|
New chat
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onSelect={() =>
|
||||||
|
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open in new pane
|
||||||
|
</ContextMenuItem>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
||||||
Rename
|
Rename
|
||||||
@@ -174,15 +192,31 @@ export function ChatTabBar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
|
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onNewTab}
|
|
||||||
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
aria-label="New tab"
|
aria-label="New chat, terminal, or coder"
|
||||||
title="New tab"
|
title="New chat / terminal / coder"
|
||||||
>
|
>
|
||||||
<Plus size={12} />
|
<Plus size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-fit">
|
||||||
|
{/* New BooChat opens a tab in THIS pane; terminal/coder can't be
|
||||||
|
tabs, so they split into a new pane (matches the Split menu). */}
|
||||||
|
<DropdownMenuItem onSelect={onNewTab}>
|
||||||
|
<MessageSquare size={14} /> New BooChat
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
|
||||||
|
<Terminal size={14} /> New BooTerm
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
|
||||||
|
<Code size={14} /> New BooCode
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen, Brain } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||||
import { api, ApiError } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||||
import { CapHitSentinel } from './CapHitSentinel';
|
import { CapHitSentinel } from './CapHitSentinel';
|
||||||
@@ -105,18 +105,6 @@ const ERROR_REASON_LABELS: Record<ErrorReason, string> = {
|
|||||||
// moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact
|
// moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact
|
||||||
// panes can render assistant content with the same Shiki + remark-gfm setup.
|
// panes can render assistant content with the same Shiki + remark-gfm setup.
|
||||||
|
|
||||||
// Pane-header title derivation for a markdown artifact. Order matches the
|
|
||||||
// server slug logic in services/artifacts.ts: first `# ` heading → first 6
|
|
||||||
// words of the body → 'Markdown artifact'. Truncated to keep the pane header
|
|
||||||
// readable.
|
|
||||||
function deriveMarkdownTitle(content: string): string {
|
|
||||||
const headingMatch = content.match(/^\s*#\s+(.+?)\s*$/m);
|
|
||||||
if (headingMatch && headingMatch[1]) return headingMatch[1].slice(0, 80);
|
|
||||||
const words = content.trim().split(/\s+/).slice(0, 6).join(' ');
|
|
||||||
if (words) return words.slice(0, 80);
|
|
||||||
return 'Markdown artifact';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessageActions {
|
export interface MessageActions {
|
||||||
onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
|
onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
|
||||||
onResend?: (chatId: string, content: string) => Promise<void>;
|
onResend?: (chatId: string, content: string) => Promise<void>;
|
||||||
@@ -129,8 +117,8 @@ interface Props {
|
|||||||
sessionChats?: Chat[];
|
sessionChats?: Chat[];
|
||||||
capHitInfo?: { position: number; isLatest: boolean };
|
capHitInfo?: { position: number; isLatest: boolean };
|
||||||
actions?: MessageActions;
|
actions?: MessageActions;
|
||||||
/** Hide actions that don't apply (fork, delete, open-in-pane). */
|
/** Hide actions that don't apply (fork, delete). */
|
||||||
hideActions?: ('fork' | 'delete' | 'openInPane')[];
|
hideActions?: ('fork' | 'delete')[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatsLine({ message }: { message: Message }) {
|
function StatsLine({ message }: { message: Message }) {
|
||||||
@@ -226,7 +214,7 @@ function ActionRow({
|
|||||||
} else {
|
} else {
|
||||||
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
||||||
sessionEvents.emit({ type: 'refetch_messages' });
|
sessionEvents.emit({ type: 'refetch_messages' });
|
||||||
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
|
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : 'fork failed');
|
toast.error(err instanceof Error ? err.message : 'fork failed');
|
||||||
@@ -258,54 +246,6 @@ function ActionRow({
|
|||||||
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
||||||
const canFork = message.status === 'complete';
|
const canFork = message.status === 'complete';
|
||||||
const canDelete = message.status !== 'streaming';
|
const canDelete = message.status !== 'streaming';
|
||||||
const [openingPane, setOpeningPane] = useState(false);
|
|
||||||
|
|
||||||
// v1.14.x-html-artifact-panes: probe for an html_artifact part. If present,
|
|
||||||
// open the HTML pane variant; otherwise fall back to the markdown variant.
|
|
||||||
// Title derivation for markdown: first `# ` heading → first 6 words of the
|
|
||||||
// body → 'Markdown artifact' (mirrors the slug logic in
|
|
||||||
// services/artifacts.ts).
|
|
||||||
async function openInPane() {
|
|
||||||
if (openingPane || message.status === 'streaming') return;
|
|
||||||
setOpeningPane(true);
|
|
||||||
try {
|
|
||||||
try {
|
|
||||||
const payload = await api.messages.getHtmlArtifact(
|
|
||||||
message.chat_id,
|
|
||||||
message.id,
|
|
||||||
);
|
|
||||||
sessionEvents.emit({
|
|
||||||
type: 'open_html_artifact_pane',
|
|
||||||
state: {
|
|
||||||
chat_id: message.chat_id,
|
|
||||||
message_id: message.id,
|
|
||||||
title: payload.title,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
// 404 (no html_artifact part) is the expected fall-through path —
|
|
||||||
// markdown variant opens below. Any other error (network, 500) is
|
|
||||||
// a real failure; toast and bail rather than masquerading as markdown.
|
|
||||||
const status = err instanceof ApiError ? err.status : null;
|
|
||||||
if (status !== 404) {
|
|
||||||
toast.error(err instanceof Error ? err.message : 'open in pane failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const title = deriveMarkdownTitle(message.content);
|
|
||||||
sessionEvents.emit({
|
|
||||||
type: 'open_markdown_artifact_pane',
|
|
||||||
state: {
|
|
||||||
chat_id: message.chat_id,
|
|
||||||
message_id: message.id,
|
|
||||||
title,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setOpeningPane(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -330,18 +270,6 @@ function ActionRow({
|
|||||||
<RefreshCw className="size-3" />
|
<RefreshCw className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isAssistant && !hiddenSet.has('openInPane') && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void openInPane()}
|
|
||||||
disabled={openingPane || message.status === 'streaming'}
|
|
||||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
||||||
aria-label="Open in pane"
|
|
||||||
title="Open in pane"
|
|
||||||
>
|
|
||||||
<PanelRightOpen className="size-3" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isAssistant && (
|
{isAssistant && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Archive, MessageSquare, RotateCcw } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import type { Chat } from '@/api/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -13,6 +16,30 @@ interface Props {
|
|||||||
// the skill — same transition the text send uses. See useSessionChats.
|
// the skill — same transition the text send uses. See useSessionChats.
|
||||||
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
|
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
|
||||||
createChat: () => Promise<{ id: string }>;
|
createChat: () => Promise<{ id: string }>;
|
||||||
|
// Session history: the session's open chats (live), and callbacks to open one
|
||||||
|
// in THIS pane / restore an archived one. Archived chats are fetched here
|
||||||
|
// (the default open-only list excludes them).
|
||||||
|
chats: Chat[];
|
||||||
|
onOpenChat: (chatId: string) => void;
|
||||||
|
onUnarchiveChat: (chatId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(iso: string): string {
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(then)) return '';
|
||||||
|
const s = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||||
|
if (s < 60) return 'just now';
|
||||||
|
const m = Math.round(s / 60);
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.round(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
const d = Math.round(h / 24);
|
||||||
|
if (d < 7) return `${d}d ago`;
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function byRecent(a: Chat, b: Chat): number {
|
||||||
|
return (b.updated_at ?? '').localeCompare(a.updated_at ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionLandingPage({
|
export function SessionLandingPage({
|
||||||
@@ -23,8 +50,24 @@ export function SessionLandingPage({
|
|||||||
onSend,
|
onSend,
|
||||||
onSkillInvoke,
|
onSkillInvoke,
|
||||||
createChat,
|
createChat,
|
||||||
|
chats,
|
||||||
|
onOpenChat,
|
||||||
|
onUnarchiveChat,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [chatId, setChatId] = useState<string | null>(null);
|
const [chatId, setChatId] = useState<string | null>(null);
|
||||||
|
const [archived, setArchived] = useState<Chat[]>([]);
|
||||||
|
|
||||||
|
// Archived chats aren't in the default (open-only) list, so fetch them. One
|
||||||
|
// shot on session change — the history view is transient (pick a chat and
|
||||||
|
// it's gone), so slight staleness is fine; reopening the pane refetches.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api.chats
|
||||||
|
.listForSession(sessionId, { status: 'archived' })
|
||||||
|
.then((list) => { if (!cancelled) setArchived(list); })
|
||||||
|
.catch(() => {});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
const ensureChat = useCallback(async (): Promise<string> => {
|
const ensureChat = useCallback(async (): Promise<string> => {
|
||||||
if (chatId) return chatId;
|
if (chatId) return chatId;
|
||||||
@@ -57,12 +100,87 @@ export function SessionLandingPage({
|
|||||||
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
|
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
|
||||||
}, [onSkillInvoke]);
|
}, [onSkillInvoke]);
|
||||||
|
|
||||||
|
const restoreAndOpen = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await onUnarchiveChat(id);
|
||||||
|
onOpenChat(id);
|
||||||
|
} catch {
|
||||||
|
// onUnarchiveChat surfaces its own toast.
|
||||||
|
}
|
||||||
|
}, [onUnarchiveChat, onOpenChat]);
|
||||||
|
|
||||||
|
const openChats = [...chats.filter((c) => c.status === 'open')].sort(byRecent);
|
||||||
|
const openIds = new Set(openChats.map((c) => c.id));
|
||||||
|
const archivedChats = archived.filter((c) => !openIds.has(c.id)).sort(byRecent);
|
||||||
|
const isEmpty = openChats.length === 0 && archivedChats.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
<div className="flex-1 flex items-center justify-center px-6">
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="max-w-[760px] mx-auto w-full px-4 py-4">
|
||||||
Send a message to start.
|
{isEmpty ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">
|
||||||
|
No conversations yet. Send a message to start.
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{openChats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground px-1 mb-1.5">
|
||||||
|
Conversations
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0.5 mb-4">
|
||||||
|
{openChats.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenChat(c.id)}
|
||||||
|
className="w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm max-md:min-h-[44px]"
|
||||||
|
>
|
||||||
|
<MessageSquare size={14} className="shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate shrink-0 max-w-[45%]">{c.name ?? 'New chat'}</span>
|
||||||
|
{c.last_message_preview && (
|
||||||
|
<span className="truncate flex-1 text-xs text-muted-foreground hidden sm:block">
|
||||||
|
{c.last_message_preview}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="shrink-0 ml-auto text-xs text-muted-foreground">
|
||||||
|
{formatRelative(c.updated_at)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{archivedChats.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground px-1 mb-1.5">
|
||||||
|
Archived
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{archivedChats.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void restoreAndOpen(c.id)}
|
||||||
|
title="Restore and open"
|
||||||
|
className="group/arch w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm text-muted-foreground max-md:min-h-[44px]"
|
||||||
|
>
|
||||||
|
<Archive size={14} className="shrink-0" />
|
||||||
|
<span className="truncate flex-1">{c.name ?? 'New chat'}</span>
|
||||||
|
<span className="shrink-0 text-xs">{formatRelative(c.updated_at)}</span>
|
||||||
|
<RotateCcw
|
||||||
|
size={13}
|
||||||
|
className="shrink-0 opacity-0 group-hover/arch:opacity-100"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChatInput
|
<ChatInput
|
||||||
disabled={false}
|
disabled={false}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function Workspace({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const {
|
const {
|
||||||
panes,
|
panes,
|
||||||
|
tabNumbers,
|
||||||
activePaneIdx,
|
activePaneIdx,
|
||||||
setActivePaneIdx,
|
setActivePaneIdx,
|
||||||
openChatInPane,
|
openChatInPane,
|
||||||
@@ -204,6 +205,7 @@ export function Workspace({
|
|||||||
<ChatTabBar
|
<ChatTabBar
|
||||||
pane={pane}
|
pane={pane}
|
||||||
tabs={chatsForPane(pane)}
|
tabs={chatsForPane(pane)}
|
||||||
|
tabNumbers={tabNumbers}
|
||||||
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
||||||
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
||||||
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
||||||
@@ -390,6 +392,9 @@ export function Workspace({
|
|||||||
createChat={() => api.chats.create(sessionId)}
|
createChat={() => api.chats.create(sessionId)}
|
||||||
onSend={(content) => void handleLandingSend(idx, content)}
|
onSend={(content) => void handleLandingSend(idx, content)}
|
||||||
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
|
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
|
||||||
|
chats={chats}
|
||||||
|
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
||||||
|
onUnarchiveChat={unarchiveChat}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Pencil, Send, Square, X } from 'lucide-react';
|
import { Pencil, Send, X } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import { useSessionStream } from '@/hooks/useSessionStream';
|
import { useSessionStream } from '@/hooks/useSessionStream';
|
||||||
@@ -248,22 +248,6 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stop button when streaming */}
|
|
||||||
{streaming && (
|
|
||||||
<div className="border-t py-1">
|
|
||||||
<div className="max-w-[1000px] mx-auto w-full flex justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleStop()}
|
|
||||||
className="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border hover:bg-muted text-muted-foreground hover:text-foreground max-md:min-h-[44px] max-md:px-5"
|
|
||||||
>
|
|
||||||
<Square size={10} className="fill-current" />
|
|
||||||
Stop generating
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stale && streamingId && (
|
{stale && streamingId && (
|
||||||
<StaleStreamBanner
|
<StaleStreamBanner
|
||||||
onRetry={() => void handleRetryStale()}
|
onRetry={() => void handleRetryStale()}
|
||||||
@@ -280,6 +264,8 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
|||||||
webSearchEnabled={webSearchEnabled}
|
webSearchEnabled={webSearchEnabled}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onForceSend={streaming ? handleForceSend : undefined}
|
onForceSend={streaming ? handleForceSend : undefined}
|
||||||
|
generating={streaming}
|
||||||
|
onStop={handleStop}
|
||||||
onSlashCommand={handleSlashCommand}
|
onSlashCommand={handleSlashCommand}
|
||||||
chatId={chatId}
|
chatId={chatId}
|
||||||
chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'}
|
chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'}
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ interface Props {
|
|||||||
actions?: MessageActions;
|
actions?: MessageActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete' | 'openInPane')[] = ['fork', 'openInPane'];
|
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete')[] = ['fork'];
|
||||||
|
|
||||||
export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
|
export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
|
||||||
const endRef = useRef<HTMLDivElement>(null);
|
const endRef = useRef<HTMLDivElement>(null);
|
||||||
|
|||||||
@@ -581,6 +581,10 @@ export function CoderPane({
|
|||||||
const [queue, setQueue] = useState<string[]>([]);
|
const [queue, setQueue] = useState<string[]>([]);
|
||||||
const queueProcessing = useRef(false);
|
const queueProcessing = useRef(false);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
// The agent is "generating" during the dispatch POST (sending) AND while its
|
||||||
|
// task runs (activeTaskId). sending alone is too brief — it clears the moment
|
||||||
|
// dispatch returns — so queueing/stop must key on this combined signal.
|
||||||
|
const generating = sending || activeTaskId !== null;
|
||||||
|
|
||||||
// Refresh pending changes when a message_complete arrives
|
// Refresh pending changes when a message_complete arrives
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -760,24 +764,35 @@ export function CoderPane({
|
|||||||
}
|
}
|
||||||
}, [sessionId, paneId, chatId, agentConfig, setMessages]);
|
}, [sessionId, paneId, chatId, agentConfig, setMessages]);
|
||||||
|
|
||||||
// Drain queue when not busy
|
// Drain queue once the agent is idle (not just past the dispatch POST).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sending || queue.length === 0 || queueProcessing.current) return;
|
if (generating || queue.length === 0 || queueProcessing.current) return;
|
||||||
queueProcessing.current = true;
|
queueProcessing.current = true;
|
||||||
const next = queue[0]!;
|
const next = queue[0]!;
|
||||||
setQueue((prev) => prev.slice(1));
|
setQueue((prev) => prev.slice(1));
|
||||||
sendOneMessage(next).finally(() => { queueProcessing.current = false; });
|
sendOneMessage(next).finally(() => { queueProcessing.current = false; });
|
||||||
}, [sending, queue, sendOneMessage]);
|
}, [generating, queue, sendOneMessage]);
|
||||||
|
|
||||||
const handleChatInputSend = useCallback(async (content: string) => {
|
const handleChatInputSend = useCallback(async (content: string) => {
|
||||||
const text = content.trim();
|
const text = content.trim();
|
||||||
if (!text || !chatId) return;
|
if (!text || !chatId) return;
|
||||||
if (sending) {
|
if (generating) {
|
||||||
setQueue((prev) => [...prev, text]);
|
setQueue((prev) => [...prev, text]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await sendOneMessage(text);
|
await sendOneMessage(text);
|
||||||
}, [sending, chatId, sendOneMessage]);
|
}, [generating, chatId, sendOneMessage]);
|
||||||
|
|
||||||
|
const handleStop = useCallback(async () => {
|
||||||
|
const taskId = activeTaskId;
|
||||||
|
if (!taskId) return;
|
||||||
|
try {
|
||||||
|
await api.coder.cancelTask(taskId);
|
||||||
|
setActiveTaskId(null); // optimistic; WS/poll terminal-state also clears it
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'stop failed');
|
||||||
|
}
|
||||||
|
}, [activeTaskId]);
|
||||||
|
|
||||||
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
@@ -867,9 +882,11 @@ export function CoderPane({
|
|||||||
{/* Composer + input */}
|
{/* Composer + input */}
|
||||||
<div className="shrink-0 border-t border-border">
|
<div className="shrink-0 border-t border-border">
|
||||||
<ChatInput
|
<ChatInput
|
||||||
disabled={sending || !chatId || chatPending}
|
disabled={!chatId || chatPending}
|
||||||
projectId={projectPath ?? ''}
|
projectId={projectPath ?? ''}
|
||||||
onSend={handleChatInputSend}
|
onSend={handleChatInputSend}
|
||||||
|
generating={generating}
|
||||||
|
onStop={handleStop}
|
||||||
onSlashCommand={handleChatInputSlash}
|
onSlashCommand={handleChatInputSlash}
|
||||||
slashGroups={slashGroups}
|
slashGroups={slashGroups}
|
||||||
chatId={chatId ?? undefined}
|
chatId={chatId ?? undefined}
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ export interface SessionUpdatedEvent {
|
|||||||
export interface SessionWorkspaceUpdatedEvent {
|
export interface SessionWorkspaceUpdatedEvent {
|
||||||
type: 'session_workspace_updated';
|
type: 'session_workspace_updated';
|
||||||
session_id: string;
|
session_id: string;
|
||||||
workspace_panes: import('@/api/types').WorkspacePane[];
|
// Legacy bare array OR the new envelope — useWorkspacePanes normalizes both
|
||||||
|
// via toWorkspaceState.
|
||||||
|
workspace_panes:
|
||||||
|
| import('@/api/types').WorkspacePane[]
|
||||||
|
| import('@/api/types').WorkspaceState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionLoadedEvent {
|
export interface SessionLoadedEvent {
|
||||||
@@ -75,6 +79,14 @@ export interface OpenChatInActivePaneEvent {
|
|||||||
chat_id: string;
|
chat_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open a whole chat in a fresh split pane (vs the active pane). Emitted by the
|
||||||
|
// ChatTabBar tab context menu ("Open in new pane") and by MessageBubble.fork()
|
||||||
|
// so a fork lands beside the original. useWorkspacePanes subscribes.
|
||||||
|
export interface OpenChatInNewPaneEvent {
|
||||||
|
type: 'open_chat_in_new_pane';
|
||||||
|
chat_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of
|
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of
|
||||||
// these; useWorkspacePanes subscribes and inserts the corresponding artifact
|
// these; useWorkspacePanes subscribes and inserts the corresponding artifact
|
||||||
// pane (or focuses an existing one keyed by message_id).
|
// pane (or focuses an existing one keyed by message_id).
|
||||||
@@ -178,6 +190,7 @@ export type SessionEvent =
|
|||||||
| OpenFileInBrowserEvent
|
| OpenFileInBrowserEvent
|
||||||
| AttachChatFileEvent
|
| AttachChatFileEvent
|
||||||
| OpenChatInActivePaneEvent
|
| OpenChatInActivePaneEvent
|
||||||
|
| OpenChatInNewPaneEvent
|
||||||
| OpenMarkdownArtifactPaneEvent
|
| OpenMarkdownArtifactPaneEvent
|
||||||
| OpenHtmlArtifactPaneEvent
|
| OpenHtmlArtifactPaneEvent
|
||||||
| OpenSettingsPaneEvent
|
| OpenSettingsPaneEvent
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
|||||||
case 'attach_chat_file':
|
case 'attach_chat_file':
|
||||||
return prev;
|
return prev;
|
||||||
case 'open_chat_in_active_pane':
|
case 'open_chat_in_active_pane':
|
||||||
|
case 'open_chat_in_new_pane':
|
||||||
// Consumed by Workspace; sidebar has no business with pane state.
|
// Consumed by Workspace; sidebar has no business with pane state.
|
||||||
return prev;
|
return prev;
|
||||||
case 'open_markdown_artifact_pane':
|
case 'open_markdown_artifact_pane':
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import type { DragEvent } from 'react';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type {
|
import type {
|
||||||
|
ClosedPaneEntry,
|
||||||
HtmlArtifactState,
|
HtmlArtifactState,
|
||||||
MarkdownArtifactState,
|
MarkdownArtifactState,
|
||||||
WorkspacePane,
|
WorkspacePane,
|
||||||
|
WorkspaceState,
|
||||||
} from '@/api/types';
|
} from '@/api/types';
|
||||||
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||||
@@ -32,19 +34,35 @@ function chatPane(chatId: string): WorkspacePane {
|
|||||||
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClosedPaneEntry {
|
// v2.6.x: reopen stack cap. The stack now lives in React state (persisted in
|
||||||
kind: WorkspacePane['kind'];
|
// the WorkspaceState envelope), not a module-level array. `appendClosed` is the
|
||||||
chatIds: string[];
|
// pure state-updater helper.
|
||||||
activeChatIdx: number;
|
|
||||||
}
|
|
||||||
const MAX_CLOSED = 10;
|
const MAX_CLOSED = 10;
|
||||||
const closedPaneStack: ClosedPaneEntry[] = [];
|
|
||||||
|
|
||||||
function pushClosed(pane: WorkspacePane): void {
|
// Pure helper: append a closed-pane entry derived from `pane` to `stack`,
|
||||||
if (pane.kind === 'empty' || pane.kind === 'settings') return;
|
// capped at MAX_CLOSED (most-recent last). Returns the SAME reference when the
|
||||||
if (pane.chatIds.length === 0) return;
|
// pane is not eligible (empty/settings/no chats) so callers can skip setState.
|
||||||
closedPaneStack.push({ kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx });
|
function appendClosed(stack: ClosedPaneEntry[], pane: WorkspacePane): ClosedPaneEntry[] {
|
||||||
if (closedPaneStack.length > MAX_CLOSED) closedPaneStack.shift();
|
if (pane.kind === 'empty' || pane.kind === 'settings') return stack;
|
||||||
|
if (pane.chatIds.length === 0) return stack;
|
||||||
|
const entry = { kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx };
|
||||||
|
// Dedupe a value-identical top entry. This is called via setClosedPaneStack
|
||||||
|
// inside the setPanes updater in removePane; React StrictMode double-invokes
|
||||||
|
// that updater in dev, which would otherwise push two identical entries.
|
||||||
|
// Real closes never collide (one chat lives in at most one pane).
|
||||||
|
const top = stack[stack.length - 1];
|
||||||
|
if (
|
||||||
|
top &&
|
||||||
|
top.kind === entry.kind &&
|
||||||
|
top.activeChatIdx === entry.activeChatIdx &&
|
||||||
|
top.chatIds.length === entry.chatIds.length &&
|
||||||
|
top.chatIds.every((id, i) => id === entry.chatIds[i])
|
||||||
|
) {
|
||||||
|
return stack;
|
||||||
|
}
|
||||||
|
const next = [...stack, entry];
|
||||||
|
if (next.length > MAX_CLOSED) next.splice(0, next.length - MAX_CLOSED);
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
|
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
|
||||||
@@ -110,6 +128,26 @@ function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
|
|||||||
return normalizePanes(panes).filter((p) => p.kind !== 'settings');
|
return normalizePanes(panes).filter((p) => p.kind !== 'settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2.6.x: LOCKED migration — a value read from session.workspace_panes (or the
|
||||||
|
// session_workspace_updated frame) may be EITHER the legacy bare
|
||||||
|
// WorkspacePane[] OR the new WorkspaceState envelope. Normalize to the
|
||||||
|
// envelope. Must match the server's normalization byte-for-byte.
|
||||||
|
function toWorkspaceState(raw: unknown): WorkspaceState {
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return { panes: raw as WorkspacePane[], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
|
||||||
|
}
|
||||||
|
if (raw && typeof raw === 'object' && Array.isArray((raw as WorkspaceState).panes)) {
|
||||||
|
const env = raw as WorkspaceState;
|
||||||
|
return {
|
||||||
|
panes: env.panes,
|
||||||
|
tabNumbers: env.tabNumbers ?? {},
|
||||||
|
nextTabNumber: env.nextTabNumber ?? 1,
|
||||||
|
closedPaneStack: env.closedPaneStack ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { panes: [], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
|
||||||
|
}
|
||||||
|
|
||||||
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
|
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
|
||||||
// Helper used at every pane-insertion site so the rule lives in one place.
|
// Helper used at every pane-insertion site so the rule lives in one place.
|
||||||
function nonSettingsCount(panes: WorkspacePane[]): number {
|
function nonSettingsCount(panes: WorkspacePane[]): number {
|
||||||
@@ -132,6 +170,9 @@ function readLegacyPanes(sessionId: string): WorkspacePane[] | null {
|
|||||||
|
|
||||||
export interface UseWorkspacePanesResult {
|
export interface UseWorkspacePanesResult {
|
||||||
panes: WorkspacePane[];
|
panes: WorkspacePane[];
|
||||||
|
// v2.6.x: stable session-scoped tab number per chat id (Batch 3a). Keyed by
|
||||||
|
// chat.id, NEVER by tab position.
|
||||||
|
tabNumbers: Record<string, number>;
|
||||||
activePaneIdx: number;
|
activePaneIdx: number;
|
||||||
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
|
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
|
||||||
activePaneIdxRef: React.MutableRefObject<number>;
|
activePaneIdxRef: React.MutableRefObject<number>;
|
||||||
@@ -171,6 +212,12 @@ export interface UseWorkspacePanesResult {
|
|||||||
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||||
const [panes, setPanes] = useState<WorkspacePane[]>(() => [emptyPane()]);
|
const [panes, setPanes] = useState<WorkspacePane[]>(() => [emptyPane()]);
|
||||||
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
||||||
|
// v2.6.x envelope state. Persisted alongside `panes` in the WorkspaceState
|
||||||
|
// envelope. `tabNumbers` is the stable session-scoped tab number per chat id;
|
||||||
|
// `nextTabNumber` only ever increments; `closedPaneStack` is the reopen LIFO.
|
||||||
|
const [tabNumbers, setTabNumbers] = useState<Record<string, number>>({});
|
||||||
|
const [nextTabNumber, setNextTabNumber] = useState(1);
|
||||||
|
const [closedPaneStack, setClosedPaneStack] = useState<ClosedPaneEntry[]>([]);
|
||||||
const draggingIdxRef = useRef<number | null>(null);
|
const draggingIdxRef = useRef<number | null>(null);
|
||||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||||
// v1.12.1: skip PATCH while hydrating from the server. Without this, the
|
// v1.12.1: skip PATCH while hydrating from the server. Without this, the
|
||||||
@@ -237,27 +284,42 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
try {
|
try {
|
||||||
const session = await api.sessions.get(sessionId);
|
const session = await api.sessions.get(sessionId);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
let initial: WorkspacePane[] = Array.isArray(session.workspace_panes)
|
let env = toWorkspaceState(session.workspace_panes);
|
||||||
? normalizePanes(session.workspace_panes)
|
let initial: WorkspacePane[] = normalizePanes(env.panes);
|
||||||
: [];
|
|
||||||
// One-time migration: if server is empty but legacy localStorage has
|
// One-time migration: if server is empty but legacy localStorage has
|
||||||
// a layout, seed the server and delete the local key.
|
// a layout, seed the server (as an envelope) and delete the local key.
|
||||||
if (initial.length === 0) {
|
if (initial.length === 0) {
|
||||||
const legacy = readLegacyPanes(sessionId);
|
const legacy = readLegacyPanes(sessionId);
|
||||||
if (legacy && legacy.length > 0) {
|
if (legacy && legacy.length > 0) {
|
||||||
try {
|
try {
|
||||||
const updated = await api.sessions.updateWorkspacePanes(sessionId, legacy);
|
const seedState: WorkspaceState = {
|
||||||
|
panes: persistablePanes(legacy),
|
||||||
|
tabNumbers: {},
|
||||||
|
nextTabNumber: 1,
|
||||||
|
closedPaneStack: [],
|
||||||
|
};
|
||||||
|
const updated = await api.sessions.updateWorkspacePanes(sessionId, seedState);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
initial = updated.workspace_panes;
|
env = toWorkspaceState(updated.workspace_panes);
|
||||||
|
initial = normalizePanes(env.panes);
|
||||||
localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
|
localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
|
||||||
} catch {
|
} catch {
|
||||||
initial = legacy;
|
env = { ...env, panes: legacy };
|
||||||
|
initial = normalizePanes(legacy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const next = initial.length > 0 ? initial : [emptyPane()];
|
const next = initial.length > 0 ? initial : [emptyPane()];
|
||||||
lastRemoteJsonRef.current = JSON.stringify(persistablePanes(next));
|
lastRemoteJsonRef.current = JSON.stringify({
|
||||||
|
panes: persistablePanes(next),
|
||||||
|
tabNumbers: env.tabNumbers,
|
||||||
|
nextTabNumber: env.nextTabNumber,
|
||||||
|
closedPaneStack: env.closedPaneStack,
|
||||||
|
});
|
||||||
setPanes(next);
|
setPanes(next);
|
||||||
|
setTabNumbers(env.tabNumbers);
|
||||||
|
setNextTabNumber(env.nextTabNumber);
|
||||||
|
setClosedPaneStack(env.closedPaneStack);
|
||||||
setActivePaneIdx(0);
|
setActivePaneIdx(0);
|
||||||
seedEmptyScopedPanes(next);
|
seedEmptyScopedPanes(next);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -273,15 +335,25 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
return sessionEvents.subscribe((ev) => {
|
return sessionEvents.subscribe((ev) => {
|
||||||
if (ev.type !== 'session_workspace_updated') return;
|
if (ev.type !== 'session_workspace_updated') return;
|
||||||
if (ev.session_id !== sessionId) return;
|
if (ev.session_id !== sessionId) return;
|
||||||
const incoming = normalizePanes(
|
const env = toWorkspaceState(ev.workspace_panes);
|
||||||
Array.isArray(ev.workspace_panes) ? ev.workspace_panes : [],
|
const incoming = normalizePanes(env.panes);
|
||||||
);
|
// Echo-dedup on the FULL envelope so tabNumber / stack-only changes are
|
||||||
const json = JSON.stringify(incoming);
|
// not mistaken for our own write echo.
|
||||||
|
const json = JSON.stringify({
|
||||||
|
panes: persistablePanes(incoming),
|
||||||
|
tabNumbers: env.tabNumbers,
|
||||||
|
nextTabNumber: env.nextTabNumber,
|
||||||
|
closedPaneStack: env.closedPaneStack,
|
||||||
|
});
|
||||||
if (json === lastRemoteJsonRef.current) return;
|
if (json === lastRemoteJsonRef.current) return;
|
||||||
lastRemoteJsonRef.current = json;
|
lastRemoteJsonRef.current = json;
|
||||||
setPanes(incoming.length > 0 ? incoming : [emptyPane()]);
|
const nextPanes = incoming.length > 0 ? incoming : [emptyPane()];
|
||||||
|
setPanes(nextPanes);
|
||||||
|
setTabNumbers(env.tabNumbers);
|
||||||
|
setNextTabNumber(env.nextTabNumber);
|
||||||
|
setClosedPaneStack(env.closedPaneStack);
|
||||||
setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
|
setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
|
||||||
seedEmptyScopedPanes(incoming.length > 0 ? incoming : [emptyPane()]);
|
seedEmptyScopedPanes(nextPanes);
|
||||||
});
|
});
|
||||||
}, [sessionId, seedEmptyScopedPanes]);
|
}, [sessionId, seedEmptyScopedPanes]);
|
||||||
|
|
||||||
@@ -333,18 +405,75 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
// before saving (ephemeral per v1.9).
|
// before saving (ephemeral per v1.9).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hydratedRef.current) return;
|
if (!hydratedRef.current) return;
|
||||||
const payload = persistablePanes(panes);
|
// v2.6.x: persist the full WorkspaceState envelope. The dedup ref compares
|
||||||
const json = JSON.stringify(payload);
|
// the whole envelope so tabNumber / reopen-stack changes also persist.
|
||||||
|
const envelope: WorkspaceState = {
|
||||||
|
panes: persistablePanes(panes),
|
||||||
|
tabNumbers,
|
||||||
|
nextTabNumber,
|
||||||
|
closedPaneStack,
|
||||||
|
};
|
||||||
|
const json = JSON.stringify(envelope);
|
||||||
if (json === lastRemoteJsonRef.current) return;
|
if (json === lastRemoteJsonRef.current) return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
lastRemoteJsonRef.current = json;
|
lastRemoteJsonRef.current = json;
|
||||||
api.sessions.updateWorkspacePanes(sessionId, payload).catch(() => {
|
api.sessions.updateWorkspacePanes(sessionId, envelope).catch(() => {
|
||||||
// Non-fatal: next change retries. Persistent failures surface via
|
// Non-fatal: next change retries. Persistent failures surface via
|
||||||
// the network layer's existing reconnect toast.
|
// the network layer's existing reconnect toast.
|
||||||
});
|
});
|
||||||
}, SAVE_DEBOUNCE_MS);
|
}, SAVE_DEBOUNCE_MS);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [sessionId, panes]);
|
}, [sessionId, panes, tabNumbers, nextTabNumber, closedPaneStack]);
|
||||||
|
|
||||||
|
// v2.6.x (Batch 3a): maintain stable, session-scoped tab numbers. Collect the
|
||||||
|
// chat ids that appear in CHAT-kind panes in deterministic order (pane index,
|
||||||
|
// then tab index). Assign numbers to any without one (global per session,
|
||||||
|
// only ever increasing, never reused) and prune entries whose chat is no
|
||||||
|
// longer in any chat-kind pane. Guarded against render loops: only setState
|
||||||
|
// when something actually changed.
|
||||||
|
useEffect(() => {
|
||||||
|
const liveChatIds: string[] = [];
|
||||||
|
const liveSet = new Set<string>();
|
||||||
|
for (const pane of panes) {
|
||||||
|
if (pane.kind !== 'chat') continue;
|
||||||
|
for (const id of pane.chatIds) {
|
||||||
|
if (!liveSet.has(id)) {
|
||||||
|
liveSet.add(id);
|
||||||
|
liveChatIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign: walk live ids in deterministic order, handing out numbers.
|
||||||
|
let counter = nextTabNumber;
|
||||||
|
const additions: Record<string, number> = {};
|
||||||
|
for (const id of liveChatIds) {
|
||||||
|
if (tabNumbers[id] === undefined && additions[id] === undefined) {
|
||||||
|
additions[id] = counter;
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune: retire numbers for chats no longer in any chat-kind pane.
|
||||||
|
const removals: string[] = [];
|
||||||
|
for (const id of Object.keys(tabNumbers)) {
|
||||||
|
if (!liveSet.has(id)) removals.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAdditions = Object.keys(additions).length > 0;
|
||||||
|
const hasRemovals = removals.length > 0;
|
||||||
|
if (!hasAdditions && !hasRemovals) return;
|
||||||
|
|
||||||
|
setTabNumbers((prev) => {
|
||||||
|
const next: Record<string, number> = {};
|
||||||
|
for (const [id, n] of Object.entries(prev)) {
|
||||||
|
if (!removals.includes(id)) next[id] = n;
|
||||||
|
}
|
||||||
|
Object.assign(next, additions);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (hasAdditions) setNextTabNumber(counter);
|
||||||
|
}, [panes, tabNumbers, nextTabNumber]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const active = panes[activePaneIdx];
|
const active = panes[activePaneIdx];
|
||||||
@@ -391,6 +520,37 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
setActivePaneIdx(paneIdx);
|
setActivePaneIdx(paneIdx);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Open a whole chat in its own fresh pane (focused). Detaches the chat from
|
||||||
|
// any pane currently showing it so it lives in exactly one pane (preserves
|
||||||
|
// the one-chat-per-pane model), dropping a source pane left with no tabs. For
|
||||||
|
// fork the chat isn't in any pane yet, so the detach is a no-op (pure append).
|
||||||
|
const openChatInNewPane = useCallback((chatId: string) => {
|
||||||
|
setPanes((prev) => {
|
||||||
|
const detached = prev.flatMap((p) => {
|
||||||
|
if (!p.chatIds.includes(chatId)) return [p];
|
||||||
|
const nextIds = p.chatIds.filter((id) => id !== chatId);
|
||||||
|
if (nextIds.length === 0) return [];
|
||||||
|
const ai = Math.min(p.activeChatIdx, nextIds.length - 1);
|
||||||
|
return [{ ...p, kind: 'chat' as const, chatId: nextIds[ai], chatIds: nextIds, activeChatIdx: ai }];
|
||||||
|
});
|
||||||
|
if (nonSettingsCount(detached) >= MAX_PANES) {
|
||||||
|
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = [...detached, chatPane(chatId)];
|
||||||
|
setActivePaneIdx(next.length - 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ChatTabBar's "Open in new pane" + MessageBubble.fork() emit this.
|
||||||
|
useEffect(() => {
|
||||||
|
return sessionEvents.subscribe((ev) => {
|
||||||
|
if (ev.type !== 'open_chat_in_new_pane') return;
|
||||||
|
openChatInNewPane(ev.chat_id);
|
||||||
|
});
|
||||||
|
}, [openChatInNewPane]);
|
||||||
|
|
||||||
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
|
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
@@ -411,7 +571,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
if (next.length > 1) {
|
if (next.length > 1) {
|
||||||
// Last tab closed and other panes exist — remove the whole pane
|
// Last tab closed and other panes exist — remove the whole pane
|
||||||
// instead of leaving an orphaned empty panel.
|
// instead of leaving an orphaned empty panel.
|
||||||
pushClosed(pane); setHasClosedPanes(true);
|
setClosedPaneStack((stack) => appendClosed(stack, pane));
|
||||||
const spliced = next.filter((_, i) => i !== paneIdx);
|
const spliced = next.filter((_, i) => i !== paneIdx);
|
||||||
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
|
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
|
||||||
return spliced;
|
return spliced;
|
||||||
@@ -547,7 +707,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
if (prev.length <= 1) {
|
if (prev.length <= 1) {
|
||||||
// Settings is the only kind that can be the last pane and still need
|
// Settings is the only kind that can be the last pane and still need
|
||||||
// closing (X / Esc / sidebar toggle). Fall back to empty.
|
// closing (X / Esc / sidebar toggle). Fall back to empty. One-pane
|
||||||
|
// edge: no relocation — there is no other pane.
|
||||||
if (prev[idx]?.kind === 'settings') {
|
if (prev[idx]?.kind === 'settings') {
|
||||||
setActivePaneIdx(0);
|
setActivePaneIdx(0);
|
||||||
return [emptyPane()];
|
return [emptyPane()];
|
||||||
@@ -559,35 +720,101 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
// The endpoint is idempotent (404 on missing session) so a strict-mode
|
// The endpoint is idempotent (404 on missing session) so a strict-mode
|
||||||
// double-invoke of the updater is safe.
|
// double-invoke of the updater is safe.
|
||||||
const removed = prev[idx];
|
const removed = prev[idx];
|
||||||
if (removed) { pushClosed(removed); setHasClosedPanes(true); }
|
// Push the original pane (with its chatIds intact) to the reopen stack.
|
||||||
|
if (removed) setClosedPaneStack((stack) => appendClosed(stack, removed));
|
||||||
if (removed?.kind === 'terminal') {
|
if (removed?.kind === 'terminal') {
|
||||||
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
|
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
|
||||||
}
|
}
|
||||||
const next = prev.filter((_, i) => i !== idx);
|
|
||||||
|
// v2.6.x (Batch 1): relocate a closing CHAT pane's tabs to the oldest
|
||||||
|
// remaining pane that can host chat tabs, so chats aren't lost on close.
|
||||||
|
// Only chat panes relocate — terminal/coder panes own a scoped chat bound
|
||||||
|
// to the pane, so those close exactly as before (no relocation).
|
||||||
|
let working = prev;
|
||||||
|
if (removed && removed.kind === 'chat' && removed.chatIds.length > 0) {
|
||||||
|
// "Oldest remaining": lowest index, excluding `idx`, that is a chat or
|
||||||
|
// empty pane (the only kinds that can host arbitrary chat tabs). Skip
|
||||||
|
// terminal/coder/settings/artifact panes.
|
||||||
|
let targetIdx = -1;
|
||||||
|
for (let i = 0; i < prev.length; i += 1) {
|
||||||
|
if (i === idx) continue;
|
||||||
|
const p = prev[i]!;
|
||||||
|
if (p.kind === 'chat' || p.kind === 'empty') {
|
||||||
|
targetIdx = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetIdx >= 0) {
|
||||||
|
working = prev.map((p, i) => {
|
||||||
|
if (i !== targetIdx) return p;
|
||||||
|
const mergedIds = [...p.chatIds, ...removed.chatIds];
|
||||||
|
// Preserve the target's existing focus — append, don't force-focus
|
||||||
|
// the moved tabs. Clamp only when the target had no active tab.
|
||||||
|
const ai = p.activeChatIdx >= 0 ? p.activeChatIdx : 0;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
kind: 'chat' as const,
|
||||||
|
chatIds: mergedIds,
|
||||||
|
activeChatIdx: ai,
|
||||||
|
chatId: mergedIds[ai],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = working.filter((_, i) => i !== idx);
|
||||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const [hasClosedPanes, setHasClosedPanes] = useState(closedPaneStack.length > 0);
|
const hasClosedPanes = closedPaneStack.length > 0;
|
||||||
|
|
||||||
const reopenPane = useCallback(() => {
|
const reopenPane = useCallback(() => {
|
||||||
const entry = closedPaneStack.pop();
|
// Read the top entry from the current render's stack (not inside the
|
||||||
setHasClosedPanes(closedPaneStack.length > 0);
|
// updater) so a StrictMode double-invoke can't pop two entries. The pop
|
||||||
if (!entry) return;
|
// setState is idempotent: filtering by reference removes exactly this entry.
|
||||||
|
const e = closedPaneStack[closedPaneStack.length - 1];
|
||||||
|
if (!e) return;
|
||||||
|
setClosedPaneStack((stack) => (stack[stack.length - 1] === e ? stack.slice(0, -1) : stack));
|
||||||
setPanes((prev) => {
|
setPanes((prev) => {
|
||||||
|
// v2.6.x (Batch 4): reversible reopen. The closed tabs may have been
|
||||||
|
// relocated into another pane on close (Batch 1). Strip e.chatIds from
|
||||||
|
// every existing pane first so reopening never duplicates a tab —
|
||||||
|
// whether or not it was relocated (a no-op strip when it wasn't). Mirror
|
||||||
|
// removeTab's emptiness handling: a chat pane emptied by the strip is
|
||||||
|
// dropped when other panes remain, else turned empty.
|
||||||
|
const stripped: WorkspacePane[] = [];
|
||||||
|
for (const p of prev) {
|
||||||
|
const idxs = p.chatIds.filter((id) => !e.chatIds.includes(id));
|
||||||
|
if (idxs.length === p.chatIds.length) {
|
||||||
|
stripped.push(p);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (idxs.length === 0) {
|
||||||
|
if (p.kind === 'chat') {
|
||||||
|
// Drop the now-empty chat pane (we still have the restored pane plus
|
||||||
|
// possibly others). If it would leave zero panes, turn it empty.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stripped.push({ ...p, chatId: undefined, chatIds: [], activeChatIdx: -1 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ai = Math.min(p.activeChatIdx, idxs.length - 1);
|
||||||
|
stripped.push({ ...p, chatIds: idxs, activeChatIdx: ai < 0 ? 0 : ai, chatId: idxs[ai < 0 ? 0 : ai] });
|
||||||
|
}
|
||||||
const restored: WorkspacePane = {
|
const restored: WorkspacePane = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
kind: entry.kind,
|
kind: e.kind,
|
||||||
chatId: entry.chatIds[entry.activeChatIdx] ?? entry.chatIds[0],
|
chatId: e.chatIds[e.activeChatIdx] ?? e.chatIds[0],
|
||||||
chatIds: entry.chatIds,
|
chatIds: e.chatIds,
|
||||||
activeChatIdx: Math.min(entry.activeChatIdx, entry.chatIds.length - 1),
|
activeChatIdx: Math.min(e.activeChatIdx, e.chatIds.length - 1),
|
||||||
};
|
};
|
||||||
const next = [...prev, restored];
|
const next = [...stripped, restored];
|
||||||
setActivePaneIdx(next.length - 1);
|
setActivePaneIdx(next.length - 1);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [closedPaneStack]);
|
||||||
|
|
||||||
// Replaces a single empty default pane with a chat pane. Used by the initial
|
// Replaces a single empty default pane with a chat pane. Used by the initial
|
||||||
// chat fetch to land on the most-recent open chat if no saved pane state.
|
// chat fetch to land on the most-recent open chat if no saved pane state.
|
||||||
@@ -705,6 +932,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
panes,
|
panes,
|
||||||
|
tabNumbers,
|
||||||
activePaneIdx,
|
activePaneIdx,
|
||||||
setActivePaneIdx,
|
setActivePaneIdx,
|
||||||
activePaneIdxRef,
|
activePaneIdxRef,
|
||||||
|
|||||||
@@ -56,19 +56,26 @@ export function inferLanguage(filename: string): string | null {
|
|||||||
|
|
||||||
export function flattenToMessage(attachments: Attachment[], text: string): string {
|
export function flattenToMessage(attachments: Attachment[], text: string): string {
|
||||||
if (attachments.length === 0) return text;
|
if (attachments.length === 0) return text;
|
||||||
const blocks = attachments.map(a => {
|
// Pasted text is raw context, not code from a file — insert it verbatim with no
|
||||||
// Pasted text is raw context, not code from a file — insert it verbatim with
|
// ``` fence or provenance header. It trails the typed text with a leading space
|
||||||
// no ``` fence or provenance header. The chip only exists to keep the textarea
|
// so a leading slash command / prompt stays first and the paste reads as its
|
||||||
// tidy while composing; on send it should be exactly what the user pasted.
|
// continuation. File/line chips stay fenced provenance blocks, appended after.
|
||||||
|
const pasteBlocks: string[] = [];
|
||||||
|
const fencedBlocks: string[] = [];
|
||||||
|
for (const a of attachments) {
|
||||||
if (a.kind === 'paste') {
|
if (a.kind === 'paste') {
|
||||||
return a.content;
|
pasteBlocks.push(a.content);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
const fence = '```' + (a.language ?? '');
|
const fence = '```' + (a.language ?? '');
|
||||||
const header =
|
const header =
|
||||||
a.kind === 'lines'
|
a.kind === 'lines'
|
||||||
? `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`
|
? `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`
|
||||||
: `// from: ${a.filename}`;
|
: `// from: ${a.filename}`;
|
||||||
return `${fence}\n${header}\n${a.content}\n\`\`\``;
|
fencedBlocks.push(`${fence}\n${header}\n${a.content}\n\`\`\``);
|
||||||
});
|
}
|
||||||
return [...blocks, text].filter(Boolean).join('\n\n');
|
// Typed text + pasted content on the same logical line (space-joined), then
|
||||||
|
// any fenced file blocks as separate paragraphs.
|
||||||
|
const lead = [text, ...pasteBlocks].filter(Boolean).join(' ');
|
||||||
|
return [lead, ...fencedBlocks].filter(Boolean).join('\n\n');
|
||||||
}
|
}
|
||||||
|
|||||||
249
boocode_code_review_v2.md
Normal file
249
boocode_code_review_v2.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# BooCode — External Code Review v2 (lift findings)
|
||||||
|
|
||||||
|
Last updated: 2026-05-31
|
||||||
|
|
||||||
|
A point-in-time **findings** doc, not a standing inventory. It consolidates two reconnaissance passes against the upstream forks at `/opt/forks/` and decides, per area, what BooCode should do about it. Pin it so the same upstreams aren't re-evaluated from scratch next month.
|
||||||
|
|
||||||
|
> **Companion docs:** `boocode_code_review.md` is the standing external-repo inventory (every repo BooCode references, *why* each earned its row, license analysis). `boocode_roadmap.md` is the canonical shipping-state / version-ordering source. This v2 doc is the **action layer** on top of both: "given what's upstream as of 2026-05-31, here's the lift/cross-check/re-derive/n-a call." Reconcile shipping state via the roadmap when in doubt; fold durable rows back into `boocode_code_review.md`.
|
||||||
|
|
||||||
|
## Sources feeding this doc
|
||||||
|
|
||||||
|
1. **Paseo recon (Sam)** — two passes: a Phase 2/3 server-manager recon and a claude-transport recon. Conclusions consolidated by area below (§2a). AGPL-3.0 — **pattern-only, no code lift, ever.**
|
||||||
|
2. **Three-fork agent sweep (this session, 2026-05-31)** — read-only general-purpose agents over `anomalyco/opencode` (MIT, code-liftable), `getpaseo/paseo` (AGPL, pattern-only), `ggml-org/llama.cpp` (consumed via llama-swap/sidecar — adopt features/flags, not C++). Detail in §2–§4.
|
||||||
|
3. **Second fork sweep (this session, 2026-05-31)** — 8 read-only agents over the remaining 11 repos in `/opt/forks/` (conductor, superset, openchamber, happy, cline, qwen-code, amp-acp, pi-acp, claude-code, goose, unsloth). Detail in §5; high-value items folded into §1.
|
||||||
|
|
||||||
|
### Caveats
|
||||||
|
- `/opt/forks/llama.cpp` is a **shallow clone** (90 commits, ~5 days visible). llama.cpp findings are read from source as it stands today; "what changed when" attribution is limited. `git fetch --unshallow` before the next review.
|
||||||
|
- `/opt/forks/opencode` arrived shallow (rooted 2026-05-25); the agent ran `git fetch --unshallow` and re-surveyed the real 6-week window. opencode also did a v2 Effect/event-sourced rewrite (`packages/core/`, `packages/llm/`) — most of that churn is architecturally divergent and ruled out.
|
||||||
|
- HEADs at review time: paseo `41cb1af` (main, v0.1.87), opencode `1afa9e3` (dev, ~v1.15.13), llama.cpp `aa46bda8` (detached).
|
||||||
|
|
||||||
|
## Verdict legend
|
||||||
|
|
||||||
|
| Verdict | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| **LIFT** | Take it. Flavor noted: *code-lift* (MIT), *pattern-lift* (AGPL/clean-room re-impl), *config-adopt* (new upstream flag), *drop-our-code* (upstream now does it → delete ours). |
|
||||||
|
| **RE-DERIVE** | Idea is right, their impl is insufficient/divergent for our needs — write fresh, don't adapt theirs. |
|
||||||
|
| **CROSS-CHECK** | We already have it; confirmed current vs upstream. No action. |
|
||||||
|
| **TRACK** | Behavioral/external change to be aware of. No code action now. |
|
||||||
|
| **N-A** | Not liftable into our architecture, or reduces to a separate decision. |
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## 1. Net actionables (priority roll-up)
|
||||||
|
|
||||||
|
Updated after the **second fork sweep** (2026-05-31, §5). New items from that sweep are tagged ⁺.
|
||||||
|
|
||||||
|
| # | Item | Source | Verdict | Maps to | Effort |
|
||||||
|
|---|------|--------|---------|---------|--------|
|
||||||
|
| 1 | **Retire the AGPL tool-call parser + html-to-md** — llama-server parses qwen3.x `<tool_call>`/`<function=>` server-side; both lifted files are **confirmed AGPL-3.0-only** (§5k); swap html-to-md to a permissive lib | llama.cpp + unsloth⁺ | LIFT · drop-our-code | license-debt / inference; new batch | M, staged + gated on jinja |
|
||||||
|
| 2 | **Warm-ACP backend (goose/qwen)** — one spawn, one `session/new`, many prompts; **validated by qwen's own `qwen --acp` reference impl** (the "qwen ACP was HTTP-only" premise is stale) | Paseo recon + qwen-code⁺ | LIFT · pattern | **v2.6 Phase 2** | M |
|
||||||
|
| 3 ⁺ | **Fuzzy patch applier for `edit_file`** — exact→whitespace→Levenshtein match ladder + unicode canon + multi-occurrence guard; BooCoder's `edit_file` is exact-`.includes`-or-throw today | cline⁺ | LIFT · code | edit/diff robustness (local-model drift) | M |
|
||||||
|
| 4 ⁺ | **`git stash create` + private-ref checkpoint** — per-turn workspace snapshot capturing **all** state incl. external-agent edits (BooCode `rewind` only undoes its own queued edits) | cline⁺ | LIFT · code | checkpoint/restore UX | M |
|
||||||
|
| 5 ⁺ | **opencode lifecycle hardening** — health monitor + crash auto-restart + busy-aware restart + port reclaim + stall-detecting SSE; **MIT, same warm-server architecture** (supersedes the paseo RE-DERIVE — better source) | openchamber⁺ | LIFT · pattern/code | **v2.6 Phase 3** | M |
|
||||||
|
| 6 | **Post-interrupt stale-terminal guard** — confirmed correctness bug in `opencode-server.ts` | opencode/paseo (verified) | LIFT · pattern (bugfix) | v2.6 Phase 1/2 | S (~½ day) |
|
||||||
|
| 7 ⁺ | **Parse qwen/claude `stream-json` NDJSON in PTY fallback** — today stdout is sliced opaque; one parser serves both (Claude-Code-compatible schema) | qwen-code⁺ | LIFT · pattern | v2.6 Phase 2 / dispatch parsing | S |
|
||||||
|
| 8 | **ctx/token usage for opencode sessions** — `session.next.step.ended` already on the wire | opencode + paseo (converged) | LIFT · code | v2.6 Phase 1 UX | S–M (~80–150 LoC) |
|
||||||
|
| 9 | **Claude continuity + transport** — `--resume` via hook/jsonl-watcher → `claudeSessionId`; **happy proves the `@anthropic-ai/claude-agent-sdk` path** (resolves the SDK-vs-PTY decision — lean SDK) | Paseo recon + happy⁺ | LIFT · code + decision | claude-provider batch | M |
|
||||||
|
| 10 ⁺ | **Universal-agent notify-hook → normalized status** — inject a hook into each PTY agent's config, normalize ~30 event names → 5 states; gives goose/qwen/claude real working/blocked/done signals | superset⁺ (clean-room, ELv2) | RE-DERIVE | v2.6 Phase 2/3 status | M–H |
|
||||||
|
| 11 | **New sampling knobs** `top_n_sigma`, `dry_*` family; **`--reasoning-budget`** | llama.cpp | LIFT · config-adopt | AGENTS.md frontmatter + validator allowlist | S |
|
||||||
|
| 12 ⁺ | **File-provenance compaction ledger** (`## Files Read/Modified`) + **`MistakeTracker`** (heterogeneous-failure recovery) | cline⁺ | LIFT · pattern | context-mgmt / recovery | S–M |
|
||||||
|
| 13 | Bundle/watch: stall-timeout + retry/backoff (opencode); worktree-archive cascade (paseo); LRU-bound caches; subagent permission demux; tool-pair-atomic prune cross-check (cline)⁺; diff-line→agent re-prompt (superset)⁺ | mixed | WATCH | Phase 2/3, review UX, resilience | varies |
|
||||||
|
|
||||||
|
**Headline:** #1 stays the strategic win and is now **settled, not speculative** — the unsloth recon confirmed both lifted files are AGPL-3.0-only (§5k); the only gate is the jinja config check (§6). The second sweep added four genuinely-new code lifts: **#3 fuzzy patch applier** and **#4 git-stash checkpoint** (both cline, both directly fix where BooCoder's write/edit surface is weakest for local models), **#5 openchamber lifecycle hardening** (the concrete, MIT, same-architecture answer to v2.6 Phase 3 — supersedes the weaker paseo re-derive), and **#7 stream-json parsing** (cheap, shared by qwen+claude PTY). #2 Phase-2 warm-ACP is now de-risked by qwen's own reference impl. #9 resolves the claude direction (lean SDK).
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## 2. Paseo (AGPL-3.0 — pattern-only)
|
||||||
|
|
||||||
|
### 2a. Consolidated recon, by area (Sam's two passes)
|
||||||
|
|
||||||
|
| Area | Verdict | One-line |
|
||||||
|
|------|---------|----------|
|
||||||
|
| OpenCode server lifecycle | **CROSS-CHECK** | Paseo hand-rolls the spawn (not `createOpencodeServer`), waits for "listening on" on stdout, port-0 allocation, concurrent callers wait on one `startPromise`, no `OPENCODE_SERVER_PASSWORD`. Same shape BooCode shipped in v2.6.1 — nothing to lift. |
|
||||||
|
| OpenCode crash recovery + reconnect | **RE-DERIVE → superseded** | Lazy restart-on-demand (exit handler nulls the server, next `getCurrentServer()` respawns), no active supervision; `resumeSession` does **not** verify the session exists on disk before resuming. Insufficient for Phase 3. **Update (2nd sweep):** `openchamber` (§5c) has a *better, MIT, same-architecture* version — health-monitor state machine + crash auto-restart + busy-aware restart. Lift from openchamber, not paseo. |
|
||||||
|
| Warm-ACP supervision (goose/qwen) | **LIFT · pattern** | `SpawnedACPProcess`: one spawn, one `session/new`, many prompts; child lives for the session not the turn; per-turn abort = `connection.cancel({sessionId})` **without killing the child**; child-exit fires `turn_failed` (no restart). Clean signal split; integrates against BooCode's existing `acp-dispatch.ts`. **This is the Phase 2 lift — and qwen-code (§5f) ships its own `qwen --acp` reference impl that validates the whole approach.** |
|
||||||
|
| OpenCode reasoning dedup | **CROSS-CHECK** | `streamedPartKeys` keyed `reasoning:${partID}`; delta adds the key, final part skips if present, cleared per turn. Identical to v2.6.1. |
|
||||||
|
| Claude transport | **N-A** | Paseo uses `@anthropic-ai/claude-agent-sdk` in stream-json mode, not PTY. Getting Paseo's transport means adopting the SDK — net-new integration, not a lift. |
|
||||||
|
| Claude continuity | **LIFT · code** | `claude --resume <sessionId>` across turns: capture the session id from claude's output, store it, pass `--resume` next turn; claude re-reads its transcript and continues. Small change to BooCode's PTY dispatch (run with `--output-format stream-json`, parse the id, persist, resume). **The actionable claude finding.** |
|
||||||
|
| Claude streaming/parsing | **N-A** | Structured events (tool calls, reasoning, partials) come from the SDK; PTY degrades to scraping. Adopting structured claude streaming = adopting the SDK — separate decision. |
|
||||||
|
| Claude session persistence | **CROSS-CHECK** | Same `describePersistence`/`resumeSession` shape BooCode already has for opencode; claude slots in. Neither Paseo nor BooCode verifies the transcript exists on disk before resume (**shared open question** — see §5). |
|
||||||
|
|
||||||
|
**Recon's net:** LIFT = warm-ACP supervision (Phase 2) + claude `--resume` continuity (standalone batch). RE-DERIVE = OpenCode crash recovery (Phase 3). Everything else cross-check or n/a. The two n/a claude items both reduce to **one deferred decision: adopt `@anthropic-ai/claude-agent-sdk` or stay PTY.**
|
||||||
|
|
||||||
|
### 2b. Additional findings (this session's Paseo agent sweep)
|
||||||
|
|
||||||
|
These came from the broader agent pass, not the targeted Phase 2/3 recon. Where they touch the same code as §2a, the §2a recon is authoritative.
|
||||||
|
|
||||||
|
| Finding | Verdict | Notes |
|
||||||
|
|---------|---------|-------|
|
||||||
|
| **Post-interrupt stale-terminal suppression** (paseo `1d38aac`) | **LIFT · pattern (bugfix)** | See §3 #3 — verified to be a live bug in BooCode. Highest-confidence paseo item. |
|
||||||
|
| **Provider-agnostic `AgentUsage`** normalized usage/cost frame | **LIFT · pattern** | Converges with opencode's `session.next.step.ended` (§3 #4). Paseo's `{inputTokens, cachedInputTokens, outputTokens, totalCostUsd, contextWindowMax/Used}` is the target *shape* for normalizing across providers; do the opencode slice first. |
|
||||||
|
| **Worktree-archive → cascade-archive agents + schedule cleanup** (paseo `b6103a5`) | **WATCH → adopt in Phase 3** | Soft-delete (keep `archivedAt`), single archive event fans out to children + downstream rows, `Promise.allSettled` so one failed delete doesn't abandon the rest. Right shape for the v2.6 Phase 3 worktree reaper. |
|
||||||
|
| **Server retire/refcount + LRU-bound caches** (paseo `server-manager.ts`, leak-fix `f20393d`) | **WATCH** (low confidence) | The agent read a retire-set/refcount mechanism; the §2a server-manager recon concluded "nothing to lift." Treat the *lifecycle* as cross-check (§2a wins). The one durable takeaway: **bound the per-session/per-worktree Maps in the warm opencode server** (long-lived daemon → unbounded caches leak). Confirm against §2a before acting. |
|
||||||
|
| **Subagent permission forwarding** (paseo `44863ec`) | **WATCH (gated)** | opencode `task` tool spawns child sessions; forward `permission.asked` from tracked children by `parentID` demux. **Blocked:** BooCode's opencode-SSE path has zero permission handling today (runs auto-approve). Reachable only after BooCoder builds opencode-SSE permission cards at all. Ties to v2.4. |
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## 3. OpenCode (MIT — code-liftable)
|
||||||
|
|
||||||
|
| # | Finding | Evidence | Verdict | Notes |
|
||||||
|
|---|---------|----------|---------|-------|
|
||||||
|
| 1 | **Consume the fuller `session.next.*` event set** in `opencode-server.ts` | `packages/core/src/session/event.ts:105-365`; BooCode handles only ~5 arms (`opencode-server.ts:215-311`) | **LIFT · code** | Events already in the **installed** `@opencode-ai/sdk` — **no dep bump.** High-value arms: **`step.ended`** (`{tokens{input,output,reasoning,cache},cost}` → #4 below); **`compaction.{started,delta,ended}`** (warm server auto-compacts mid-conversation; today shows as a silent context gap); `tool.progress`, `tool.input.{started,delta}`, `retried`, `step.failed`. |
|
||||||
|
| 4 | **ctx/token usage for opencode** (the high-value slice of #1) | `event.ts:117-135` | **LIFT · code** | Closes the roadmap-named gap: *"opencode/goose/qwen/claude dispatch with no ctx/token usage; only native boocode tracks ctx."* Mirror BooChat's existing `'usage'` WS frame on the coder side; accumulate per `(chat, agent)`. Converges with paseo `AgentUsage` (§2b). |
|
||||||
|
| 2 | **Stalled-stream chunk-timeout** (`wrapSSE` + header timeout) | `provider/provider.ts:40-96` (`f965db9`, `c7e1fc5`) | **WATCH · pattern** | BooChat's `stream-phase.ts` has **no server-side stall timeout** — a hung llama-swap stream relies entirely on the frontend 60s `discard_stale` watchdog. ~40-60 LoC to wrap the `fullStream` loop with a per-chunk timeout firing the existing abort path. Low incidence on a single local instance; do it if stuck rows recur. |
|
||||||
|
| 3 | **Retry-with-backoff + retryability classifier** (`session/retry.ts`) | `session/retry.ts`, `message-v2.ts:1155` (`14e0b9b`) | **WATCH · pattern** | BooChat has **zero** retry logic. `delay()` parses `retry-after[-ms]` headers w/ exp-backoff fallback; `retryable()` classifies transient-5xx / rate-limit / context-overflow-exclusion. Strip the Go-billing arms. Pairs naturally with #2. llama-swap rarely emits `retry-after`, so value is mostly transient-5xx/stall retry. |
|
||||||
|
| — | **MCP auth file-lock** (`mcp/auth.ts`, `fa73ec4`) | — | **N-A (deferred)** | Serializes concurrent OAuth token refreshes. Can't trigger — BooCode's config schema *rejects* OAuth MCP servers until secret storage lands (roadmap). Note for when OAuth MCP is un-deferred. |
|
||||||
|
|
||||||
|
**Confirmed current (cross-check, no refresh needed):** compaction algorithm (incl. `tail_start_id`/`splitTurn` post-fix — verified identical), two-tier prune, truncate, run-loop (BooCode drives off live `result.toolCalls`, not a history scan — not vulnerable to opencode's interrupted-tool re-prompt bug), doom-loop guard, MCP client, permission ruleset. **Ruled out:** v2 Effect/event-sourced core, `packages/llm/` native runtime (diverges from the AI SDK v6 BooCode just adopted), adaptive-reasoning (cloud-Anthropic only), `acp-next` (BooCoder is the ACP *client*).
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## 4. llama.cpp (consumed via llama-swap / llama-sidecar — adopt features, not C++)
|
||||||
|
|
||||||
|
### 4a. ⭐ Retire the AGPL tool-call parser — **LIFT · drop-our-code**
|
||||||
|
|
||||||
|
llama-server moved to a **template-learning PEG auto-parser + lazy grammar** that parses qwen3.5/3.6's tool markup server-side into OpenAI `tool_calls`.
|
||||||
|
|
||||||
|
- **Evidence:** `common/chat-auto-parser-generator.cpp`, `common/chat-diff-analyzer.cpp` (1570 lines), `common/chat-peg-parser.cpp`; shipped `models/templates/Qwen3.5-4B.jinja` (uses BooCode's exact Pattern-2 `<tool_call><function=…><parameter=…>` + `<think>`); server emits structured `tool_calls` in **both** non-streaming and streaming (`tools/server/server-chat.cpp:421-577`), reasoning split into `reasoning_content`/`reasoning_content_delta`. `tool_choice=required` + grammar-constrained calls exist (`common/chat.cpp:290-300`).
|
||||||
|
- **Gate (the one open question):** only fires if llama-server runs with **`--jinja` + a qwen3.x template**. BooCode already treats `--jinja`/`--chat-template*` as managed flags (`llama-args-validator.ts:92-102`) and sends `tools`/`toolChoice:'auto'` through the AI SDK (`stream-phase.ts:202,438`) — the path is wired; the unknown is whether the **live llama-swap/sidecar model config passes `--jinja`** (§5).
|
||||||
|
- **What's missing:** no qwen3.x-named native handler — qwen3.6 rides the generic template-driven path. The template teaches Patterns 1 (`<tool_call>{json}`) and 2 (`<function=…>`) but **not Pattern 3 (`<invoke name=…>`)**, the Anthropic-shape residue qwen drifts into.
|
||||||
|
- **Staged plan (do not delete blind — CLAUDE.md notes qwen3.6 was unreliable):**
|
||||||
|
1. Confirm `--jinja` + Qwen3.5 template are live (add the flags if not).
|
||||||
|
2. Validate native `tool_calls` against **real qwen3.6 streaming** for one release, behind a feature flag.
|
||||||
|
3. Trim `tool-call-parser.ts` to a **clean-room `<invoke>`-only fallback** (~250 of 427 lines deletable; rewrite the remainder without Unsloth/AGPL provenance). **Net: AGPL-3.0 liability eliminated** even if a thin fallback stays.
|
||||||
|
|
||||||
|
### 4b. Config-level adopts — **LIFT · config-adopt** (pass straight through llama-swap as OpenAI-compat body fields; no binary upgrade)
|
||||||
|
|
||||||
|
- **New sampling params** (`server-task.cpp:279-290`): `top_n_sigma`, `xtc_probability/threshold`, `typical_p`, the **`dry_*` repetition family** (`dry_multiplier/base/allowed_length/penalty_last_n/sequence_breakers`), `frequency_penalty`, `repeat_penalty`. `top_n_sigma` + `dry_*` are the high-value pair for an agentic model prone to loops — ties to the doom-loop sentinel. Surface in AGENTS.md frontmatter + the validator allowlist.
|
||||||
|
- **`--reasoning-budget N`** (`LLAMA_ARG_THINK_BUDGET`) + `--reasoning on|off|auto`, default `reasoning_format=auto`: server-side cap on qwen3.6 thinking (cheaper turns) without prompt hacks, and `reasoning_content` arrives as a **separate field** — BooCode could consume it directly instead of scraping `<think>`.
|
||||||
|
|
||||||
|
### 4c. Behavioral changes — **TRACK** (no code action; awareness)
|
||||||
|
|
||||||
|
- **SSE headers sent at slot-start** (`0821c5fcf`): in stream mode, HTTP 200 + headers flush when prompt processing *begins*, before the first token. BooCode keys its stale-stream timer on **token activity**, not header arrival → safe, but time-to-headers semantics shift. Also `task_params.stream` default flipped `true → false` — harmless for BooCode (always sets `stream`), but any llama-swap/sidecar code omitting `stream` now defaults to non-streaming.
|
||||||
|
- **`/props` router-mode dummy `n_ctx:0`** (`server-models.cpp:1170-1173`): llama.cpp gained a native multi-model router; its **bare** `/props` (no `?model=`) returns `n_ctx:0`. BooCode reads `/upstream/<model>/props` which resolves to a specific model → still correct today. Silent failure mode only if a bare router `/props` is ever hit: `ctx_max=0` → rejected → negative-cache masks the misconfig → compaction budget degrades. (Aside: the native router could eventually **replace llama-swap** — separate evaluation.)
|
||||||
|
- **`LLAMA_ARG_` env-prefix unification** (`6b4e4bd58`): confirm the sidecar's `LLAMA_*` env vars use the `LLAMA_ARG_` prefix.
|
||||||
|
|
||||||
|
### 4d. **SKIP**
|
||||||
|
|
||||||
|
- Native **Anthropic Messages API** in llama-server (`test_compat_anthropic.py`) — BooCode is OpenAI-compat via the AI SDK; switching wire formats buys nothing. (Minor TRACK: could in principle back a local "claude-compatible" provider — net-new feature, not a lift.)
|
||||||
|
- Qwen 3.5/3.6 **TP granularity fix** (`8b0e0db60`) — only relevant if running qwen3.6 across 3 GPUs with tensor-parallel; then it's a binary-upgrade correctness fix, not an API change.
|
||||||
|
- HTTP ETags / `--api-key-file` / timeout bump — irrelevant behind Authelia + llama-swap.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## 5. Second fork sweep (2026-05-31) — 11 repos
|
||||||
|
|
||||||
|
Read-only agent review of everything else in `/opt/forks/` except the three already covered (paseo/opencode/llama.cpp), BooCode's own `llama-sidecar`, and `codecontext`/`codesight` (skipped on request). Repos: **conductor, superset, openchamber, happy, cline, qwen-code, amp-acp, pi-acp, claude-code, goose, unsloth.** Shallow clones (history-limited but source intact): cline, qwen-code, amp-acp, pi-acp, claude-code, goose, unsloth. Full: conductor, superset, openchamber, happy.
|
||||||
|
|
||||||
|
### 5a. openchamber (`openchamber/openchamber`, **MIT** — code-liftable) ⭐
|
||||||
|
Multi-runtime (web/PWA/Electron/VS Code) GUI for **opencode-as-warm-server** — the closest architectural sibling to BooCoder's backend. **Stronger than BooCode in exactly one dimension: opencode process-lifecycle hardening** (BooCode's v2.6 Phase 3 frontier). Divergence shaping every lift: openchamber runs **one global opencode server + one `/global/event` stream**; BooCode runs per-`(chat,agent)` sessions with per-session `event.subscribe({directory})` — so these are pattern/code-adaptation lifts, not drop-ins.
|
||||||
|
|
||||||
|
| # | Finding | Evidence (HEAD `a394a877`) | Verdict | Maps to |
|
||||||
|
|---|---------|---------|---------|---------|
|
||||||
|
| 5c | **Lifecycle hardening: health monitor + crash auto-restart + busy-aware restart** | `packages/web/server/lib/opencode/lifecycle.js` — `runHealthCheckCycle` (L896), `HEALTH_CHECK_MAX_CONSECUTIVE_FAILURES=20`, `shouldSkipRestartForBusySessions`+`STALE_BUSY_GRACE_MS` (L872/838), `startHealthMonitoring` 15s (L938), `triggerHealthCheck` (L930). BooCode's `opencode-server.ts:143` literally comments *"recovery is Phase 3"* | **LIFT · pattern** | **v2.6 Phase 3** (#5) |
|
||||||
|
| | **Port reclaim before respawn** (`killProcessOnPort` lsof+kill, `waitForPortRelease` net.connect poll) | `lifecycle.js:44,101`, used in `restartOpenCode` L595 | LIFT · code (S) | Phase 3 |
|
||||||
|
| | **Stall-detecting SSE reader + `Last-Event-ID` replay** (2048-event ring, 20s stall-abort) | `lib/event-stream/upstream-reader.js:110-131`, `global-hub.js:88-149` | LIFT · pattern (the stall-timer half is S, high-value) | hardens `runSessionEventLoop` |
|
||||||
|
| | **`OPENCODE_SERVER_PASSWORD` scheme confirmed** = `Authorization: Basic base64("opencode:"+pw)`, rotate-on-restart | `packages/vscode/src/opencode.ts:55-65,786`; `lifecycle.js:458` | CROSS-CHECK → LIFT · config | closes a known unknown (BooCode runs the warm server unsecured on loopback) |
|
||||||
|
| | Worktree layout/reaper mirrors opencode's `<data>/worktree/<projectID>/`; `removeWorktree` saga | `packages/vscode/src/gitService.ts:1062,1874` | CROSS-CHECK | Phase 3 reaper; check BooCode's worktree paths align with opencode's expected layout |
|
||||||
|
|
||||||
|
Ruled out: warm-ACP/goose/qwen/claude (openchamber is **opencode-only**), SSE part-translation/reasoning-dedup (BooCode's is more complete), Arena-equivalent, permission cards — all already-better-in-BooCode or N-A.
|
||||||
|
|
||||||
|
### 5b. cline (`cline/cline`, **Apache-2.0** — code-liftable) ⭐
|
||||||
|
Re-architected into a layered SDK. Two strong **code** lifts that hit exactly where BooCoder's write/edit surface is weakest for local quantized models.
|
||||||
|
|
||||||
|
| # | Finding | Evidence (HEAD `31a118f`) | Verdict | Maps to |
|
||||||
|
|---|---------|---------|---------|---------|
|
||||||
|
| 5d | **`git stash create` + private-ref checkpoint** — per-turn snapshot of full dirty worktree, GC-safe, invisible to `git stash list`, restorable with conversation-trim in sync | `sdk/packages/core/src/hooks/checkpoint-hooks.ts:177-253`; `session/checkpoint-restore.ts:161-189` | **LIFT · code+pattern** (#4) | checkpoint/restore — captures **external-agent** edits BooCode's `rewind` can't |
|
||||||
|
| 5e | **Fuzzy patch applier** — exact→`trimEnd`→`trim`→Levenshtein≥0.66 ladder + unicode canon (dashes/curly-quotes/nbsp) + multi-occurrence guard; unmatched→warning not throw | `extensions/tools/executors/apply-patch-parser.ts:347-431,58-83`; `editor.ts:133-143` | **LIFT · code** (#3) | BooCoder `edit_file` is exact `.includes`-or-throw (`pending_changes.ts:111`) |
|
||||||
|
| | **File-provenance carry-forward** — `## Files {Read,Modified}` ledger merged across compactions, deterministic | `extensions/context/compaction-shared.ts:351-410` | LIFT · pattern (#12) | context-mgmt |
|
||||||
|
| | **`MistakeTracker`** — counts *heterogeneous* consecutive failures (api/invalid-tool/exec), injects recovery guidance + resets vs hard-stop | `runtime/safety/mistake-tracker.ts:82-142` | LIFT · pattern (#12) | complements doom-loop (which only catches *identical* repeats) |
|
||||||
|
| | Tool-pair-atomic compaction eviction (BFS over `tool_use_id`, turn-boundary cut) | `extensions/context/basic-compaction.ts:181-205` | CROSS-CHECK | verify `selectPruneTargets` never orphans a `tool_result` |
|
||||||
|
|
||||||
|
Ruled out: prompt-caching (Anthropic `cache_control` markers — N-A, llama.cpp auto-prefix-caches), stream retry (delegated to AI SDK — same as BooCode), MCP marketplace, hub/daemon (multi-client — BooCode is single-process).
|
||||||
|
|
||||||
|
### 5f. qwen-code (`QwenLM/qwen-code` v0.17.0, **Apache-2.0** — code-liftable) ⭐
|
||||||
|
**The "qwen = one-shot PTY because ACP was HTTP-only" premise is obsolete.** qwen now ships a full stdio-ACP agent, a `qwen serve` HTTP+SSE daemon, and a Claude-Code-compatible stream-json protocol.
|
||||||
|
|
||||||
|
| # | Finding | Evidence | Verdict | Maps to |
|
||||||
|
|---|---------|---------|---------|---------|
|
||||||
|
| | **Warm `qwen --acp` is real** — multi-session `Map<sessionId,Session>`, `loadSession`/`unstable_resumeSession`, `setSessionMode`/`unstable_setSessionModel`, stdio NDJSON via `@agentclientprotocol/sdk` | `packages/cli/src/acp-integration/acpAgent.ts:308,322-351,384-568` | CROSS-CHECK → **LIFT · pattern** (#2) | **v2.6 Phase 2** — validates the openspec plan; wire goose/qwen to `acp-dispatch.ts` |
|
||||||
|
| 5g | **stream-json = Claude-compatible NDJSON** (`system`/`assistant`/`result`/`stream_event` with `content_block_delta` text/thinking/tool deltas, `usage`, `session_id`) — BooCode **parses none of it** (`dispatcher.ts:406` slices stdout opaque) | `nonInteractive/types.ts:88-262`, `StreamJsonOutputAdapter.ts` | **LIFT · pattern** (#7) | one parser serves qwen **and** claude PTY fallbacks |
|
||||||
|
| | **Resume primitives** `--resume <uuid\|title>` / `--continue` / `--session-id <uuid>` / `--fork-session` | `config/config.ts:825-985,1668-1721` | LIFT · config | mint a stable per-`(chat,agent)` UUID; parity with claude `--resume` |
|
||||||
|
| | `qwen serve` daemon + `@qwen-code/sdk` (HTTP+SSE, **`Last-Event-ID` replay ring**, better than opencode's SSE) | `commands/serve.ts:51-266`; `packages/sdk-typescript/src/daemon/*` | TRACK | stdio-ACP is cheaper now; mine its SSE-reconnect design when hardening opencode SSE (converges w/ openchamber 5c) |
|
||||||
|
|
||||||
|
Note: BooCode ships `@agentclientprotocol/sdk@^0.22.1` (newer than qwen's `^0.14.1`) — same package family, BooCode ahead; **cross-check the v0.14↔v0.22 `initialize`/capability handshake before relying on `unstable_resumeSession`** (the `unstable_` prefix signals churn). Ruled out: the `rewind` commit (`c699738`) is a qwen-TUI history-count fix, not a wire event — N-A.
|
||||||
|
|
||||||
|
### 5h. happy (`slopus/happy`, **MIT** — code-liftable) ⭐
|
||||||
|
Mobile/remote client that drives **Claude Code** via the **`@anthropic-ai/claude-agent-sdk`** (NOT PTY). A working existence-proof for BooCode's claude SDK-vs-PTY decision.
|
||||||
|
|
||||||
|
| # | Finding | Evidence (HEAD `21c6ced`) | Verdict | Maps to |
|
||||||
|
|---|---------|---------|---------|---------|
|
||||||
|
| | **Claude Agent SDK in streaming-input mode** — one persistent `query()` fed a `PushableAsyncIterable<SDKUserMessage>`; structured `system/init` (tools/skills/mcp), `assistant`, `result`, tool parts — no stdout scraping | `claude/sdk/query.ts`, `claude/claudeRemote.ts:152-259`; dep `@anthropic-ai/claude-agent-sdk@^0.2.96` | **LIFT · pattern** + resolves the decision → **lean SDK** (#9) | claude-provider direction |
|
||||||
|
| 5i | **`--resume` continuity via SessionStart-hook + JSONL watcher** → captures Claude's UUID as `claudeSessionId`, fed back as SDK `resume:`; reconnect-safe (`treatExistingAsProcessed`) | `claude/utils/generateHookSettings.ts`, `sessionScanner.ts`, `claude/session.ts:113-127` | **LIFT · code** (#9) | cleanly separates Claude's UUID from BooCode's `(chat_id,agent)` key; **transport-independent — pays off even on PTY** |
|
||||||
|
| | `canUseTool` permission callback — single chokepoint, live `setPermissionMode`, bash-prefix allow-cache | `claude/claudeRemote.ts:134,169`, `permissionHandler.ts` | CROSS-CHECK | cleaner integration point than parsing PTY permission prompts |
|
||||||
|
| | Local↔remote single-session handoff (TTY ⇄ SDK share one Claude UUID); E2E socket.io relay | `claude/loop.ts:77-115`; `api/encryption.ts` | TRACK / N-A | relay N-A (Authelia owns auth); handoff only if BooTerm⇄CoderPane session-continue is ever wanted |
|
||||||
|
|
||||||
|
### 5j. superset (`superset-sh/superset`, **Elastic License 2.0 — source-available, PATTERN-ONLY**)
|
||||||
|
Electron macOS "code editor for AI agents"; runs every agent as a **raw PTY process** and learns state purely from **hooks the agents POST back** (no editor↔agent protocol, tracks **zero** tokens/cost). All items clean-room only.
|
||||||
|
|
||||||
|
| # | Finding | Evidence (HEAD `7f3e5b3`) | Verdict | Maps to |
|
||||||
|
|---|---------|---------|---------|---------|
|
||||||
|
| 5j | **Universal-agent lifecycle hooks → normalized status** — inject a notify hook into each agent's native config (`~/.claude/settings.json`, `~/.codex/hooks.json`, opencode plugin), POST `{terminalId,eventType,agent}`; server collapses ~30 vendor event names → 5 states | `apps/desktop/.../agent-setup/*`, `templates/notify-hook.template.sh`, `host-service/.../map-event-type.ts` | **RE-DERIVE** (#10) | gives BooCode's **PTY agents (goose/qwen/claude) real working/blocked/done state** it lacks today |
|
||||||
|
| | Worktree destroy saga — preflight `inspect` (dirty/unpushed) + ordered failure semantics + in-flight guard | `host-service/.../workspace-cleanup.ts` | RE-DERIVE | Phase 3 worktree reaper |
|
||||||
|
| | Out-of-process PTY daemon w/ crash supervision + adoption (circuit-breaker, adopted-PID liveness poll) | `host-service/.../DaemonSupervisor.ts` | RE-DERIVE / TRACK | Phase 3 (BooTerm tmux already does some) |
|
||||||
|
| | Diff-line → agent-comment re-prompt loop (select lines → send to existing session or new agent) | `apps/desktop/.../DiffPane/AgentCommentComposer/*` | RE-DERIVE | review/diff UX frontier |
|
||||||
|
|
||||||
|
Ruled out: token/cost (superset tracks **none** — BooCode ahead), permission cards (BooCode's intercept-and-render is richer; superset just chimes + bypass-flags the agent), editor↔agent protocol (there is none), all SaaS/cloud/billing plumbing.
|
||||||
|
|
||||||
|
### 5k. unsloth (`unslothai/unsloth`) — **decision-settling: CONFIRMED AGPL-3.0-only**
|
||||||
|
The lifted parser + HTML→MD converter ARE AGPL-3.0; the v2 clean-room recommendation stands. Unsloth is **dual-licensed**: core `unsloth/` lib = Apache-2.0 (`LICENSE`, `pyproject.toml`), but the `studio/` subtree = **AGPL-3.0-only** — dedicated `studio/LICENSE.AGPL-3.0`, `studio/package.json` `"license":"AGPL-3.0-only"`, README §line 262 carves Studio out explicitly, and **both lifted files carry per-file SPDX headers** (`studio/backend/core/inference/{tool_call_parser.py,_html_to_md.py}` → `# SPDX-License-Identifier: AGPL-3.0-only`). BooCode's ports already carry the AGPL SPDX header (obligation on-record). Network-served ⇒ **AGPL §13 network-copyleft is the live liability.** HTML→MD can be replaced outright by a permissive lib (turndown / node-html-markdown); the tool-call parser needs a clean-room rewrite from spec (the `<tool_call>`/`<function=>` grammar is short and re-derivable).
|
||||||
|
|
||||||
|
### 5l. conductor (`conductor-oss/conductor`, **Apache-2.0**, Java) — **LOW / near-NONE**
|
||||||
|
Confirmed **Netflix/Orkes Conductor** — enterprise distributed workflow engine (5600 commits, Spring/Flyway/Cassandra), **not** the Mac Claude-Code app. Wrong scale + wrong substrate (polling workers + Redis queues vs BooCode's single-user Postgres LISTEN/NOTIFY), and BooCode already sourced its task-DAG/dispatcher/pipelines/human_inbox from `agent-hub` + Roo Boomerang. **One** worth-a-glance reference: the **retry/backoff/timeout taxonomy** (`TaskDef.java` `RetryLogic{FIXED,LINEAR,EXP}` + `TimeoutPolicy`, delay formula in `DeciderService.java:634-680`, with jitter + total-time-budget guard) — BooCode has **no retries today**; copy the *field set + three formulas* when retries land. Everything else (decider-replay engine, 24 task mappers, fork-join, sub-workflow, human-task) = N-A, already-covered or wrong-scale.
|
||||||
|
|
||||||
|
### 5m. ACP provider candidates — amp-acp **SKIP**, pi-acp **WATCH**
|
||||||
|
Both are config-only adds to BooCode's v2.3 catalog (`{extends:'acp', label, command, env}`) and both use **`@agentclientprotocol/sdk@~0.22/0.12`, proto v1 — wire-compatible with BooCode's own `@agentclientprotocol/sdk@0.22.1`** (see correction in §6).
|
||||||
|
- **amp-acp** (`tao12345666333/amp-acp`, Apache-2.0): adapter for Sourcegraph **Amp**. `npx -y amp-acp` + `AMP_API_KEY`. **SKIP** — Amp is a **paid cloud product with no self-host / no BYO-key / no local-model path**; can't point at llama-swap. Keep only as the canonical *"does add-from-catalog work"* smoke entry (lowest-risk Apache-2.0 ACP adapter).
|
||||||
|
- **pi-acp** (`svkozak/pi-acp`, MIT): bridge for **pi** (spawns `pi --mode rpc`). `npx -y pi-acp`, pi free + self-hostable, dynamic model discovery. **WATCH** — but found **no evidence pi supports an OpenAI-compatible/llama-swap base URL** (cloud BYO-keys only today) + v0.0.27 maturity ("MVP", MCP not wired). Re-evaluate if pi adds a local provider — then it's a strong config-only ADD.
|
||||||
|
|
||||||
|
### 5n. claude-code & goose — low/cosmetic
|
||||||
|
- **claude-code** (`anthropics/claude-code`, depth-1): the public **issue-tracker/docs repo, not source.** Thin. No stream-json schema doc (keep relying on observed output). Notables: `CLAUDE_CODE_SESSION_ID` env injected into Bash-tool subprocesses (hook↔session correlation); `examples/settings/*.json` permission/sandbox shapes; `SKILL.md` frontmatter is simpler (`name/description/version`) than BooCode's `eval.yaml`. The one example hook (`bash_command_validator`) is the same family BooCode already vendored. **Nothing net-new liftable.**
|
||||||
|
- **goose** (`block/goose`, depth-1, Apache-2.0 Rust → pattern-only): the **AAIF/Linux-Foundation move is cosmetic** — binary `goose`, `goose acp` invocation, and `~/.config/goose/` config path all **UNCHANGED**; only org/URLs changed (`block/goose` → `aaif-goose/goose`). **Watch:** grep BooCode install docs for `block/goose` URLs (will eventually 404). **For v2.6 Phase 2:** goose ACP supports multi-session + mid-session model/mode switch + session persistence, but **no `loadSession`/resume method surfaced** → cross-restart resume looks thinner than opencode's; don't assume opencode-style `agent_sessions` resume works identically for goose.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## 6. Open decisions / things to think about
|
||||||
|
|
||||||
|
1. **The jinja gate (blocks #1, the top item).** Is `--jinja` + a qwen3.x template live in the llama-swap/sidecar model config? Read-only to answer, but the config may live with the sidecar on sam-desktop (`100.101.41.16`) — needs Sam's OK before any SSH. *This single check decides whether the AGPL-parser retirement is actionable now or needs a config change first.*
|
||||||
|
2. **Claude transport: SDK vs PTY — now evidenced, leaning SDK.** `happy` (§5h) is a working existence-proof that `@anthropic-ai/claude-agent-sdk` in streaming-input mode drives Claude Code with structured events (tool calls, reasoning, `system/init` tool/skill/mcp lists, usage) and clean continuity — richer than PTY stdout-scraping. **Decision narrowed to: adopt the SDK** (net-new integration, ~100-line streaming-input pump) **vs. stay PTY + just add `--resume`.** Independent of warm-ACP Phase 2. Note the continuity mechanism (§5i hook + jsonl-watcher → `claudeSessionId`) is **transport-independent**, so ship it either way.
|
||||||
|
3. **`stream-json` parser is shared infrastructure, not a per-agent chore.** qwen-code (§5g) and claude-code emit the *same* Claude-Code-compatible NDJSON. One parser keyed on `type` / `stream_event.event.type` unlocks tool/reasoning/usage surfacing for **both** qwen and claude PTY fallbacks (today both are sliced opaque). Decide whether to build it as a shared module now (cheap) rather than twice later.
|
||||||
|
4. **Transcript/session verification before resume (shared gap).** Neither Paseo nor BooCode (nor openchamber, nor goose's ACP) verifies the session/transcript exists on disk before resuming — true for opencode, claude, qwen. Folds into v2.6 Phase 3 (crash recovery + active supervision, now lifting from openchamber §5c). Decide whether "resume blindly, recover on failure" is good enough for single-user, or worth a pre-resume existence check. **Caveat:** goose ACP exposes no `loadSession`/resume (§5n) → its cross-restart resume needs a different design than opencode's.
|
||||||
|
5. **Usage *and status* normalization scope.** Two converging gaps: (a) **tokens/cost** — the opencode token slice (#8) converges with paseo `AgentUsage`; (b) **liveness/status** — superset's notify-hook pattern (§5j, #10) is the only way to know whether a one-shot PTY agent (goose/qwen/claude) is working / blocked-on-permission / done. Decide whether to design one normalized per-`(chat,agent)` "agent telemetry" shape (tokens + status) up front so all providers slot in, or ship opencode-token-only and generalize at Phase 2.
|
||||||
|
6. **Correction — ACP SDK package.** This doc and the roadmap state BooCode uses `@zed-industries/agent-client-protocol`; the live `apps/coder/package.json` actually declares **`@agentclientprotocol/sdk@^0.22.1`** (verified installed). Both amp-acp and pi-acp use the same package, so the "version-drift" worry is moot. Worth correcting in `boocode_roadmap.md`'s lift table on the next pass.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## 7. Housekeeping
|
||||||
|
|
||||||
|
- **Stale `.bak` in the working tree:** `apps/server/src/services/inference/tool-phase.ts.bak-20260531` (today, 15.5 KB). Violates CLAUDE.md's "don't accumulate `.bak-*`". Dated today and `tool-phase.ts` is on the active path — may be an in-progress safety copy. **Confirm before removing.**
|
||||||
|
- **Unshallow `/opt/forks/llama.cpp`** (`git fetch --unshallow`) before the next review so commit-level attribution is possible. (opencode was unshallowed mid-review; cline/qwen-code/amp-acp/pi-acp/claude-code/goose/unsloth remain shallow but their source was intact.)
|
||||||
|
- **Grep BooCode install docs/scripts for `block/goose` URLs** — goose moved to `aaif-goose/goose` (§5n); old release URLs will eventually 404.
|
||||||
|
- **Correct the ACP-SDK package name** in `boocode_roadmap.md`'s lift table → `@agentclientprotocol/sdk@0.22.1` (§6.6).
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
## 8. Roadmap mapping (where each actionable lands)
|
||||||
|
|
||||||
|
| Roadmap slot | Items from this review |
|
||||||
|
|---|---|
|
||||||
|
| **v2.6 Phase 2** (warm ACP goose/qwen) | #2 warm-ACP backend — **validated by qwen's own `qwen --acp`** (§5f); #7 parse qwen/claude stream-json in the one-shot fallback |
|
||||||
|
| **v2.6 Phase 3** (lifecycle hardening) | **#5 openchamber lifecycle hardening** (health monitor + crash restart + port reclaim + stall-SSE — §5c, supersedes the paseo re-derive); worktree-archive cascade (paseo) + superset destroy-saga (§5j); LRU-bound caches; pre-resume session verification |
|
||||||
|
| **v2.6 Phase 1 UX** | #6 interrupt-bug fix; #8 opencode token/ctx usage; richer SSE arms (compaction surfacing) |
|
||||||
|
| **Write/edit robustness (NEW batch)** | **#3 fuzzy patch applier** + **#4 git-stash checkpoint** (cline §5b) — both directly harden BooCoder's edit/rewind surface for local models |
|
||||||
|
| **Cross-agent telemetry (NEW)** | #10 superset notify-hook → normalized **status** for PTY agents (§5j); pairs with #8 token usage |
|
||||||
|
| **Standalone claude-provider batch** | #9 `--resume` via hook/jsonl-watcher (§5i) + the SDK-vs-PTY decision (now lean-SDK, §6.2); #12 MistakeTracker + file-provenance ledger (cline) |
|
||||||
|
| **Inference / license-debt batch** | #1 AGPL parser retirement (**AGPL confirmed §5k**; gated on the jinja check §6.1); #11 sampling/reasoning-budget config adopts |
|
||||||
|
| **BooChat resilience (opportunistic)** | stall-timeout + retry/backoff (opencode); tool-pair-atomic prune cross-check (cline §5b) |
|
||||||
|
| **Provider catalog** | amp-acp = keep as add-from-catalog **smoke test only** (§5m); pi-acp = WATCH for a local-provider mode |
|
||||||
|
| **Deferred / gated** | subagent permission demux (needs opencode-SSE permission cards first); MCP auth lock (needs OAuth MCP un-deferred); `qwen serve` HTTP backend (stdio-ACP cheaper) |
|
||||||
|
| **Not actionable** | conductor (wrong scale — only the retry-taxonomy reference §5l); claude-code public repo (docs only §5n) |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# BooCode roadmap (v1.x–v2.x)
|
# BooCode roadmap (v1.x–v2.x)
|
||||||
|
|
||||||
Last updated: 2026-05-26
|
Last updated: 2026-05-31
|
||||||
|
|
||||||
> **Companion doc:** `boocode_code_review.md` holds the full external-repo inventory, lift rationale, and license analysis. This document is the canonical source for shipping state, version ordering, and what's planned vs. shipped.
|
> **Companion doc:** `boocode_code_review.md` holds the full external-repo inventory, lift rationale, and license analysis. This document is the canonical source for shipping state, version ordering, and what's planned vs. shipped.
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ Last updated: 2026-05-26
|
|||||||
BooCode is a **3-app monorepo** at `/opt/boocode/` (locked 2026-05-22):
|
BooCode is a **3-app monorepo** at `/opt/boocode/` (locked 2026-05-22):
|
||||||
|
|
||||||
- **BooChat** (`apps/server` + `apps/web`, port `9500`, `code.indifferentketchup.com`) — read-only chat with file-inspection tools. Backend in `apps/server`, SPA in `apps/web`. Database `boochat` (renamed from `boocode` at v2.0).
|
- **BooChat** (`apps/server` + `apps/web`, port `9500`, `code.indifferentketchup.com`) — read-only chat with file-inspection tools. Backend in `apps/server`, SPA in `apps/web`. Database `boochat` (renamed from `boocode` at v2.0).
|
||||||
- **BooCoder** (`apps/coder`, port `9502`, `coder.indifferentketchup.com`) — write tools + external-CLI dispatch. **Shipped v2.0.0–v2.2.1.** Host systemd service (not Docker since v2.1.0). In-process inference (with `pending_changes` table) AND Paseo-style ACP dispatch for seven providers (cursor, opencode, goose, claude, qwen, copilot + native boocode) with PTY fallback where ACP is unavailable.
|
- **BooCoder** (`apps/coder`, port `9502`, `coder.indifferentketchup.com`) — write tools + external-CLI dispatch. **Shipped v2.0.0–v2.6.4.** Host systemd service (not Docker since v2.1.0). In-process inference (with `pending_changes` table) AND Paseo-style ACP dispatch for five providers (opencode, goose, claude, qwen + native boocode; cursor + copilot retired at v2.5.3) with PTY fallback where ACP is unavailable. Provider lifecycle is config-backed (`data/coder-providers.json`, enable/disable, two-tier probe — shipped v2.5.4–v2.5.13). opencode now runs as a **warm HTTP server** with persistent per-chat sessions (v2.6 Phase 1); goose/qwen/claude still dispatch one-shot.
|
||||||
- **BooTerm** (`apps/booterm`, port `9501`) — PTY/tmux/xterm.js. **Live since May 2026.** bookworm-slim + node-pty + tmux + xterm.js. Tmux session per pane (`bc-<uuid>`), SSH-out works (openssh-client + gosu in the image). Shares Postgres database `boochat`.
|
- **BooTerm** (`apps/booterm`, port `9501`) — PTY/tmux/xterm.js. **Live since May 2026.** bookworm-slim + node-pty + tmux + xterm.js. Tmux session per pane (`bc-<uuid>`), SSH-out works (openssh-client + gosu in the image). Shares Postgres database `boochat`.
|
||||||
|
|
||||||
Caddy → Authelia → Tailscale → `100.114.205.53` → 9500/9501/9502. Three apps, **one shared Postgres** (Docker service `boocode_db`, database name `boochat`).
|
Caddy → Authelia → Tailscale → `100.114.205.53` → 9500/9501/9502. Three apps, **one shared Postgres** (Docker service `boocode_db`, database name `boochat`).
|
||||||
@@ -348,9 +348,43 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
|
|||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
|
## Shipped (v2.2.2–v2.6.4 — interactive ACP, provider lifecycle, persistent agent sessions)
|
||||||
|
|
||||||
|
All tags `vMAJOR.MINOR.PATCH-slug`, monotonic per minor, assigned at ship time (planning slugs differ — see the numbering-discipline note below). `CHANGELOG.md` is the canonical per-tag record. **Note on numbering divergence:** the *planned-feature* "v2.3 — Provider lifecycle" actually shipped under the **v2.5.4–v2.5.13** tags; the *planned-feature* "v2.4 — BooCoder as ACP agent" remains **unshipped** even though v2.4.0/v2.4.1 *tags* shipped unrelated content (Unsloth lifts, sidecar routing). The patch-tag thread and the conceptual-milestone thread have diverged — read tags as the ship record, the `## v2.x` feature sections below as the milestone plan. The v2.3.0–v2.5.1 tags were never CHANGELOG-backfilled; summarized here from commit bodies.
|
||||||
|
|
||||||
|
- `v2.2.2-xml-placeholder-reject` — reject placeholder XML tool args (`...`, `<path>`, empty/whitespace, angle-bracket sentinels) at parse time; appends raw block to prose instead of silent-deleting. Fixes qwen3.6 answer-then-spurious-tools duplicate-row tail
|
||||||
|
- `v2.3.0-sampling-params-ask-user` — per-agent sampling params (`top_p`/`top_k`/`min_p`/`presence_penalty`) in AGENTS.md frontmatter threaded through inference (null = omit, preserve provider default); `ask_user_input` interactive card wired into both BooCoder frontends (CoderPane + standalone coder SPA)
|
||||||
|
- `v2.3.1-permission-questions` — enrich ACP `permission_requested` frame with `kind` (`tool`|`question`|`plan`|`elicitation`) + `input` + `description`; PermissionCard renders interactive radio/checkbox forms for AskUserQuestion; ACP `createElicitation` (experimental) JSON-Schema-driven forms
|
||||||
|
- `v2.3.2-coder-answer-endpoint` — fix `ask_user_input` submit in CoderPane (register `answer_user_input` on the boocoder service; `apiPrefix` routes through `/api/coder/...` so the right inference runner picks up the answer)
|
||||||
|
- `v2.4.0-unsloth-studio-lift` — port of Unsloth Studio modules: `tool-call-parser.ts` (replaces `xml-parser.ts`; balanced-brace JSON scanner, `hasToolSignal`/`stripToolMarkup`/`parseToolCallsFromText`, stripping at all 3 final-write sites) + `web/html-to-md.ts` (parse5 HTML→Markdown for `web_fetch`). **License flag:** Unsloth Studio is AGPL-3.0 — tension with the roadmap's MIT / no-AGPL-code-lift commitment; revisit before any network-served distribution
|
||||||
|
- `v2.4.1-sidecar-routing` — route per-agent `llama_extra_args` to `LLAMA_SIDECAR_URL` via `X-Agent-Flags` (boot guard if set but URL unset); `resolveRoute` + PrefixFingerprint `route` field. AGENTS.md tool-gap fix: 8 post-hoc tools (`request_read_access`, `view_truncated_output`, `ask_user_input`, `git_status`, …) added to every agent's whitelist
|
||||||
|
- `v2.5.0-task-model` — lightweight task-model services (`TASK_MODEL_URL` dedicated llama-server, falls back to `LLAMA_SWAP_URL`+`FAST_MODEL`) for auto-naming/search-rewrite/tags/summaries; search-query rewriting on step 0 when web tools enabled; `sessions.tags` column
|
||||||
|
- `v2.5.1-budget-100` — tool-call budgets raised 50/10/50 → **100/100/100** (read-only / non-read-only / no-agent); per-agent `max_tool_calls` still overrides. `.claude/worktrees/` added to `.codecontextignore`
|
||||||
|
- `v2.5.2-coder-ux-fixes` — dispatcher reacts immediately via Postgres `LISTEN/NOTIFY` (`tasks_new` trigger, 2s poll fallback); mobile nav-drawer bfcache fix (`useViewport` re-syncs on `pageshow`/`visibilitychange`); reasoning "Thinking" collapsible in MessageBubble (ACP `agent_thought_chunk` + native `reasoning_parts`); paste-to-chip verbatim; "New file from pasted text" RightRail affordance; DiffPanel approve/reject repointed to real routes. Ships the `v2-6-persistent-agent-sessions` openspec as planning docs only
|
||||||
|
- `v2.5.3-remove-cursor-copilot` — retire cursor + copilot providers entirely (argv cases, manifest, command maps, cursor model-CLI branch, `cursor-models.ts`). Built-ins now: claude, opencode, goose, qwen, native boocode
|
||||||
|
- `v2.5.4-provider-lifecycle-phase1` — **(v2.3 milestone, phase 1/5)** config-backed provider layer (`CODER_PROVIDERS_PATH` default `/data/coder-providers.json`; `provider-config.ts` never-throws loader; `buildResolvedRegistry` merge) over built-ins; `agent-probe` iterates the resolved registry. No runtime change when no config file exists
|
||||||
|
- `v2.5.5-provider-lifecycle-phase2` — **(phase 2/5)** snapshot lifecycle status (`loading`|`ready`|`unavailable`|`error`) + `enabled` flag; always lists every registered provider; two-tier probe (fast `which` vs cold ACP, skipped unless forced / `PROVIDER_PROBE_TTL_MS` 24h stale / DB-empty); `provider-types-parity.test.ts`
|
||||||
|
- `v2.5.6-provider-lifecycle-phase3` — **(phase 3/5)** generic ACP dispatch (`resolveLaunchSpec` from config `launchCommand`; spawn `spec.binary`/`args`/`env`); built-in dispatch byte-identical (regression-tested). Config-defined custom ACP providers dispatch with no new switch case
|
||||||
|
- `v2.5.7-claude-models-and-picker-fix` — fix the empty provider picker (a v2.5.5 regression: `getProviderSnapshot` returned sync `loading` entries the composer filtered out → now awaits build, returns terminal entries); wire config `models` (replace) / `additionalModels` (merge); claude static models bumped to opus/sonnet/haiku latest-aliases + pinned full names
|
||||||
|
- `v2.5.8-mobile-composer-row` — AgentComposerBar mobile fix (dot + refresh as one right-aligned unit, was wrapping); Mode picker icon-only on mobile via `CompactPicker` `iconOnly`. Desktop unchanged
|
||||||
|
- `v2.5.9-agent-slash-commands` — segmented per-agent slash menu (active agent's commands first, BooCoder skills second; opt-in `groups` prop, BooChat flat path byte-identical); skills now run under the selected external agent (skill body injected into a dispatched task); landing-chat skill-invoke fix
|
||||||
|
- `v2.5.10-opencode-live-commands` — capture opencode's live ACP `available_commands` (poll for the async `available_commands_update`, was racing to 0); persist to new `available_agents.commands` JSONB; serve merged on the tier-2-probe-skip path
|
||||||
|
- `v2.5.11-claude-skill-discovery` — surface Claude Code's real enabled commands + plugin skills in the coder slash menu (`claude-command-discovery.ts` reads `~/.claude/commands` + `enabledPlugins` skills/commands); three icon'd groups (agent commands / agent skills / BooCoder skills); `AgentCommand.kind`
|
||||||
|
- `v2.5.12-provider-lifecycle-phase4` — **(phase 4/5)** HTTP API: `GET`/`PATCH /api/providers/config`, optional-subset `POST /refresh`, `GET /:id/diagnostic`. PATCH ordering validate→save→reload→clear (malformed body → 422 no-write; save-fail → 500 no-divergence); `mergeProviderConfigPatch`; +28 tests
|
||||||
|
- `v2.5.13-provider-lifecycle-phase5` — **(phase 5/5, closes the v2.3 arc)** Settings → Providers UI (status badge, enable/disable toggle, per-provider refresh, plaintext diagnostic); composer filters to `enabled && ready|loading`; curated ACP catalog + `AddProviderModal`; two mobile fixes (Settings reachable on phones; modal scroll-containment). `docs/DEFERRED-WORK.md` §2 marked addressed
|
||||||
|
- `v2.5.14-claude-md` — docs-only CLAUDE.md session-learnings (stale boocoder process after build, container `build:.` deploys working tree, wholesale `PATCH /providers/config` merge, one-shot external dispatch has no ctx tracking, `ui/` switch/sheet fallbacks, mobile Dialog scroll recipe); backfills v2.5.7–v2.5.11 doc bullets
|
||||||
|
- `v2.5.15-acp-path-guard` — security: separator-bounded worktree path guard in `acp-client-fs.ts` (closes a sibling-prefix `<worktree>-evil/` escape; `writeWorktreeTextFile` bypasses `pending_changes`, writes disk directly) via shared `resolveInWorktree` + regression test; stop tracking live `data/coder-providers.json` (gitignore + `data/coder-providers.example.json` reference; loader falls back to built-ins-only)
|
||||||
|
- `v2.6.0-phase0-foundations` — **(v2.6 Phase 0, no behavior change)** schema + interface scaffold: `session_worktrees` (one shared worktree per session) + `agent_sessions` (one backend session per `(session, agent)`) tables, `pending_changes.agent` attribution column; `AgentBackend`/`AgentSessionHandle` interface + normalized transport-agnostic `AgentEvent` union (types only)
|
||||||
|
- `v2.6.1-phase1-opencode` — **(Phase 1)** opencode as a **warm HTTP server** (`opencode serve` per BooCoder process, one opencode session per BooCode session resumed across turns via `agent_sessions`); single SSE read loop, Paseo reasoning-dedup, inactivity watchdog, stale-session guard (`config_hash` = `opencode_server|<model>`, excludes the ephemeral port so cross-restart resume survives). Hard-won: opencode streams `session.next.*` (not `message.part.*`), `event.subscribe()` must pass the worktree `directory`, models must be `llama-swap/`-prefixed + in opencode's config. Bundled: dcp-message-id strip, reopen-pane control, `[+]`/split separation, auto-name on session model, `systematic-debugging` slash command. Known limit (closed in v2.6.2): single SSE scoped to the most-recent directory
|
||||||
|
- `v2.6.2-delete-guard-and-sse` — session-delete work-loss guard (server gates `DELETE /api/sessions/:id`: reads `session_worktrees`, calls BooCoder `/worktree-risk` which runs git on the host; dirty/unpushed/unmerged → 409 + per-worktree `RiskReport[]`, `force` bypasses, fail-closed; sidebar block dialog distinguishes at-risk from couldn't-verify, never auto-commits). **Per-session SSE (P1.5-a):** one `event.subscribe({directory})` per live opencode session, each with an `AbortController`, so sessions in different worktrees stream concurrently (was: second silently dropped); `sessionID` demux guard + zombie-loop fix
|
||||||
|
- `v2.6.3-chatkey-and-skills` — re-key `agent_sessions` to **`(chat_id, agent)`** (P1.5-b: the tab/chat is the agent-context unit; two opencode tabs in one session = two contexts sharing one worktree); `tasks.chat_id` threaded end-to-end (`runOpenCodeServerTask` resolve-or-creates a chat for session-less creators); first-class `worktrees` table (one-per-session, survives session delete) supersedes the defanged `session_worktrees`; `agent_sessions.chat_id` CASCADEs from `chats`; stateful cross-chunk dcp-message-id stripper; `committing-changes` + `using-worktrees` judgment skills in `data/skills/boocode/` + parser-safe `data/AGENTS.md` preamble
|
||||||
|
- `v2.6.4-agent-sessions-fk` — converge the live `agent_sessions.session_id` FK `CASCADE → SET NULL` (standalone `confdeltype`-guarded `DO` block, idempotent — the P1.5-b re-key gate skipped already-re-keyed DBs and left it diverged); CLAUDE.md doc-sync (per-session SSE, `(chat_id, agent)` re-key, `data/AGENTS.md` parsing + `data/skills/<vendor>/` conventions)
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
## v2.3 — Provider lifecycle (Paseo-style registry)
|
## v2.3 — Provider lifecycle (Paseo-style registry)
|
||||||
|
|
||||||
**Planned.** Config-backed provider registry (`/data/coder-providers.json`), merged built-ins + overrides, enable/disable toggles, two-tier probe (fast binary vs slow ACP session), generic ACP spawn from config without new code paths. Depends on v2.2 snapshot wire shape. Openspec: `openspec/changes/v2-3-provider-lifecycle/`. See `CURRENT.md`.
|
**Shipped across `v2.5.4`–`v2.5.13` (5 phases, 2026-05-29).** Config-backed provider registry (`data/coder-providers.json`), merged built-ins + overrides, enable/disable toggles in Settings → Providers, two-tier probe (fast binary vs slow ACP session, TTL-gated), generic ACP spawn from config without new code paths, HTTP config/refresh/diagnostic API, curated add-from-catalog. The milestone shipped under v2.5.x patch tags (not "v2.3.x") because patch numbers are assigned at ship time. Openspec: `openspec/changes/v2-3-provider-lifecycle/` (design §2–§6 map to phases 1–4; phase 5 = UI). `docs/DEFERRED-WORK.md` §2 marked addressed; Tier-2 follow-ups (WS `provider_snapshot_updated` frame, `available_agents.enabled` column, shared types package, MCP provider tools) stay deferred.
|
||||||
|
|
||||||
**Lift source:** Paseo provider docs (design only — no AGPL code lift).
|
**Lift source:** Paseo provider docs (design only — no AGPL code lift).
|
||||||
|
|
||||||
@@ -360,6 +394,8 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
|
|||||||
|
|
||||||
## v2.4 — BooCoder as ACP agent (driveable from external editors)
|
## v2.4 — BooCoder as ACP agent (driveable from external editors)
|
||||||
|
|
||||||
|
**Status: not shipped.** This is a conceptual milestone, not yet built. The `v2.4.0`/`v2.4.1` *patch tags* shipped unrelated content (Unsloth Studio parser/HTML-to-md lift, llama-sidecar routing) — patch numbers are assigned at ship time and have outrun the milestone plan. The outbound ACP-agent surface below is still future work.
|
||||||
|
|
||||||
**Goal:** expose `boocoder acp` so Zed, JetBrains, Avante.nvim, CodeCompanion.nvim can drive BooCoder as their agent. Outbound exposure of the BooCoder write-tool surface to ACP-compatible editors.
|
**Goal:** expose `boocoder acp` so Zed, JetBrains, Avante.nvim, CodeCompanion.nvim can drive BooCoder as their agent. Outbound exposure of the BooCoder write-tool surface to ACP-compatible editors.
|
||||||
|
|
||||||
**Scope:**
|
**Scope:**
|
||||||
@@ -378,6 +414,32 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
|
|||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
|
## v2.6 — Persistent agent sessions (warm processes + OpenCode server)
|
||||||
|
|
||||||
|
**Goal:** make a BooCode chat map to a **persistent agent backend + a persistent worktree** that live for the whole conversation, so turns are warm and the agent sees its own accumulating edits. Replaces the one-shot-per-task model (fresh worktree + process spawn + ACP handshake every turn) with Paseo's pattern: OpenCode as a long-lived HTTP server, goose/qwen as warm stdio-ACP processes. Reasoning passthrough was already solved in v2.5.2's Thinking block — this batch is about persistence, not capability.
|
||||||
|
|
||||||
|
**Decisions locked:** persistent worktree per session (shared across agents); free agent-switch with per-agent memory (one backend session per `(chat, agent)` pair, re-keyed from `(session, agent)` in P1.5-b); OpenCode → one shared `opencode serve` HTTP server (multi-session, directory-routed); goose/qwen → warm stdio ACP per live session; claude stays one-shot PTY.
|
||||||
|
|
||||||
|
**Shipped so far:**
|
||||||
|
|
||||||
|
1. `v2.6.0-phase0-foundations` ✅ — schema + `AgentBackend`/`AgentEvent` interface scaffold (no behavior change).
|
||||||
|
1. `v2.6.1-phase1-opencode` ✅ — OpenCode warm-server backend, per-chat resumable session, SSE demux, reasoning dedup, watchdog, stale-session guard.
|
||||||
|
1. `v2.6.2-delete-guard-and-sse` ✅ — session-delete work-loss guard + **per-session SSE (P1.5-a)** so concurrent opencode sessions in different worktrees stream independently.
|
||||||
|
1. `v2.6.3-chatkey-and-skills` ✅ — **P1.5-b** re-key `agent_sessions` to `(chat_id, agent)`; first-class `worktrees` table; `tasks.chat_id` threading; cross-chunk dcp-strip; judgment skills.
|
||||||
|
1. `v2.6.4-agent-sessions-fk` ✅ — converge `agent_sessions.session_id` FK to `SET NULL`; doc-sync.
|
||||||
|
|
||||||
|
**Remaining (per openspec `v2-6-persistent-agent-sessions/tasks.md`):**
|
||||||
|
|
||||||
|
- **Phase 1 UX** — DiffPanel per-change agent attribution (`pending_changes.agent` badges), resumed/new-session chip on AgentComposerBar (`GET /api/sessions/:id/agent-sessions`), staging-boundary hint.
|
||||||
|
- **Phase 2 — warm ACP backend (goose, qwen)** — persistent `SpawnedACPProcess` connection reused across turns (one `session/new`, many prompts); dispatcher routes goose/qwen to the warm backend; switch round-trip smoke (opencode → boocode → opencode resumes the same session).
|
||||||
|
- **Phase 3 — lifecycle hardening** — idle TTL eviction per `(chat, agent)`, crash recovery, chat-close/archive worktree cleanup, orphan reaper + max-live-worktrees LRU cap, re-baseline diff after `apply_pending`, reconnect test.
|
||||||
|
|
||||||
|
**Lift sources:** `getpaseo/paseo` (design only — OpenCode-as-HTTP-server pattern, `streamedPartKeys` reasoning dedup), `@opencode-ai/sdk` (v2 client), `/opt/forks/opencode`.
|
||||||
|
|
||||||
|
**Dependencies:** v2.2 (ACP dispatch) + v2.3 provider lifecycle (registry/snapshot). Openspec: `openspec/changes/v2-6-persistent-agent-sessions/`.
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
## v2.1.0 — Provider picker + model discovery
|
## v2.1.0 — Provider picker + model discovery
|
||||||
|
|
||||||
**Shipped `v2.1.0-provider-picker`.** Provider registry with 5 providers (boocode, opencode, goose, claude, qwen). Model discovery via `LLAMA_SWAP_URL/upstream/<model>/props`. `/api/providers` route returns installed providers with models. v2.1 `ProviderPicker` UI **superseded by `AgentComposerBar` in v2.2.** Agent-probe startup probe discovers installed agents on host, their versions, ACP support, and models. Booterm SSH host configurable via `BOOTERM_SSH_HOST`/`BOOTERM_SSH_USER` env vars.
|
**Shipped `v2.1.0-provider-picker`.** Provider registry with 5 providers (boocode, opencode, goose, claude, qwen). Model discovery via `LLAMA_SWAP_URL/upstream/<model>/props`. `/api/providers` route returns installed providers with models. v2.1 `ProviderPicker` UI **superseded by `AgentComposerBar` in v2.2.** Agent-probe startup probe discovers installed agents on host, their versions, ACP support, and models. Booterm SSH host configurable via `BOOTERM_SSH_HOST`/`BOOTERM_SSH_USER` env vars.
|
||||||
@@ -412,7 +474,7 @@ Per-session Docker sandbox spawned by BooCoder on first write. Only project path
|
|||||||
|-------------------------------|---------------------|-----------------------------|------------------------------------------------------------------------|----------------------|
|
|-------------------------------|---------------------|-----------------------------|------------------------------------------------------------------------|----------------------|
|
||||||
|`boochat` (was `boocode`) |`100.114.205.53:9500`|`/opt:/opt:ro` |Read-only chat + SPA host + MCP client |Live (renames at v2.0)|
|
|`boochat` (was `boocode`) |`100.114.205.53:9500`|`/opt:/opt:ro` |Read-only chat + SPA host + MCP client |Live (renames at v2.0)|
|
||||||
|`booterm` |`100.114.205.53:9501`|`/opt:/opt` |PTY/tmux terminal sessions |**Live (May 2026)** |
|
|`booterm` |`100.114.205.53:9501`|`/opt:/opt` |PTY/tmux terminal sessions |**Live (May 2026)** |
|
||||||
|`boocoder` (host systemd) |`100.114.205.53:9502`|full host FS (policy-gated) |Write tools + ACP client + MCP client + MCP server + external-CLI dispatch|**Shipped v2.0.0–v2.2.1** (systemd since v2.1.0) |
|
|`boocoder` (host systemd) |`100.114.205.53:9502`|full host FS (policy-gated) |Write tools + ACP client + MCP client + MCP server + external-CLI dispatch + warm opencode server|**Shipped v2.0.0–v2.6.4** (systemd since v2.1.0) |
|
||||||
|**`boochat`** (Docker service `boocode_db`)|`127.0.0.1:5500` |`boocode_pgdata` volume |Postgres 16-alpine (shared by all three) |**Live** (DB renamed from `boocode` at v2.0)|
|
|**`boochat`** (Docker service `boocode_db`)|`127.0.0.1:5500` |`boocode_pgdata` volume |Postgres 16-alpine (shared by all three) |**Live** (DB renamed from `boocode` at v2.0)|
|
||||||
|`codecontext` |`:8080` (internal, Docker network) |`/opt:/opt:ro`|Go HTTP sidecar for code graph tools |**Live (v1.12.0)** |
|
|`codecontext` |`:8080` (internal, Docker network) |`/opt:/opt:ro`|Go HTTP sidecar for code graph tools |**Live (v1.12.0)** |
|
||||||
|
|
||||||
@@ -459,7 +521,12 @@ term.indifferentketchup.com → booterm :9501 (or routed under code.
|
|||||||
- **v1.16:** `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`
|
- **v1.16:** `repo_health_cache (project_id, file_hashes_sig, payload JSONB, created_at)`
|
||||||
- **v2.0 (shipped):** `pending_changes`, `tasks`, `available_agents`, `human_inbox` view; database renamed `boocode` → `boochat`
|
- **v2.0 (shipped):** `pending_changes`, `tasks`, `available_agents`, `human_inbox` view; database renamed `boocode` → `boochat`
|
||||||
- **v2.2 (shipped):** none (provider snapshot + ACP dispatch are runtime/services; pane chat scoping uses existing `sessions.workspace_panes` + `chats`)
|
- **v2.2 (shipped):** none (provider snapshot + ACP dispatch are runtime/services; pane chat scoping uses existing `sessions.workspace_panes` + `chats`)
|
||||||
- **v2.4:** none (`boocoder acp` is a new entry point, not a schema change)
|
- **v2.5.0 (shipped):** `sessions.tags` column (task-model tagging)
|
||||||
|
- **v2.5.10 (shipped):** `available_agents.commands jsonb` column (persisted ACP `available_commands`)
|
||||||
|
- **v2.6.0 (shipped):** `session_worktrees` (one shared worktree per session) + `agent_sessions` (one backend session per `(session, agent)`, `backend`/`status` CHECKs) tables; `pending_changes.agent` attribution column. All idempotent (`IF NOT EXISTS`)
|
||||||
|
- **v2.6.3 (shipped):** re-key `agent_sessions` to `(chat_id, agent)` (`chat_id` FK CASCADEs from `chats`; `session_id`/`worktree_id` informational); new first-class `worktrees` table (one-per-session, `session_id` `SET NULL`) supersedes the defanged `session_worktrees`; `tasks.chat_id` column
|
||||||
|
- **v2.6.4 (shipped):** `agent_sessions.session_id` FK converged `CASCADE → SET NULL` (standalone `confdeltype`-guarded `DO` block; idempotent)
|
||||||
|
- **v2.4 (planned, not shipped):** none (`boocoder acp` is a new entry point, not a schema change)
|
||||||
|
|
||||||
-----
|
-----
|
||||||
|
|
||||||
@@ -494,8 +561,9 @@ Full inventory and rationale in `boocode_code_review.md`. Headline items below;
|
|||||||
|`spirituslab/codesight` |MIT-ish |Repo health analyzer (`analyze.mjs`) |v1.16 |
|
|`spirituslab/codesight` |MIT-ish |Repo health analyzer (`analyze.mjs`) |v1.16 |
|
||||||
|`plandex-ai/plandex` |MIT |Pending-changes data model + diff/apply/rewind UX |v2.0 |
|
|`plandex-ai/plandex` |MIT |Pending-changes data model + diff/apply/rewind UX |v2.0 |
|
||||||
|`Dominic789654/agent-hub` |Apache-2.0 |**Task DAG schema, dispatcher worker, project registry, human inbox** — primary architectural template for v2.0 dispatcher|v2.0 |
|
|`Dominic789654/agent-hub` |Apache-2.0 |**Task DAG schema, dispatcher worker, project registry, human inbox** — primary architectural template for v2.0 dispatcher|v2.0 |
|
||||||
|`getpaseo/paseo` |AGPL-3.0 (**design only, no code lift**)|Daemon+clients arch, CLI verb shape, –worktree flag, provider snapshot/dispatch patterns |**v2.2 (shipped)** / v2.x |
|
|`getpaseo/paseo` |AGPL-3.0 (**design only, no code lift**)|Daemon+clients arch, CLI verb shape, –worktree flag, provider snapshot/dispatch, OpenCode-as-HTTP-server + reasoning dedup |**v2.2, v2.6 (shipped)** / v2.x |
|
||||||
|**`agentclientprotocol.com` spec + `@zed-industries/agent-client-protocol` SDK**|**Apache-2.0** |**ACP client (host) — replaces raw-PTY dispatch for opencode/goose/cursor** |**v2.0 → v2.2** |
|
|**`@opencode-ai/sdk`** |**MIT** |**OpenCode warm HTTP server client (`opencode serve`, SSE `session.next.*`, multi-session)** |**v2.6.1 (shipped)** |
|
||||||
|
|**`agentclientprotocol.com` spec + `@zed-industries/agent-client-protocol` SDK**|**Apache-2.0** |**ACP client (host) — replaces raw-PTY dispatch for opencode/goose (cursor retired v2.5.3)** |**v2.0 → v2.2** |
|
||||||
|**anthropics/skills `mcp-builder`** |**MIT** |**MCP server build workflow + 10-question evaluation framework** |**v2.0 (BooCoder MCP server)** |
|
|**anthropics/skills `mcp-builder`** |**MIT** |**MCP server build workflow + 10-question evaluation framework** |**v2.0 (BooCoder MCP server)** |
|
||||||
|**`zed-industries/codex-acp`** |**Apache-2.0** |**ACP server-side reference for `boocoder acp`** |**v2.4** |
|
|**`zed-industries/codex-acp`** |**Apache-2.0** |**ACP server-side reference for `boocoder acp`** |**v2.4** |
|
||||||
|Roo Code: Boomerang Tasks |Apache-2.0 (pattern only) |Orchestrator capability restriction + down-pass/up-pass context discipline |v1.14 (AGENTS.md) → v2.0 (real delegation) |
|
|Roo Code: Boomerang Tasks |Apache-2.0 (pattern only) |Orchestrator capability restriction + down-pass/up-pass context discipline |v1.14 (AGENTS.md) → v2.0 (real delegation) |
|
||||||
@@ -554,7 +622,7 @@ Sam wanted BooCode to function like Paseo without using Paseo itself. **Paseo is
|
|||||||
- **Observation pattern:** Claude Code hooks (siropkin/budi reference) — register BooCode as the hook receiver for `SessionStart`/`UserPromptSubmit`/`PostToolUse`/`SubagentStart`/`Stop`.
|
- **Observation pattern:** Claude Code hooks (siropkin/budi reference) — register BooCode as the hook receiver for `SessionStart`/`UserPromptSubmit`/`PostToolUse`/`SubagentStart`/`Stop`.
|
||||||
- **Protocol-level Paseo equivalence (shipped v2.2):** the ACP client + MCP server combination in BooCoder is the protocol-spelled version of Paseo's daemon. ACP gives multi-agent dispatch with structured events instead of free-form PTY output. MCP server gives BooCoder-as-task-board, callable from any MCP client (Termius-based opencode, future editors). One MCP config feeds every dispatched agent (via `context_servers` auto-forward). v2.2 added provider snapshot, mode/thinking, permission prompts, and Paseo-style stream/persist.
|
- **Protocol-level Paseo equivalence (shipped v2.2):** the ACP client + MCP server combination in BooCoder is the protocol-spelled version of Paseo's daemon. ACP gives multi-agent dispatch with structured events instead of free-form PTY output. MCP server gives BooCoder-as-task-board, callable from any MCP client (Termius-based opencode, future editors). One MCP config feeds every dispatched agent (via `context_servers` auto-forward). v2.2 added provider snapshot, mode/thinking, permission prompts, and Paseo-style stream/persist.
|
||||||
|
|
||||||
**Next on this track:** v2.3 provider lifecycle (config-backed registry, enable/disable, two-tier probe). See openspec `v2-3-provider-lifecycle`.
|
**Next on this track:** v2.3 provider lifecycle shipped (`v2.5.4`–`v2.5.13`); the live frontier is **v2.6 persistent agent sessions** — Phase 0/1 + P1.5-a/b shipped (`v2.6.0`–`v2.6.4`), Phase 2 (warm ACP for goose/qwen) + Phase 3 (lifecycle hardening) remain. See openspec `v2-6-persistent-agent-sessions`.
|
||||||
|
|
||||||
### BooCoder execution: both Option A AND Option B, full-featured (2026-05-22)
|
### BooCoder execution: both Option A AND Option B, full-featured (2026-05-22)
|
||||||
|
|
||||||
@@ -575,9 +643,19 @@ The v1.13.x cleanup line shipped 21 batches over a single intense window in `vMA
|
|||||||
- **v2.2-paseo-providers** ✅ — 7-provider snapshot, `AgentComposerBar`, ACP dispatch rewrite, permission prompts, agent commands, cursor/copilot providers
|
- **v2.2-paseo-providers** ✅ — 7-provider snapshot, `AgentComposerBar`, ACP dispatch rewrite, permission prompts, agent commands, cursor/copilot providers
|
||||||
- **v2.2.1-pane-scoped-chats** ✅ — pane-scoped chat resolution, `CoderMessageList` tool UI, WS user-delta fix, inference orphan tool_call stripping
|
- **v2.2.1-pane-scoped-chats** ✅ — pane-scoped chat resolution, `CoderMessageList` tool UI, WS user-delta fix, inference orphan tool_call stripping
|
||||||
|
|
||||||
|
### v2.2.2–v2.6 shipped (2026-05-26 → 2026-05-31)
|
||||||
|
|
||||||
|
Full per-tag detail in the **Shipped (v2.2.2–v2.6.4)** section above and in `CHANGELOG.md`. Threads:
|
||||||
|
|
||||||
|
- **Interactive ACP** (`v2.2.2`–`v2.3.2`) ✅ — placeholder-XML reject; per-agent sampling params; `ask_user_input` cards in both BooCoder frontends; enriched `permission_requested` frame (question/plan/elicitation) with interactive PermissionCard; coder `answer_user_input` endpoint fix.
|
||||||
|
- **Unsloth lift + sidecar + task model** (`v2.4.0`–`v2.5.1`) ✅ — Unsloth Studio `tool-call-parser.ts` (replaces `xml-parser.ts`) + parse5 HTML→Markdown (**AGPL-3.0 source — license flag vs the MIT commitment**); llama-sidecar per-agent-flags routing; dedicated task-model services; tool budgets → 100/100/100.
|
||||||
|
- **Provider lifecycle = the planned "v2.3"** (`v2.5.3`–`v2.5.15`) ✅ — cursor/copilot retired; config-backed registry + snapshot lifecycle + two-tier probe (phases 1–5); empty-picker fix; claude model list; mobile composer; per-agent + claude/opencode slash-command discovery; ACP path-guard security fix.
|
||||||
|
- **v2.6 persistent agent sessions** (`v2.6.0`–`v2.6.4`) ✅ Phase 0/1 + P1.5-a/b — foundations scaffold; opencode warm HTTP server with per-chat resumable sessions; session-delete work-loss guard; per-session SSE; `(chat_id, agent)` re-key + `worktrees` table; FK convergence.
|
||||||
|
|
||||||
### In flight
|
### In flight
|
||||||
|
|
||||||
- **v2.3-provider-lifecycle** — config-backed provider registry, enable/disable, two-tier probe (openspec drafted; not started). See `CURRENT.md`.
|
- **v2.6 persistent agent sessions — Phase 2/3** — warm ACP backend for goose/qwen (persistent process reused across turns) + lifecycle hardening (idle eviction, crash recovery, worktree cleanup/reaper, post-apply re-baseline) + the Phase-1 UX attribution work (DiffPanel agent badges, resumed/new-session chip). See openspec `v2-6-persistent-agent-sessions/tasks.md`.
|
||||||
|
- **Frontend pane/composer UX (uncommitted working tree, 2026-05-31)** — CoderPane stop button (`api.coder.cancelTask` → `POST /api/coder/tasks/:id/cancel`; `generating = sending || activeTaskId` gates queue/stop); ChatTabBar `[+]` becomes a New BooChat / BooTerm / BooCode menu; "Open in new pane" tab context-menu + `open_chat_in_new_pane` SessionEvent + `useWorkspacePanes.openChatInNewPane` (detaches the chat so it lives in exactly one pane); MessageBubble simplification so fork lands beside the original. Not yet committed/tagged.
|
||||||
|
|
||||||
### Numbering and scope-revision discipline during v1.13.x (2026-05-23)
|
### Numbering and scope-revision discipline during v1.13.x (2026-05-23)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
60
data/skills/boocode/committing-changes/SKILL.md
Normal file
60
data/skills/boocode/committing-changes/SKILL.md
Normal 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.
|
||||||
31
data/skills/boocode/committing-changes/eval.yaml
Normal file
31
data/skills/boocode/committing-changes/eval.yaml
Normal 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
|
||||||
@@ -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.
|
||||||
73
data/skills/boocode/using-worktrees/SKILL.md
Normal file
73
data/skills/boocode/using-worktrees/SKILL.md
Normal 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.
|
||||||
32
data/skills/boocode/using-worktrees/eval.yaml
Normal file
32
data/skills/boocode/using-worktrees/eval.yaml
Normal 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
|
||||||
Reference in New Issue
Block a user