Compare commits
24 Commits
v2.5.15-ac
...
v2.6.5-pan
| Author | SHA1 | Date | |
|---|---|---|---|
| 5527e7a5e8 | |||
| 08d6a8fa40 | |||
| 2fd7e5bf97 | |||
| d05f73be26 | |||
| e857815d79 | |||
| 12d31a81a0 | |||
| 5da6eb2447 | |||
| 7f6c4780e2 | |||
| 30b6f70f95 | |||
| c2b3e0a013 | |||
| cb1846c0d5 | |||
| f1a85627e4 | |||
| c65daba5dd | |||
| c9e302da37 | |||
| f69ea5f494 | |||
| 3a26563be2 | |||
| 937920df06 | |||
| e05469c6ae | |||
| 0e026be5f8 | |||
| 315cdd23e2 | |||
| 6d24726c3a | |||
| 1bbeaf95c7 | |||
| e30a9e8b23 | |||
| 140ff26204 |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -2,6 +2,26 @@
|
||||
|
||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||
|
||||
## v2.6.5-panes-tabs-composer — 2026-05-31
|
||||
|
||||
A workspace UX batch across BooChat panes, tabs, and the composer, plus the persistence model that backs them. **Panes & tabs:** a chat can be opened in a fresh pane (the ChatTabBar tab context menu's "Open in new pane", and the fork button — which now lands the fork beside the original via a new `open_chat_in_new_pane` event instead of replacing the active pane); the per-pane "+" became a New BooChat/BooTerm/BooCode menu; closing a chat pane relocates its tabs (in order) into the oldest chat/empty pane instead of discarding them, and reopen strips the restored chatIds from every live pane first so a relocated-then-reopened pane never duplicates a tab (no stack-shape change); each tab carries a stable session-scoped number assigned on open and retired on close (never reused), rendered map-keyed rather than positional. The per-message "Open in pane" artifact button was removed, and the empty/landing pane became a real session history — the session's open chats plus separately-fetched archived chats, click to open or restore-and-open. **Persistence:** `sessions.workspace_panes` was widened from a bare `WorkspacePane[]` to a `WorkspaceState` envelope (`panes` + `tabNumbers`/`nextTabNumber` + `closedPaneStack`) so tab numbers and the reopen stack survive reload; the PATCH validator accepts the legacy array or the envelope (zod union) and migrates on write, and the `session_workspace_updated` WS-frame schema was widened on both web and server (byte-identical, parity test green) — the same schema-drift class as `v2.6.4-agent-sessions-fk`. **Composer:** the send button morphs Send → Stop → Queue with generation state (BooCoder keys on `sending || activeTaskId`, which also corrected its queue gates and added `cancelTask`), the standalone "Stop generating" pill was folded into it, and pasted chips now trail the typed text so a leading slash command stays first. **Tooling:** adds the read-only `read_tab_by_number` tool — resolves a session-scoped tab number to its chat via the persisted `tabNumbers` map and returns that chat's transcript; tools gained an optional `ToolExecCtx` (`{ sql, sessionId }`) on `execute` to support DB-reading tools. Builds on `v2.6.4-agent-sessions-fk`.
|
||||
|
||||
## v2.6.4-agent-sessions-fk — 2026-05-31
|
||||
|
||||
Follow-up to `v2.6.3-chatkey-and-skills` (P1.5-b): the live `agent_sessions.session_id` foreign key is converged from `ON DELETE CASCADE` to `ON DELETE SET NULL`, matching the schema's stated intent. The P1.5-b re-key block re-adds `session_id_fkey` as `SET NULL`, but the whole block is guarded on `chat_id_fkey`'s absence — so a database already re-keyed to `(chat_id, agent)` while `session_id_fkey` was still `CASCADE` never re-enters it, leaving the live FK at `CASCADE` and diverging from both `worktree_id` (already `SET NULL`) and the `v2.6.3` changelog's own claim that `session_id` is informational `SET NULL`. The fix adds a standalone `confdeltype`-guarded `DO` block (mirroring the `session_worktrees` defang) that flips `session_id_fkey` `CASCADE → SET NULL` independently of the re-key gate; it is idempotent — fires only while the FK is still `'c'`, a no-op on a fresh deploy (already `'n'`) and on every re-run. The live DB was converged by hand with the identical statements, so `applySchema` and the hand-applied state match (`\d agent_sessions` now shows `session_id ... ON DELETE SET NULL`). Also bundles a CLAUDE.md doc-sync (committed separately): per-session SSE (P1.5-a) and the `(chat_id, agent)` re-key reflected in the engineering notes, the stale root `AGENTS.md` navigation pointer dropped, and new conventions for `data/AGENTS.md` parsing and the `data/skills/<vendor>/` layout.
|
||||
|
||||
## v2.6.3-chatkey-and-skills — 2026-05-31
|
||||
|
||||
Three threads. **agent_sessions re-keyed to `(chat_id, agent)` (P1.5-b):** the tab (a chat) is now the agent-context unit, so two opencode tabs in one BooCode session are two independent contexts that share one worktree. `chat_id` is threaded end-to-end — `tasks.chat_id` added, stamped by the coder message + skills routes from the frontend tab, read by `runOpenCodeServerTask` which falls back to resolve-or-create a chat for session-less creators (arena/MCP/new_task/generic `/api/tasks`) so `ensureSession` never receives a degenerate `(null, agent)` key. A new first-class `worktrees` table (one-per-session, survives session delete via `session_id ON DELETE SET NULL`) supersedes `session_worktrees`, which is defanged (CASCADE dropped, not yet removed); `agent_sessions.chat_id` CASCADEs from `chats` (closing a tab ends its context) while `worktree_id`/`session_id` are informational `SET NULL`. The migration is idempotent with a backfill-verify gate; the live re-key was applied against an empty table after the 35-chat test session `20d28876` was deleted (backed up first). This corrects and supersedes an earlier draft that wrongly keyed on `(worktree_id, agent)`; the delete-guard from `v2.6.2-delete-guard-and-sse` is repointed here from `session_worktrees` to `worktrees` (`worktree_path`→`path`). **dcp-strip cross-chunk fix:** the `<dcp-message-id>` tag streams split across SSE deltas, which the per-chunk strip from `v2.6.1-phase1-opencode` missed — a stateful `makeDcpStreamStripper` at the dispatcher boundary holds back partial-tag tails so neither live frames nor persisted content carry the tag (11 unit tests). **Agent-judgment skills:** `committing-changes` (segment by concern, stage explicitly, present-and-stop, never push) and `using-worktrees` (the when-to-isolate heuristic, autonomous-when-clear vs committing's command-gate) land in `data/skills/boocode/` with eval.yamls, plus a parser-safe `data/AGENTS.md` preamble pointing at both.
|
||||
|
||||
## v2.6.2-delete-guard-and-sse — 2026-05-30
|
||||
|
||||
Two coder-side batches under one tag. **Session-delete work-loss guard:** deleting a BooChat session CASCADE-wipes its `session_worktrees` row, which would silently orphan uncommitted/unpushed/unmerged work — so the server's `DELETE /api/sessions/:id` now gates before the delete. It reads `session_worktrees` from the shared DB first (no row → chat-only session → delete immediately, zero round-trip), and for worktree-backed sessions calls a new BooCoder endpoint (`/worktree-risk`) that runs git on the host, since the container can't see `/tmp/booworktrees` — only the host systemd service can. `checkWorktreeWorkAtRisk` reports dirty/unpushed/unmerged via the audited `hostExec`+`shellEscape` path, default branch detected from `refs/remotes/origin/HEAD` (never the worktree's own branch, never hardcoded); any at-risk worktree returns 409 with per-worktree `RiskReport[]`, `force=true` bypasses, and the check is fail-closed (BooCoder unreachable also blocks — force still escapes). The sidebar renders a block dialog distinguishing work-at-risk (Commit/Stash/Force; stash uses `-u` and re-blocks on remaining commits) from couldn't-verify (Cancel/Force), and Commit never auto-commits. A follow-up fix gates the `unpushed` arm behind an actual upstream (`atRisk = dirty || unmerged > 0 || (hasUpstream && unpushed > 0)`) so the no-upstream `session-<id>` branches stop flagging every pristine worktree-backed session — no protection lost, since real local work always also surfaces as `unmerged > 0`. **Per-session SSE (P1.5-a):** replaces the single global SSE loop scoped to the most-recent worktree directory — the known limit flagged in `v2.6.1-phase1-opencode` — with one `event.subscribe({directory})` per live opencode session, so sessions in different worktrees stream concurrently instead of the second silently dropping the first's events. Each session owns an `AbortController` wired into `subscribe(…, {signal})`, which also fixes a latent Phase-1 bug where switching directories left the old loop parked forever in its `for await` (zombie loops); a `sessionID` demux guard drops cross-session events so two sessions sharing a worktree (possible after P1.5-b) don't double-process deltas. The opencode SDK was confirmed to open an independent SSE connection per `subscribe()` call, so N concurrent dir-scoped streams are supported.
|
||||
|
||||
## v2.6.1-phase1-opencode — 2026-05-30
|
||||
|
||||
v2.6 Phase 1: opencode runs as a warm HTTP server (`apps/coder/src/services/backends/opencode-server.ts`) — one `opencode serve` per BooCoder process, one opencode session per BooCode session resumed across turns via the new `agent_sessions` table, with a single SSE read loop, reasoning dedup ported from Paseo, an inactivity watchdog, and a stale-session guard (crashed-not-resumed + a `config_hash` fingerprint over `opencode_server|<model>`, deliberately excluding the ephemeral server port so cross-restart resume survives). Builds on the `v2.6.0-phase0-foundations` schema/interface scaffold. The batch's hard-won fixes: opencode streams `session.next.*` events (not `message.part.*`), and `event.subscribe()` must pass the session's worktree `directory` or events route to the server CWD and turns come back empty; model strings must be `llama-swap/`-prefixed and present in opencode's own config, with `agent-probe` now populating `available_agents.models` via `mergeLlamaSwap` so the frontend stops sending an empty model; `session_worktrees`/`agent_sessions` FKs are `ON DELETE CASCADE` so session deletion no longer 500s. Also bundled: dcp-message-id tag stripping from opencode text output, a reopen-closed-pane control, the `[+]`/split-pane button separation, auto-name using the session's loaded model, and a `systematic-debugging` slash command. Smoke 1 verified end-to-end (two turns, session reuse, turn 2 ~9x faster). Known Phase 1 limit: one SSE stream scoped to the most-recent session's directory — concurrent opencode sessions in different worktrees collide (warns; per-session SSE is Phase 2).
|
||||
|
||||
## v2.5.15-acp-path-guard — 2026-05-29
|
||||
|
||||
Security fix + repo hygiene. Fixes a path-traversal in the ACP filesystem bridge (`acp-client-fs.ts`, flagged by the automated push security review): the worktree guard used an unbounded `startsWith(resolve(worktreePath))`, so a sibling path sharing the worktree as a string prefix (`<worktree>-evil/…`) escaped the scope — and `writeWorktreeTextFile` writes to disk directly (no `pending_changes` gate), so a confused/buggy ACP agent could write outside its worktree. Now uses a separator-bounded check matching `write_guard.ts` (`resolve()` + `startsWith(root + sep)` / `=== root`) via a shared `resolveInWorktree`, with a regression test covering `../` traversal and the sibling-prefix bug. Symlink-swap/`O_NOFOLLOW` hardening was intentionally skipped — consistent with `write_guard`'s no-realpath stance, and the agent already runs with host FS access so this is a containment guard, not a trust boundary. Separately, stops tracking the live `data/coder-providers.json` (it's runtime config the UI reads *and writes* on provider toggles, which churned `git status`) — it's now gitignored with a tracked `data/coder-providers.example.json` reference; the loader falls back to built-ins-only when the live file is absent. The provider-type duplication (coder ↔ web) stays guarded by the existing text-identity `provider-types-parity.test.ts` — a shared package was considered and declined (drift is already prevented; not worth the Docker/build-order risk at solo scale).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
**Cursor agents:** start with `AGENTS.md` (navigation) and `docs/ARCHITECTURE.md` (diagram). This file is the deep engineering reference.
|
||||
**Cursor agents:** start with `docs/ARCHITECTURE.md` (diagram). This file is the deep engineering reference. (Note: the root navigation `AGENTS.md` was removed in v1.12; `data/AGENTS.md` is the agent *registry*, not navigation.)
|
||||
|
||||
## What is BooCode
|
||||
|
||||
@@ -89,7 +89,10 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
|
||||
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
|
||||
- **Provider snapshot lifecycle** (`apps/coder/src/services/`): `provider-config.ts` (Zod config, never-throws on bad input) → `provider-config-registry.ts` (`buildResolvedRegistry`, singleton) → `provider-snapshot.ts` (two-tier probe: tier-1 fast presence, tier-2 cold ACP probe skipped unless force / stale `PROVIDER_PROBE_TTL_MS` 24h / dbEmpty; cached). Verify live: `curl http://100.114.205.53:9502/api/providers/snapshot` — returns providers + models + commands, the exact shape `AgentComposerBar` renders.
|
||||
- `PATCH /api/providers/config` replaces a provider id's override object **wholesale** (per-id shallow merge) — to flip one field send `{...existing, enabled}`, or a custom ACP entry's `command`/`label` is wiped and it drops out of the resolved registry. `data/coder-providers.json` is **gitignored** (it's live runtime config — the coder reads AND writes it on UI toggles); the tracked reference is `data/coder-providers.example.json`. The loader falls back to `{providers:{}}` (built-ins only) when the live file is absent, so a fresh checkout needs no copy.
|
||||
- External agents dispatch **one-shot** (`opencode acp` / `goose acp` / `qwen --acp`) and report no context-window/token usage; only native `boocode` (llama-swap engine) tracks ctx. OpenCode-as-HTTP-server (warm process + `@opencode-ai/sdk`, the source of a real context bar) is the **planned, unshipped** `openspec/changes/v2-6-persistent-agent-sessions` batch; Paseo's per-provider native clients (design §12) were deliberately not ported.
|
||||
- **opencode** runs as a warm HTTP server (v2.6 Phase 1, `services/backends/opencode-server.ts` — `opencode serve` per BooCoder process, one opencode session per BooCode session, resumed via `agent_sessions`). goose/qwen/claude still dispatch **one-shot** ACP/PTY with no ctx/token usage; only native `boocode` (llama-swap engine) tracks ctx. Paseo's per-provider native clients (design §12) deliberately not ported.
|
||||
- **opencode SSE** (`opencode-server.ts`): live streaming arrives as `session.next.text.delta` / `session.next.reasoning.delta` / `session.next.tool.{called,success,failed}` — NOT `message.part.*` (those are terminal/post-hoc). `client.event.subscribe({ directory })` MUST pass the session's worktree directory; omit it and opencode scopes events to the server's `process.cwd()` → zero session events (empty turns, 180s watchdog timeout). Per-session SSE (P1.5-a): each live session owns its own `event.subscribe({directory})` loop + AbortController, so concurrent sessions in different worktrees stream independently; a `sessionID` demux guard drops cross-session events when two share a dir. Turn completes on `session.idle`; `promptAsync` is fire-and-forget (204).
|
||||
- **opencode model strings** must be provider-prefixed (`llama-swap/<model>`) AND exist in `~/.config/opencode/opencode.json` `provider.llama-swap.models` — not merely loadable by llama-swap. `parseModel` infers `llama-swap/` for a bare id; the dispatcher coalesces empty→DEFAULT_MODEL then prefixes. `agent-probe` populates opencode's `available_agents.models` via `mergeLlamaSwap` (fetches `/v1/models`); empty model list → frontend sends `''` → no inference (`input:0`, empty turn).
|
||||
- **agent_sessions resume**: `config_hash = sha256('opencode_server|<model>')` — must NOT include the server port (random per boot; including it breaks cross-restart resume). 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/`)
|
||||
|
||||
@@ -189,6 +192,8 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
||||
- **ReadableStream test stubs** use `pull()` (not `start()`) so chunks are produced lazily — `start()` enqueues everything and calls `controller.close()` before the consumer reads, so a subsequent `reader.cancel()` finds the stream already closed and the `cancel()` callback never fires. Also provide MORE chunks than the test will consume so the source stays in 'readable' state when cancel runs (e.g. cap test reads ~6 chunks, stub provides 10).
|
||||
- Tool-name whitelists must derive from `ALL_TOOLS` in `services/tools.ts`, never hardcoded. `services/agents.ts` `ALL_TOOL_NAMES` had this drift class until v1.12 — same pattern applies to any future tool-aware code.
|
||||
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
|
||||
- `data/AGENTS.md` is PARSED (`agents.ts` `splitSections`/`parseAgentSection`): each `## <Name>` is one agent and must be followed by a `---` frontmatter fence or the block throws; content before the first `## ` is discarded. Do NOT add free-form `## ` rule sections — they break the registry. Cross-cutting agent rules go in CLAUDE.md or a parser-ignored preamble.
|
||||
- Skills live in `data/skills/<vendor>/`; Sam's own namespace is `boocode/` (`committing-changes`, `using-worktrees`, `improving-boocode-guidance`) — `SKILL.md` + optional `eval.yaml` (gerund names; eval = `skill:` + `tasks:` of `prompt`+`grader`, incl. a negative-trigger task). `data/skills/` is canonical; a divergent mirror at `/opt/skills/` exists.
|
||||
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
|
||||
- **Workspace dependency pattern** (`apps/coder` → `@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
|
||||
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@agentclientprotocol/sdk": "^0.22.1",
|
||||
"@boocode/server": "workspace:*",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@opencode-ai/sdk": "~1.15.0",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"fastify": "^4.28.1",
|
||||
|
||||
@@ -30,9 +30,11 @@ import { registerInboxRoutes } from './routes/inbox.js';
|
||||
import { registerStatsRoutes } from './routes/stats.js';
|
||||
import { registerArenaRoutes } from './routes/arena.js';
|
||||
import { registerProviderRoutes } from './routes/providers.js';
|
||||
import { registerWorktreeSafetyRoutes } from './routes/worktree-safety.js';
|
||||
import { registerWebSocket } from './routes/ws.js';
|
||||
// Phase 4: dispatcher + agent probe
|
||||
import { createDispatcher } from './services/dispatcher.js';
|
||||
import { agentPool } from './services/agent-pool.js';
|
||||
import { probeAgents } from './services/agent-probe.js';
|
||||
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
||||
import { setPermissionHooks } from './services/permission-waiter.js';
|
||||
@@ -178,7 +180,12 @@ async function main() {
|
||||
// Phase 4: dispatcher — polls tasks table and runs inference
|
||||
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
|
||||
dispatcher.start();
|
||||
app.addHook('onClose', () => dispatcher.stop());
|
||||
app.addHook('onClose', async () => {
|
||||
// stop() first so in-flight dispatcher turns settle, then drain the pool.
|
||||
// Pool is empty in Phase 0 (nothing spawns yet) — dispose() is inert.
|
||||
await dispatcher.stop();
|
||||
await agentPool.dispose();
|
||||
});
|
||||
|
||||
// Register routes
|
||||
registerMessageRoutes(app, sql, broker, inferenceApi);
|
||||
@@ -189,6 +196,7 @@ async function main() {
|
||||
registerStatsRoutes(app, sql);
|
||||
registerArenaRoutes(app, sql);
|
||||
registerProviderRoutes(app, sql, config);
|
||||
registerWorktreeSafetyRoutes(app, sql);
|
||||
registerWebSocket(app, sql, broker);
|
||||
|
||||
// Serve static frontend (built web app). In production, the dist/ is
|
||||
|
||||
@@ -224,8 +224,8 @@ export function registerMessageRoutes(
|
||||
// External provider: create a task for the dispatcher
|
||||
const projectId = sessionRows[0]!.project_id;
|
||||
const [task] = await sql<{ id: string; state: string }[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
|
||||
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
|
||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id)
|
||||
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId}, ${chatId})
|
||||
RETURNING id, state
|
||||
`;
|
||||
reply.code(202);
|
||||
|
||||
@@ -91,8 +91,8 @@ export function registerSkillRoutes(
|
||||
|
||||
const taskInput = `${body}\n\n---\n\n${userText}`;
|
||||
const [task] = await sql<{ id: string; state: string }[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
|
||||
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
|
||||
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id)
|
||||
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId}, ${chatId})
|
||||
RETURNING id, state
|
||||
`;
|
||||
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||
|
||||
45
apps/coder/src/routes/worktree-safety.ts
Normal file
45
apps/coder/src/routes/worktree-safety.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Session-delete work-loss guard (coder side).
|
||||
*
|
||||
* Session delete itself lives in apps/server (Docker), which CANNOT see the
|
||||
* host worktree dirs (/tmp/booworktrees) or run git on them. Only BooCoder
|
||||
* (host systemd) can. So the server's DELETE route calls these endpoints
|
||||
* pre-delete to learn whether a session's worktree holds work at risk, and to
|
||||
* stash it. The server owns the gate; coder owns the git truth.
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
import { checkWorktreeWorkAtRisk, stashWorktree } from '../services/worktrees.js';
|
||||
|
||||
export function registerWorktreeSafetyRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
// GET risk for a session's worktree(s). One row per session today (PK on
|
||||
// session_id); the loop already handles the Phase-1.5 multi-worktree case.
|
||||
app.get<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/worktree-risk',
|
||||
async (req) => {
|
||||
const rows = await sql<{ worktree_path: string }[]>`
|
||||
SELECT path AS worktree_path FROM worktrees WHERE session_id = ${req.params.sessionId}
|
||||
`;
|
||||
const reports = [];
|
||||
for (const row of rows) {
|
||||
reports.push(await checkWorktreeWorkAtRisk(row.worktree_path));
|
||||
}
|
||||
return { reports };
|
||||
},
|
||||
);
|
||||
|
||||
// Stash a session's worktree(s) — clears the dirty risk; recoverable.
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/worktree-stash',
|
||||
async (req) => {
|
||||
const rows = await sql<{ worktree_path: string }[]>`
|
||||
SELECT path AS worktree_path FROM worktrees WHERE session_id = ${req.params.sessionId}
|
||||
`;
|
||||
const results = [];
|
||||
for (const row of rows) {
|
||||
results.push({ worktreePath: row.worktree_path, ...(await stashWorktree(row.worktree_path)) });
|
||||
}
|
||||
return { results };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -76,6 +76,159 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
|
||||
|
||||
-- v2.6: one shared worktree per session (all agents/panes in the session operate in it).
|
||||
CREATE TABLE IF NOT EXISTS session_worktrees (
|
||||
session_id UUID PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
worktree_path TEXT NOT NULL,
|
||||
base_commit TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
-- P1.5-b: DEFANG the CASCADE — a session delete must no longer wipe its worktree
|
||||
-- row. This table is SUPERSEDED by `worktrees` below; all readers are repointed
|
||||
-- this phase, so the row just persists (dead) on session delete until a later
|
||||
-- cleanup drops the table. session_id is this table's PRIMARY KEY, so it cannot be
|
||||
-- nullable → SET NULL is invalid and NO ACTION/RESTRICT would block deletes; the
|
||||
-- only valid defang is to drop the FK with no replacement. Idempotent: only fires
|
||||
-- while the FK is still ON DELETE CASCADE ('c').
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'session_worktrees_session_id_fkey'
|
||||
AND confdeltype = 'c'
|
||||
) THEN
|
||||
ALTER TABLE session_worktrees DROP CONSTRAINT session_worktrees_session_id_fkey;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- v2.6: one backend session per (session, agent); resumed on switch-back.
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
agent TEXT NOT NULL,
|
||||
backend TEXT NOT NULL,
|
||||
agent_session_id TEXT,
|
||||
server_port INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'idle',
|
||||
last_active_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
PRIMARY KEY (session_id, agent),
|
||||
CONSTRAINT agent_sessions_backend_chk CHECK (backend IN ('opencode_server', 'acp_warm')),
|
||||
CONSTRAINT agent_sessions_status_chk CHECK (status IN ('idle', 'active', 'crashed', 'closed'))
|
||||
);
|
||||
|
||||
-- Migrate existing agent_sessions FK to CASCADE.
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'agent_sessions_session_id_fkey'
|
||||
AND confdeltype <> 'c'
|
||||
) THEN
|
||||
ALTER TABLE agent_sessions DROP CONSTRAINT agent_sessions_session_id_fkey;
|
||||
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- v2.6: config fingerprint for stale-session detection (auto-recover on model change).
|
||||
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT;
|
||||
|
||||
-- ─── P1.5-b (corrected): worktrees entity + re-key agent_sessions to (chat_id, agent) ───
|
||||
-- The TAB (a chat) is the context unit: two opencode tabs in one session = two
|
||||
-- independent contexts sharing one worktree. So agent_sessions keys on
|
||||
-- (chat_id, agent), NOT (worktree_id, agent) or (session_id, agent). The
|
||||
-- `worktrees` table is one-per-session (selectable later) and only referenced
|
||||
-- informationally by agent_sessions.worktree_id (SET NULL); chat_id is the key.
|
||||
--
|
||||
-- PREREQUISITE: the unmigratable test session (35 chats, 1 agent_sessions row that
|
||||
-- maps to no single chat) is DELETED before this runs, so agent_sessions is empty
|
||||
-- and the chat_id backfill is N/A. If a row with NULL chat_id remains, the verify
|
||||
-- gate below RAISEs and aborts — delete the offending session first.
|
||||
|
||||
-- worktree as a first-class entity; survives session delete (session_id SET NULL).
|
||||
CREATE TABLE IF NOT EXISTS worktrees (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID REFERENCES sessions(id) ON DELETE SET NULL,
|
||||
project_id UUID,
|
||||
path TEXT NOT NULL,
|
||||
branch TEXT,
|
||||
base_commit TEXT,
|
||||
slug TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS worktrees_active_path_uidx ON worktrees(path) WHERE status='active';
|
||||
|
||||
-- Migrate any surviving session_worktrees rows → worktrees (idempotent; 0 rows
|
||||
-- after the test-session delete, kept for generality / fresh-DB safety).
|
||||
INSERT INTO worktrees (session_id, path, branch, base_commit, status)
|
||||
SELECT sw.session_id, sw.worktree_path, 'session-' || sw.session_id, sw.base_commit, 'active'
|
||||
FROM session_worktrees sw
|
||||
WHERE NOT EXISTS (SELECT 1 FROM worktrees w WHERE w.session_id = sw.session_id AND w.status='active');
|
||||
|
||||
-- Dispatch hint: which chat (tab) a task belongs to. The coder message route and
|
||||
-- skills route set it from the frontend tab; session-less creators (arena, MCP,
|
||||
-- new_task, generic /api/tasks) leave it NULL and the dispatcher creates a chat.
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS chat_id UUID REFERENCES chats(id) ON DELETE SET NULL;
|
||||
|
||||
-- Re-key columns on agent_sessions.
|
||||
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS chat_id UUID;
|
||||
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS worktree_id UUID;
|
||||
|
||||
-- BACKFILL-VERIFY GATE: the new PK is (chat_id, agent), so chat_id must be
|
||||
-- non-null on every row before the swap. With the test session deleted this is a
|
||||
-- 0-row assertion; if any row has NULL chat_id (an unmigratable pre-existing row),
|
||||
-- abort loudly rather than create a degenerate (NULL, agent) key.
|
||||
DO $$
|
||||
DECLARE n int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n FROM agent_sessions WHERE chat_id IS NULL;
|
||||
IF n > 0 THEN
|
||||
RAISE EXCEPTION 'P1.5-b: % agent_sessions row(s) have NULL chat_id — delete the unmigratable session(s) before applying', n;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Swap PK (session_id,agent) → (chat_id,agent) + FKs (run-once, guarded on the new
|
||||
-- FK's absence). chat_id CASCADEs from chats (closing a tab ends its context);
|
||||
-- worktree_id is informational SET NULL; session_id defanged to nullable SET NULL.
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agent_sessions_chat_id_fkey') THEN
|
||||
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_pkey;
|
||||
ALTER TABLE agent_sessions DROP CONSTRAINT IF EXISTS agent_sessions_session_id_fkey;
|
||||
ALTER TABLE agent_sessions ALTER COLUMN session_id DROP NOT NULL;
|
||||
ALTER TABLE agent_sessions ALTER COLUMN chat_id SET NOT NULL;
|
||||
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_pkey PRIMARY KEY (chat_id, agent);
|
||||
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_chat_id_fkey
|
||||
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE;
|
||||
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL;
|
||||
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_worktree_id_fkey
|
||||
FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- P1.5-b follow-up: converge agent_sessions.session_id FK CASCADE → SET NULL.
|
||||
-- The re-key block above re-adds session_id_fkey as SET NULL, but it is guarded on
|
||||
-- chat_id_fkey's ABSENCE — so a DB already re-keyed to (chat_id, agent) while
|
||||
-- session_id_fkey was still ON DELETE CASCADE never re-enters that block and stays
|
||||
-- 'c'. This standalone guard flips it to SET NULL ('n'), matching worktree_id.
|
||||
-- Idempotent (mirrors the session_worktrees defang's confdeltype check): only fires
|
||||
-- while the FK is still CASCADE — a no-op on a fresh deploy (already 'n' from the
|
||||
-- re-key block) and on every re-run thereafter.
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'agent_sessions_session_id_fkey'
|
||||
AND confdeltype = 'c'
|
||||
) THEN
|
||||
ALTER TABLE agent_sessions ALTER COLUMN session_id DROP NOT NULL;
|
||||
ALTER TABLE agent_sessions DROP CONSTRAINT agent_sessions_session_id_fkey;
|
||||
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this).
|
||||
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
|
||||
|
||||
-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
|
||||
-- new_task tool, arena, MCP server) fires pg_notify('tasks_new') in the same
|
||||
-- transaction, so the dispatcher reacts immediately instead of waiting for the
|
||||
|
||||
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>');
|
||||
});
|
||||
});
|
||||
96
apps/coder/src/services/agent-backend.ts
Normal file
96
apps/coder/src/services/agent-backend.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* v2.6 — AgentBackend abstraction (Phase 0 scaffold; types only, zero runtime logic).
|
||||
*
|
||||
* The core abstraction for persistent agent sessions. Two implementations land
|
||||
* later: `OpenCodeServerBackend` (Phase 1, opencode HTTP server) and
|
||||
* `WarmAcpBackend` (Phase 2, long-lived ACP process). Backends emit
|
||||
* transport-agnostic `AgentEvent`s; the dispatcher maps them to WS frames.
|
||||
*
|
||||
* Nothing imports this file yet — it must compile standalone.
|
||||
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2.
|
||||
*/
|
||||
import type { AcpToolSnapshot } from './acp-tool-snapshot.js';
|
||||
import type { AgentCommand } from './provider-types.js';
|
||||
|
||||
/** Backend transport kind. Mirrors `agent_sessions.backend` CHECK in schema.sql. */
|
||||
export type AgentBackendKind = 'opencode_server' | 'acp_warm';
|
||||
|
||||
/**
|
||||
* Normalized, transport-agnostic events a backend emits during a turn (§2).
|
||||
* Derived from acp-dispatch's session-update handling, but WITHOUT the WS
|
||||
* envelope (message_id/chat_id) — the dispatcher owns frame mapping.
|
||||
*
|
||||
* `tool_call` vs `tool_update` are kept distinct on purpose: acp-dispatch
|
||||
* currently merges both into one snapshot frame, but opencode's SSE
|
||||
* distinguishes tool-start from tool-result, so the contract carries both.
|
||||
* `commands` mirrors the ACP `available_commands_update` path (v2.5.10).
|
||||
*/
|
||||
export type AgentEvent =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'reasoning'; text: string }
|
||||
| { type: 'tool_call'; toolCall: AcpToolSnapshot }
|
||||
| { type: 'tool_update'; toolCall: AcpToolSnapshot }
|
||||
| { type: 'commands'; commands: AgentCommand[] };
|
||||
|
||||
/** Params to establish (or look up) a backend session (§2). */
|
||||
export interface EnsureSessionOpts {
|
||||
agent: string;
|
||||
/** Resolved model id. */
|
||||
model: string;
|
||||
/** P1.5-b: the chat (tab) this turn belongs to. agent_sessions is keyed
|
||||
* (chat_id, agent) — the tab/chat is the context unit. Always non-null:
|
||||
* the dispatcher creates a chat for session-less tasks before calling. */
|
||||
chatId: string;
|
||||
/** Shared per-session worktree (one per `sessions.id`, not per pane). */
|
||||
worktreePath: string;
|
||||
/** P1.5-b: the `worktrees.id` for this session's worktree — stored on the
|
||||
* agent_sessions row informationally (NOT the key). */
|
||||
worktreeId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/** Opaque handle to a live backend session, persisted to `agent_sessions` (§2). */
|
||||
export interface AgentSessionHandle {
|
||||
sessionId: string;
|
||||
agent: string;
|
||||
backend: AgentBackendKind;
|
||||
/** P1.5-b: the chat (tab) this session is keyed on (with agent). */
|
||||
chatId: string;
|
||||
/** P1.5-b: the worktree this session's chat runs in (informational link). */
|
||||
worktreeId: string;
|
||||
/** Provider's own session id (resume token); null until the backend assigns one. */
|
||||
agentSessionId: string | null;
|
||||
/** opencode HTTP server port; null for ACP backends. */
|
||||
serverPort: number | null;
|
||||
}
|
||||
|
||||
/** Per-turn context passed to `prompt` (§2). */
|
||||
export interface PromptCtx {
|
||||
worktreePath: string;
|
||||
model: string;
|
||||
signal: AbortSignal;
|
||||
onEvent: (e: AgentEvent) => void;
|
||||
}
|
||||
|
||||
/** Result of a completed turn (§2). Diff/persist happen outside the backend. */
|
||||
export interface TurnResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The core backend abstraction (§2). Implementations: OpenCodeServerBackend
|
||||
* (Phase 1), WarmAcpBackend (Phase 2).
|
||||
*/
|
||||
export interface AgentBackend {
|
||||
/** Lazy: spawn server / warm process if not already up for this (session, agent). §2 */
|
||||
ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle>;
|
||||
/** Send a prompt; stream events via ctx.onEvent; resolves when the turn completes. §2 */
|
||||
prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult>;
|
||||
/** Graceful teardown of one session (session close or idle timeout). §2 */
|
||||
closeSession(handle: AgentSessionHandle): Promise<void>;
|
||||
/** Full teardown — kills all spawned servers/processes. §2 */
|
||||
dispose(): Promise<void>;
|
||||
/** Liveness for health endpoint + dispatcher fallback decision. §2 */
|
||||
health(): 'up' | 'down';
|
||||
}
|
||||
44
apps/coder/src/services/agent-pool.ts
Normal file
44
apps/coder/src/services/agent-pool.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* v2.6 — AgentPool (Phase 0 scaffold).
|
||||
*
|
||||
* Lazy get-or-create registry of `AgentBackend` instances keyed by
|
||||
* `${sessionId}:${agent}`. Phase 0 ships the skeleton only: an in-memory Map,
|
||||
* lookup / register / health, and clean disposal wired to the server's onClose.
|
||||
* Spawning lands in Phase 1/2; nothing populates the map yet.
|
||||
*
|
||||
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2.
|
||||
*/
|
||||
import type { AgentBackend } from './agent-backend.js';
|
||||
|
||||
export class AgentPool {
|
||||
private readonly backends = new Map<string, AgentBackend>();
|
||||
|
||||
private key(sessionId: string, agent: string): string {
|
||||
return `${sessionId}:${agent}`;
|
||||
}
|
||||
|
||||
/** Map lookup only. Spawning is Phase 1/2 — never creates here. */
|
||||
get(sessionId: string, agent: string): AgentBackend | undefined {
|
||||
return this.backends.get(this.key(sessionId, agent));
|
||||
}
|
||||
|
||||
/** Store a backend instance for this (session, agent). */
|
||||
register(sessionId: string, agent: string, backend: AgentBackend): void {
|
||||
this.backends.set(this.key(sessionId, agent), backend);
|
||||
}
|
||||
|
||||
/** Summary for the health endpoint. */
|
||||
health(): { size: number } {
|
||||
return { size: this.backends.size };
|
||||
}
|
||||
|
||||
/** Dispose every backend and clear the map. Tolerates throwing backends. */
|
||||
async dispose(): Promise<void> {
|
||||
const entries = [...this.backends.values()];
|
||||
this.backends.clear();
|
||||
await Promise.allSettled(entries.map((b) => b.dispose()));
|
||||
}
|
||||
}
|
||||
|
||||
/** Single shared instance — referenced only by the server's onClose hook in Phase 0. */
|
||||
export const agentPool = new AgentPool();
|
||||
@@ -4,7 +4,7 @@ import { exec as execCb, execFile as execFileCb } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { PROVIDERS_BY_NAME } from './provider-registry.js';
|
||||
import { resolveAcpProbeBinaries } from './acp-spawn.js';
|
||||
import { clearProviderSnapshotCache } from './provider-snapshot.js';
|
||||
import { clearProviderSnapshotCache, fetchLlamaSwapModels, prefixLlamaSwapModels } from './provider-snapshot.js';
|
||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||
import { loadConfig } from '../config.js';
|
||||
import { loadProviderConfig } from './provider-config-registry.js';
|
||||
@@ -117,6 +117,15 @@ export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<voi
|
||||
if (agentName === 'qwen') {
|
||||
models = await readQwenSettingsModels();
|
||||
}
|
||||
if (providerDef?.mergeLlamaSwap) {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const llamaModels = prefixLlamaSwapModels(await fetchLlamaSwapModels(config));
|
||||
models = [...models, ...llamaModels];
|
||||
} catch (err) {
|
||||
log.warn({ agent: agentName, err: err instanceof Error ? err.message : String(err) }, 'agent-probe: llama-swap model fetch failed (non-fatal)');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const label = resolved.configLabel ?? resolved.label;
|
||||
|
||||
784
apps/coder/src/services/backends/opencode-server.ts
Normal file
784
apps/coder/src/services/backends/opencode-server.ts
Normal file
@@ -0,0 +1,784 @@
|
||||
/**
|
||||
* v2.6 Phase 1 — OpenCodeServerBackend.
|
||||
*
|
||||
* Warm, multi-turn backend for the `opencode` agent. One `opencode serve` HTTP
|
||||
* server per BooCoder process; one opencode session per BooCode session (resumed
|
||||
* on switch-back); one SSE read loop PER session, each scoped to that session's
|
||||
* worktree directory so sessions in different directories stream concurrently
|
||||
* (P1.5-a — replaced the Phase-1 single-stream-last-directory model).
|
||||
*
|
||||
* Implements the Phase 0 `AgentBackend` interface. Emits transport-agnostic
|
||||
* `AgentEvent`s — the dispatcher (Phase 1.7, NOT wired in this batch) maps them
|
||||
* to WS frames. No dispatcher/route references this file yet.
|
||||
*
|
||||
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2 / §2a.
|
||||
* SDK shapes verified by direct read of @opencode-ai/sdk@1.15.12 dist .d.ts:
|
||||
* - client methods take FLATTENED params (sessionID/directory/body all inline),
|
||||
* not {path,query,body}. create→{directory}, promptAsync→{sessionID,directory,
|
||||
* parts,model}, abort→{sessionID,directory}. model is {providerID,modelID}.
|
||||
* - client.event() resolves to { stream: AsyncGenerator<GlobalEvent> }; the
|
||||
* real event is chunk.payload (discriminate on chunk.payload.type).
|
||||
* - promptAsync is fire-and-forget (204); the turn completes via a
|
||||
* 'session.idle' event for that opencode session id.
|
||||
*/
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createServer } from 'node:net';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type OpencodeClient,
|
||||
type Event,
|
||||
type Part,
|
||||
type ToolPart,
|
||||
type ToolState,
|
||||
type AssistantMessage,
|
||||
} from '@opencode-ai/sdk/v2/client';
|
||||
import type { ToolCallStatus } from '@agentclientprotocol/sdk';
|
||||
import type { Sql } from '../../db.js';
|
||||
import type { AcpToolSnapshot } from '../acp-tool-snapshot.js';
|
||||
import type {
|
||||
AgentBackend,
|
||||
AgentEvent,
|
||||
AgentSessionHandle,
|
||||
EnsureSessionOpts,
|
||||
PromptCtx,
|
||||
TurnResult,
|
||||
} from '../agent-backend.js';
|
||||
|
||||
const READY_TIMEOUT_MS = 30_000;
|
||||
const SSE_RECONNECT_DELAY_MS = 1_000;
|
||||
/**
|
||||
* No-activity backstop for an in-flight turn. opencode streams reasoning/text/tool
|
||||
* deltas continuously while working, so "zero events for this long" means the turn
|
||||
* is wedged or its terminal event (session.idle) was lost (see the reconnect race
|
||||
* below). Generous so a legitimately slow turn never trips it.
|
||||
*/
|
||||
const TURN_INACTIVITY_MS = 180_000;
|
||||
|
||||
/** One in-flight turn's emitter + completion settler. */
|
||||
interface TurnState {
|
||||
onEvent: (e: AgentEvent) => void;
|
||||
settle: (r: TurnResult) => void;
|
||||
}
|
||||
|
||||
/** Per-(opencode session) demux state. dedup sets scoped here, cleared per turn. */
|
||||
interface SessionState {
|
||||
boocodeSessionId: string;
|
||||
agentSessionId: string;
|
||||
/** Worktree directory for SDK `directory` routing; refreshed each turn from ctx. */
|
||||
worktreePath: string;
|
||||
/** dedup gate: `${type}:${id}` added on delta, deleted-and-tested on updated. Cleared at turn end. */
|
||||
streamedPartKeys: Set<string>;
|
||||
/** partID → 'text' | 'reasoning', so a delta with a non-'reasoning' field is still classed right. Cleared at turn end. */
|
||||
partTypeById: Map<string, string>;
|
||||
activeTurn: TurnState | null;
|
||||
/** Inactivity backstop timer for the active turn; null when no turn in flight. */
|
||||
watchdog: ReturnType<typeof setTimeout> | null;
|
||||
/** Per-session SSE subscription handle. Non-null while the loop is running;
|
||||
* aborting it tears down the underlying fetch and exits the loop. */
|
||||
sseAbort: AbortController | null;
|
||||
}
|
||||
|
||||
export interface OpenCodeServerBackendDeps {
|
||||
sql: Sql;
|
||||
log: FastifyBaseLogger;
|
||||
/** Absolute path to the opencode binary (resolved from available_agents at wiring time, Phase 1.7). */
|
||||
opencodeBinary: string;
|
||||
}
|
||||
|
||||
export class OpenCodeServerBackend implements AgentBackend {
|
||||
readonly backend = 'opencode_server' as const;
|
||||
|
||||
private readonly sql: Sql;
|
||||
private readonly log: FastifyBaseLogger;
|
||||
private readonly opencodeBinary: string;
|
||||
|
||||
private child: ChildProcess | null = null;
|
||||
private client: OpencodeClient | null = null;
|
||||
private port: number | null = null;
|
||||
private up = false;
|
||||
private serverStarting: Promise<void> | null = null;
|
||||
|
||||
/** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */
|
||||
private readonly byOpencodeId = new Map<string, SessionState>();
|
||||
|
||||
constructor(deps: OpenCodeServerBackendDeps) {
|
||||
this.sql = deps.sql;
|
||||
this.log = deps.log;
|
||||
this.opencodeBinary = deps.opencodeBinary;
|
||||
}
|
||||
|
||||
/** §2: liveness for the health endpoint + dispatcher fallback decision. */
|
||||
health(): 'up' | 'down' {
|
||||
return this.up ? 'up' : 'down';
|
||||
}
|
||||
|
||||
// ─── Server lifecycle (1.2: spawn once + client + ready) ─────────────────────
|
||||
|
||||
/** Lazy: start the single server on first use. Idempotent — one server per backend. */
|
||||
private ensureServer(): Promise<void> {
|
||||
if (!this.serverStarting) this.serverStarting = this.startServer();
|
||||
return this.serverStarting;
|
||||
}
|
||||
|
||||
private async startServer(): Promise<void> {
|
||||
const port = await freePort();
|
||||
|
||||
// Phase 1: run unsecured on loopback (opencode's documented default — serve.ts
|
||||
// only WARNS when OPENCODE_SERVER_PASSWORD is unset). The real boundary is the
|
||||
// 127.0.0.1 bind. Defense-in-depth basic-auth is deferred: the hey-api client's
|
||||
// auth wiring + opencode's exact scheme must be confirmed against a live server
|
||||
// first, else every request 401s. Recon explicitly said "do NOT block on it".
|
||||
const child = spawn(this.opencodeBinary, ['serve', '--hostname', '127.0.0.1', '--port', String(port)], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
this.child = child;
|
||||
this.port = port;
|
||||
|
||||
// Child lifetime is the backend's (the pool's), NOT a request's. We never tie
|
||||
// it to a per-turn abort signal. On unexpected exit we mark down + log; crash
|
||||
// recovery is Phase 3.
|
||||
child.on('exit', (code, signal) => {
|
||||
this.up = false;
|
||||
this.log.warn({ code, signal, port }, 'opencode-server: child exited (recovery is Phase 3)');
|
||||
});
|
||||
|
||||
await waitForReady(child, READY_TIMEOUT_MS);
|
||||
|
||||
this.client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` });
|
||||
this.up = true;
|
||||
this.log.info({ port }, 'opencode-server: ready');
|
||||
}
|
||||
|
||||
// ─── SSE read loop + demux + translate (1.3) + dedup (1.4) ───────────────────
|
||||
|
||||
/** Per-session SSE subscription, scoped to the session's worktree directory.
|
||||
* opencode scopes events by the `directory` query param (defaults to the
|
||||
* server's cwd if omitted), so two sessions in different worktrees each get
|
||||
* their own dir-scoped stream and never drop each other's events. Idempotent:
|
||||
* a no-op if this session's loop is already running. Started from ensureSession
|
||||
* (and defensively from prompt) once worktreePath is known. */
|
||||
private startSessionEventLoop(state: SessionState): void {
|
||||
if (state.sseAbort) return; // already running
|
||||
const abort = new AbortController();
|
||||
state.sseAbort = abort;
|
||||
void this.runSessionEventLoop(state, abort).finally(() => {
|
||||
// Only clear if this controller is still the live one (a later restart may
|
||||
// have already installed a new one).
|
||||
if (state.sseAbort === abort) state.sseAbort = null;
|
||||
});
|
||||
}
|
||||
|
||||
private async runSessionEventLoop(state: SessionState, abort: AbortController): Promise<void> {
|
||||
const signal = abort.signal;
|
||||
while (this.up && this.client && !signal.aborted) {
|
||||
try {
|
||||
// Re-read worktreePath each (re)subscribe so a directory refresh is picked
|
||||
// up on reconnect. Passing `signal` lets close/dispose tear down a stream
|
||||
// that's parked in `for await` between events.
|
||||
const sub = await this.client.event.subscribe(
|
||||
{ directory: state.worktreePath },
|
||||
{ signal },
|
||||
);
|
||||
for await (const ev of sub.stream) {
|
||||
if (signal.aborted) break;
|
||||
// Dir-scoped streams should only carry this session's events, but two
|
||||
// sessions sharing a worktree (possible post-P1.5-b) each receive BOTH
|
||||
// sessions' events — so drop anything that isn't ours, else the other
|
||||
// session's deltas get processed twice (once per loop).
|
||||
const sid = eventSessionId(ev);
|
||||
if (sid != null && sid !== state.agentSessionId) continue;
|
||||
this.dispatchEvent(ev);
|
||||
}
|
||||
if (this.up && !signal.aborted) {
|
||||
await this.reconcile(state); // recover an idle/error lost during the gap
|
||||
await sleep(SSE_RECONNECT_DELAY_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!this.up || signal.aborted) break;
|
||||
this.log.warn(
|
||||
{ err: errMsg(err), agentSessionId: state.agentSessionId },
|
||||
'opencode-server: session event loop error; reconnecting',
|
||||
);
|
||||
await this.reconcile(state);
|
||||
await sleep(SSE_RECONNECT_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Demux one event to the owning session's active turn. Unknown/between-turns → drop. */
|
||||
private dispatchEvent(ev: Event): void {
|
||||
switch (ev.type) {
|
||||
// ─── session.next.* — live streaming events (the primary path) ─────────
|
||||
case 'session.next.text.delta': {
|
||||
const p = ev.properties;
|
||||
const st = this.byOpencodeId.get(p.sessionID);
|
||||
if (!st?.activeTurn) return;
|
||||
this.bumpActivity(st);
|
||||
const cleaned = stripDcpTags(p.delta);
|
||||
if (cleaned) st.activeTurn.onEvent({ type: 'text', text: cleaned });
|
||||
return;
|
||||
}
|
||||
case 'session.next.reasoning.delta': {
|
||||
const p = ev.properties;
|
||||
const st = this.byOpencodeId.get(p.sessionID);
|
||||
if (!st?.activeTurn) return;
|
||||
this.bumpActivity(st);
|
||||
st.activeTurn.onEvent({ type: 'reasoning', text: p.delta });
|
||||
return;
|
||||
}
|
||||
case 'session.next.tool.called': {
|
||||
const p = ev.properties;
|
||||
const st = this.byOpencodeId.get(p.sessionID);
|
||||
if (!st?.activeTurn) return;
|
||||
this.bumpActivity(st);
|
||||
const snap: AcpToolSnapshot = {
|
||||
toolCallId: p.callID,
|
||||
title: p.tool,
|
||||
kind: null,
|
||||
status: 'in_progress',
|
||||
rawInput: p.input,
|
||||
rawOutput: undefined,
|
||||
};
|
||||
st.activeTurn.onEvent({ type: 'tool_call', toolCall: snap });
|
||||
return;
|
||||
}
|
||||
case 'session.next.tool.success': {
|
||||
const p = ev.properties;
|
||||
const st = this.byOpencodeId.get(p.sessionID);
|
||||
if (!st?.activeTurn) return;
|
||||
this.bumpActivity(st);
|
||||
const output = p.content?.map((c) => ('text' in c ? (c as { text: string }).text : '')).join('') ?? '';
|
||||
const snap: AcpToolSnapshot = {
|
||||
toolCallId: p.callID,
|
||||
title: p.callID,
|
||||
kind: null,
|
||||
status: 'completed',
|
||||
rawInput: undefined,
|
||||
rawOutput: output,
|
||||
};
|
||||
st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap });
|
||||
return;
|
||||
}
|
||||
case 'session.next.tool.failed': {
|
||||
const p = ev.properties;
|
||||
const st = this.byOpencodeId.get(p.sessionID);
|
||||
if (!st?.activeTurn) return;
|
||||
this.bumpActivity(st);
|
||||
const snap: AcpToolSnapshot = {
|
||||
toolCallId: p.callID,
|
||||
title: p.callID,
|
||||
kind: null,
|
||||
status: 'failed',
|
||||
rawInput: undefined,
|
||||
rawOutput: errToString(p.error),
|
||||
};
|
||||
st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap });
|
||||
return;
|
||||
}
|
||||
// ─── message.part.* — terminal/post-hoc events (dedup gate) ────────────
|
||||
case 'message.part.delta': {
|
||||
const p = ev.properties;
|
||||
const st = this.byOpencodeId.get(p.sessionID);
|
||||
if (!st?.activeTurn) return;
|
||||
this.bumpActivity(st);
|
||||
const isReasoning = p.field === 'reasoning' || st.partTypeById.get(p.partID) === 'reasoning';
|
||||
if (isReasoning) {
|
||||
st.streamedPartKeys.add(`reasoning:${p.partID}`);
|
||||
st.activeTurn.onEvent({ type: 'reasoning', text: p.delta });
|
||||
} else if (p.field === 'text') {
|
||||
st.streamedPartKeys.add(`text:${p.partID}`);
|
||||
const cleaned = stripDcpTags(p.delta);
|
||||
if (cleaned) st.activeTurn.onEvent({ type: 'text', text: cleaned });
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'message.part.updated': {
|
||||
const part = ev.properties.part;
|
||||
const st = this.byOpencodeId.get(part.sessionID);
|
||||
if (!st?.activeTurn) return;
|
||||
this.bumpActivity(st);
|
||||
this.handleUpdatedPart(part, st);
|
||||
return;
|
||||
}
|
||||
// ─── lifecycle ─────────────────────────────────────────────────────────
|
||||
case 'session.idle': {
|
||||
this.byOpencodeId.get(ev.properties.sessionID)?.activeTurn?.settle({ ok: true });
|
||||
return;
|
||||
}
|
||||
case 'session.error': {
|
||||
const sid = ev.properties.sessionID;
|
||||
if (!sid) return;
|
||||
this.byOpencodeId.get(sid)?.activeTurn?.settle({ ok: false, error: errToString(ev.properties.error) });
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Terminal part: dedup gate for text/reasoning; tool parts → tool_call/tool_update. */
|
||||
private handleUpdatedPart(part: Part, st: SessionState): void {
|
||||
const turn = st.activeTurn;
|
||||
if (!turn) return;
|
||||
|
||||
if (part.type === 'text' || part.type === 'reasoning') {
|
||||
st.partTypeById.set(part.id, part.type);
|
||||
const key = resolvePartDedupeKey(part, part.type);
|
||||
if (key && st.streamedPartKeys.delete(key)) return; // already streamed via delta
|
||||
const raw = part.text ?? '';
|
||||
const text = part.type === 'text' ? stripDcpTags(raw) : raw;
|
||||
if (text && part.time?.end != null) {
|
||||
turn.onEvent({ type: part.type, text });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (part.type === 'tool') {
|
||||
const snap = toolPartToSnapshot(part);
|
||||
const status = part.state?.status;
|
||||
// tool_call on start (pending/running), tool_update on terminal (completed/error).
|
||||
// The current ACP path merges both into one frame; the contract keeps them
|
||||
// distinct because opencode's SSE distinguishes start from result.
|
||||
const event: AgentEvent =
|
||||
status === 'completed' || status === 'error'
|
||||
? { type: 'tool_update', toolCall: snap }
|
||||
: { type: 'tool_call', toolCall: snap };
|
||||
turn.onEvent(event);
|
||||
return;
|
||||
}
|
||||
// NOTE: opencode's SSE payload union carries no available-commands event, so the
|
||||
// AgentEvent 'commands' arm is intentionally never emitted here (1.3).
|
||||
}
|
||||
|
||||
// ─── turn-completion resilience (watchdog + reconnect reconcile) ─────────────
|
||||
|
||||
/** Reset the inactivity backstop on any event routed to a session's active turn. */
|
||||
private bumpActivity(st: SessionState): void {
|
||||
if (!st.activeTurn) return;
|
||||
if (st.watchdog) clearTimeout(st.watchdog);
|
||||
st.watchdog = setTimeout(() => {
|
||||
void this.onTurnStall(st);
|
||||
}, TURN_INACTIVITY_MS);
|
||||
st.watchdog.unref?.();
|
||||
}
|
||||
|
||||
/** Watchdog fired: reconcile once; if the server says still-running we can't tell, so fail closed.
|
||||
* Also mark the agent_sessions row crashed so a stale session isn't resumed next turn. */
|
||||
private async onTurnStall(st: SessionState): Promise<void> {
|
||||
const settled = await this.reconcile(st);
|
||||
if (!settled) {
|
||||
this.log.warn({ agentSessionId: st.agentSessionId }, 'opencode-server: turn stalled (no activity), failing + marking crashed');
|
||||
await this.sql`
|
||||
UPDATE agent_sessions SET status = 'crashed'
|
||||
WHERE agent_session_id = ${st.agentSessionId}
|
||||
`.catch(() => {});
|
||||
st.activeTurn?.settle({ ok: false, error: 'turn timed out (no activity)' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the server whether this session's turn already finished — recovers a
|
||||
* session.idle/error lost during an SSE gap. Returns true if it settled the turn.
|
||||
* Inconclusive (still running / call failed) → false; the watchdog covers that.
|
||||
*/
|
||||
private async reconcile(st: SessionState): Promise<boolean> {
|
||||
const turn = st.activeTurn;
|
||||
if (!turn || !this.client) return false;
|
||||
try {
|
||||
const res = await this.client.session.messages({
|
||||
sessionID: st.agentSessionId,
|
||||
directory: st.worktreePath,
|
||||
});
|
||||
if (res.error || !res.data) return false;
|
||||
let lastAssistant: AssistantMessage | undefined;
|
||||
for (let i = res.data.length - 1; i >= 0; i--) {
|
||||
const info = res.data[i]!.info;
|
||||
if (info.role === 'assistant') {
|
||||
lastAssistant = info;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!lastAssistant) return false;
|
||||
if (lastAssistant.error != null) {
|
||||
turn.settle({ ok: false, error: errToString(lastAssistant.error) });
|
||||
return true;
|
||||
}
|
||||
if (lastAssistant.time.completed != null) {
|
||||
turn.settle({ ok: true });
|
||||
return true;
|
||||
}
|
||||
return false; // still running — the live stream will deliver session.idle
|
||||
} catch {
|
||||
return false; // inconclusive — watchdog backstop covers it
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ensureSession: create-or-resume against agent_sessions (1.5) ────────────
|
||||
|
||||
async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> {
|
||||
await this.ensureServer();
|
||||
if (!this.client) throw new Error('opencode-server: client not ready after ensureServer');
|
||||
|
||||
const configHash = sessionConfigHash(opts.model);
|
||||
// P1.5-b: agent_sessions is keyed (chat_id, agent) — the tab/chat is the
|
||||
// context unit (two tabs in one session = two contexts sharing one worktree).
|
||||
// session_id + worktree_id are retained as informational (SET NULL) columns.
|
||||
const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>`
|
||||
SELECT agent_session_id, status, config_hash FROM agent_sessions
|
||||
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
|
||||
`;
|
||||
let agentSessionId = row?.agent_session_id ?? null;
|
||||
|
||||
// Don't resume crashed sessions or sessions whose config drifted (model change).
|
||||
const shouldResume = agentSessionId
|
||||
&& row!.status !== 'crashed'
|
||||
&& (row!.config_hash == null || row!.config_hash === configHash);
|
||||
|
||||
if (!shouldResume) {
|
||||
if (agentSessionId) {
|
||||
this.log.info({ sessionId, oldStatus: row!.status, hashMatch: row!.config_hash === configHash },
|
||||
'opencode-server: not resuming stale session, creating fresh');
|
||||
this.byOpencodeId.delete(agentSessionId);
|
||||
}
|
||||
const created = await this.client.session.create({ directory: opts.worktreePath });
|
||||
if (created.error || !created.data) {
|
||||
throw new Error(`opencode-server: session.create failed: ${errToString(created.error)}`);
|
||||
}
|
||||
agentSessionId = created.data.id;
|
||||
await this.sql`
|
||||
INSERT INTO agent_sessions
|
||||
(chat_id, session_id, worktree_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
|
||||
VALUES
|
||||
(${opts.chatId}, ${sessionId}, ${opts.worktreeId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
|
||||
ON CONFLICT (chat_id, agent) DO UPDATE SET
|
||||
session_id = EXCLUDED.session_id,
|
||||
worktree_id = EXCLUDED.worktree_id,
|
||||
backend = 'opencode_server',
|
||||
agent_session_id = EXCLUDED.agent_session_id,
|
||||
server_port = EXCLUDED.server_port,
|
||||
status = 'active',
|
||||
last_active_at = clock_timestamp(),
|
||||
config_hash = EXCLUDED.config_hash
|
||||
`;
|
||||
} else {
|
||||
await this.sql`
|
||||
UPDATE agent_sessions
|
||||
SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash}
|
||||
WHERE chat_id = ${opts.chatId} AND agent = ${opts.agent}
|
||||
`;
|
||||
}
|
||||
|
||||
// Both branches above guarantee agentSessionId is non-null.
|
||||
const ocSessionId = agentSessionId!;
|
||||
|
||||
// Register / refresh the demux entry the SSE loop keys on. Preserve an existing
|
||||
// entry (and any in-flight turn) — just refresh the routing fields.
|
||||
let state = this.byOpencodeId.get(ocSessionId);
|
||||
if (state) {
|
||||
state.boocodeSessionId = sessionId;
|
||||
state.worktreePath = opts.worktreePath;
|
||||
} else {
|
||||
state = {
|
||||
boocodeSessionId: sessionId,
|
||||
agentSessionId: ocSessionId,
|
||||
worktreePath: opts.worktreePath,
|
||||
streamedPartKeys: new Set(),
|
||||
partTypeById: new Map(),
|
||||
activeTurn: null,
|
||||
watchdog: null,
|
||||
sseAbort: null,
|
||||
};
|
||||
this.byOpencodeId.set(ocSessionId, state);
|
||||
}
|
||||
|
||||
// Start this session's own SSE loop, scoped to its worktree directory. Both
|
||||
// fresh-create and resume reach here; idempotent, so a re-ensure (e.g. a
|
||||
// second turn) won't spawn a duplicate loop.
|
||||
this.startSessionEventLoop(state);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
agent: opts.agent,
|
||||
backend: 'opencode_server',
|
||||
chatId: opts.chatId,
|
||||
worktreeId: opts.worktreeId,
|
||||
agentSessionId: ocSessionId,
|
||||
serverPort: this.port,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── prompt: send one turn (1.6) ─────────────────────────────────────────────
|
||||
|
||||
async prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult> {
|
||||
if (!this.client) throw new Error('opencode-server: client not ready');
|
||||
const oc = handle.agentSessionId;
|
||||
if (!oc) throw new Error('opencode-server: handle has no agentSessionId');
|
||||
|
||||
let state = this.byOpencodeId.get(oc);
|
||||
if (!state) {
|
||||
state = {
|
||||
boocodeSessionId: handle.sessionId,
|
||||
agentSessionId: oc,
|
||||
worktreePath: ctx.worktreePath,
|
||||
streamedPartKeys: new Set(),
|
||||
partTypeById: new Map(),
|
||||
activeTurn: null,
|
||||
watchdog: null,
|
||||
sseAbort: null,
|
||||
};
|
||||
this.byOpencodeId.set(oc, state);
|
||||
}
|
||||
const session = state;
|
||||
// Authoritative per-turn directory for SDK routing + reconcile.
|
||||
session.worktreePath = ctx.worktreePath;
|
||||
// Defensive: ensureSession normally starts the loop, but if prompt is reached
|
||||
// with a freshly-created state (no loop yet), start it so the turn streams.
|
||||
// Idempotent when ensureSession already started one.
|
||||
this.startSessionEventLoop(session);
|
||||
const client = this.client;
|
||||
|
||||
return await new Promise<TurnResult>((resolve) => {
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
session.activeTurn = null;
|
||||
if (session.watchdog) {
|
||||
clearTimeout(session.watchdog);
|
||||
session.watchdog = null;
|
||||
}
|
||||
session.streamedPartKeys.clear();
|
||||
session.partTypeById.clear();
|
||||
ctx.signal.removeEventListener('abort', onAbort);
|
||||
};
|
||||
const settle = (r: TurnResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(r);
|
||||
};
|
||||
const onAbort = () => {
|
||||
// Abort the turn only — never the server.
|
||||
client.session.abort({ sessionID: oc, directory: ctx.worktreePath }).catch(() => {});
|
||||
settle({ ok: false, error: 'aborted' });
|
||||
};
|
||||
|
||||
session.activeTurn = { onEvent: ctx.onEvent, settle };
|
||||
this.bumpActivity(session); // arm the inactivity backstop
|
||||
|
||||
if (ctx.signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
ctx.signal.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
const model = parseModel(ctx.model);
|
||||
client.session
|
||||
.promptAsync({
|
||||
sessionID: oc,
|
||||
directory: ctx.worktreePath,
|
||||
parts: [{ type: 'text', text: input }],
|
||||
...(model ? { model } : {}),
|
||||
})
|
||||
.then((res) => {
|
||||
// promptAsync is fire-and-forget (204); the turn completes via session.idle.
|
||||
// Only a submission error settles here.
|
||||
if (res.error) settle({ ok: false, error: errToString(res.error) });
|
||||
})
|
||||
.catch((err) => settle({ ok: false, error: errMsg(err) }));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── teardown ────────────────────────────────────────────────────────────────
|
||||
|
||||
async closeSession(handle: AgentSessionHandle): Promise<void> {
|
||||
if (handle.agentSessionId) {
|
||||
// Stop this session's SSE loop before dropping its demux entry.
|
||||
this.byOpencodeId.get(handle.agentSessionId)?.sseAbort?.abort();
|
||||
this.byOpencodeId.delete(handle.agentSessionId);
|
||||
}
|
||||
await this.sql`
|
||||
UPDATE agent_sessions SET status = 'closed'
|
||||
WHERE chat_id = ${handle.chatId} AND agent = ${handle.agent}
|
||||
`.catch(() => {});
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.up = false;
|
||||
// Abort every per-session SSE loop so none survive the teardown.
|
||||
for (const st of this.byOpencodeId.values()) st.sseAbort?.abort();
|
||||
const child = this.child;
|
||||
this.child = null;
|
||||
this.client = null;
|
||||
this.byOpencodeId.clear();
|
||||
if (child && !child.killed) {
|
||||
child.kill('SIGTERM');
|
||||
const t = setTimeout(() => {
|
||||
if (!child.killed) child.kill('SIGKILL');
|
||||
}, 5_000);
|
||||
t.unref();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract the opencode sessionID an event belongs to, across event shapes.
|
||||
* Most carry `properties.sessionID`; `message.part.updated` nests it under
|
||||
* `properties.part.sessionID`. Returns null when the event has no session
|
||||
* (the per-session loop then leaves it to dispatchEvent, which drops it). */
|
||||
function eventSessionId(ev: Event): string | null {
|
||||
const props = (ev as { properties?: unknown }).properties;
|
||||
if (!props || typeof props !== 'object') return null;
|
||||
if (ev.type === 'message.part.updated') {
|
||||
const part = (props as { part?: { sessionID?: string } }).part;
|
||||
return part?.sessionID ?? null;
|
||||
}
|
||||
return (props as { sessionID?: string }).sessionID ?? null;
|
||||
}
|
||||
|
||||
/** BooCoder model string "provider/model" → opencode's structured {providerID, modelID}. */
|
||||
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
|
||||
if (!model || !model.trim()) return undefined;
|
||||
const trimmed = model.trim();
|
||||
const idx = trimmed.indexOf('/');
|
||||
if (idx > 0 && idx < trimmed.length - 1) {
|
||||
return { providerID: trimmed.slice(0, idx), modelID: trimmed.slice(idx + 1) };
|
||||
}
|
||||
// No slash but non-empty → infer llama-swap (the only configured provider).
|
||||
// Guard against bare '/' or trailing/leading slash.
|
||||
if (idx < 0 && trimmed.length > 0) {
|
||||
return { providerID: 'llama-swap', modelID: trimmed };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Ported verbatim from Paseo opencode-agent.ts: id → message-id fallback → null. */
|
||||
function resolvePartDedupeKey(part: { id: string; messageID: string }, type: string): string | null {
|
||||
if (part.id.trim().length > 0) return `${type}:${part.id}`;
|
||||
if (part.messageID.trim().length > 0) return `${type}:message:${part.messageID}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** opencode ToolPart → ACP-shaped snapshot (reuses the existing persist/render path). */
|
||||
function toolPartToSnapshot(part: ToolPart): AcpToolSnapshot {
|
||||
const state = part.state;
|
||||
let rawInput: unknown;
|
||||
let rawOutput: unknown;
|
||||
let title: string | undefined;
|
||||
if (state) {
|
||||
if ('input' in state) rawInput = (state as { input?: unknown }).input;
|
||||
if ('output' in state) rawOutput = (state as { output?: unknown }).output;
|
||||
else if ('error' in state) rawOutput = (state as { error?: unknown }).error;
|
||||
if ('title' in state) title = (state as { title?: string }).title;
|
||||
}
|
||||
return {
|
||||
toolCallId: part.callID,
|
||||
title: title ?? part.tool,
|
||||
kind: null,
|
||||
status: mapToolStatus(state?.status),
|
||||
rawInput,
|
||||
rawOutput,
|
||||
};
|
||||
}
|
||||
|
||||
function mapToolStatus(s: ToolState['status'] | undefined): ToolCallStatus | null {
|
||||
switch (s) {
|
||||
case 'pending':
|
||||
return 'pending';
|
||||
case 'running':
|
||||
return 'in_progress';
|
||||
case 'completed':
|
||||
return 'completed';
|
||||
case 'error':
|
||||
return 'failed';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bind-probe an ephemeral port on loopback. */
|
||||
function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = createServer();
|
||||
srv.unref();
|
||||
srv.on('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const addr = srv.address();
|
||||
if (addr && typeof addr === 'object') {
|
||||
const { port } = addr;
|
||||
srv.close(() => resolve(port));
|
||||
} else {
|
||||
srv.close(() => reject(new Error('opencode-server: could not determine a free port')));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve when the child prints the ready line; reject on timeout or early exit. */
|
||||
function waitForReady(child: ChildProcess, timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false;
|
||||
let stderrBuf = '';
|
||||
|
||||
const finish = (err?: Error) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
child.stdout?.off('data', onOut);
|
||||
child.stderr?.off('data', onErr);
|
||||
child.off('exit', onExit);
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
};
|
||||
|
||||
const onOut = (buf: Buffer) => {
|
||||
if (buf.toString().includes('opencode server listening on')) finish();
|
||||
};
|
||||
const onErr = (buf: Buffer) => {
|
||||
stderrBuf += buf.toString();
|
||||
};
|
||||
const onExit = (code: number | null) =>
|
||||
finish(new Error(`opencode serve exited before ready (code ${code}); stderr: ${stderrBuf.slice(-2000)}`));
|
||||
const timer = setTimeout(
|
||||
() => finish(new Error(`opencode serve not ready in ${timeoutMs}ms; stderr: ${stderrBuf.slice(-2000)}`)),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
child.stdout?.on('data', onOut);
|
||||
child.stderr?.on('data', onErr);
|
||||
child.on('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/** Strip opencode-dcp plugin tags that render as literal text in the UI. */
|
||||
function stripDcpTags(s: string): string {
|
||||
return s.replace(/<dcp-message-id>[^<]*<\/dcp-message-id>/g, '');
|
||||
}
|
||||
|
||||
function errMsg(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
function errToString(e: unknown): string {
|
||||
if (e == null) return 'unknown error';
|
||||
if (typeof e === 'string') return e;
|
||||
if (e instanceof Error) return e.message;
|
||||
try {
|
||||
return JSON.stringify(e);
|
||||
} catch {
|
||||
return String(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hash of stable config — detects model changes across sessions without
|
||||
* invalidating on ephemeral state like the random server port (which changes
|
||||
* every BooCoder restart). */
|
||||
function sessionConfigHash(model: string): string {
|
||||
return createHash('sha256').update(`opencode_server|${model}`).digest('hex').slice(0, 16);
|
||||
}
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,13 +3,18 @@ import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import type { Config } from '../config.js';
|
||||
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
|
||||
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
|
||||
import { makeDcpStreamStripper } from './dcp-strip.js';
|
||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||
import { getResolvedRegistry } from './provider-config-registry.js';
|
||||
import { dispatchViaPty } from './pty-dispatch.js';
|
||||
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
|
||||
import { getManifestCommands } from './provider-commands.js';
|
||||
import { persistExternalAgentTurn } from './agent-turn-persist.js';
|
||||
import { snapshotToWireToolCall, type AcpToolSnapshot } from './acp-tool-snapshot.js';
|
||||
import { agentPool } from './agent-pool.js';
|
||||
import { OpenCodeServerBackend } from './backends/opencode-server.js';
|
||||
import type { AgentBackend, AgentEvent } from './agent-backend.js';
|
||||
|
||||
interface InferenceRunner {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||
@@ -35,47 +40,66 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
const { sql, inference, broker, log, config } = deps;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let listener: { unlisten: () => Promise<void> } | null = null;
|
||||
let running = false;
|
||||
let polling = false;
|
||||
let stopping = false;
|
||||
let inflightPromise: Promise<void> | null = null;
|
||||
// v2.6 (1.9): per-session in-flight registry replaces the global `running`
|
||||
// boolean. Key = session_id (or `task:<id>` for sessionless tasks). Sessions
|
||||
// without an in-flight turn run concurrently; within a session, strictly one
|
||||
// turn at a time.
|
||||
const inflight = new Map<string, Promise<void>>();
|
||||
|
||||
// Shared entry point for both the poll timer and the NOTIFY listener. poll()'s
|
||||
// `running`/`stopping` guard makes this safe to call concurrently — a notify
|
||||
// arriving mid-task returns immediately and never double-dispatches.
|
||||
// `polling`/`stopping` guard makes this safe to call concurrently — a notify
|
||||
// arriving mid-poll returns immediately and never double-dispatches.
|
||||
function triggerPoll(reason: string): void {
|
||||
poll().catch((err) => {
|
||||
log.error({ err, reason }, 'dispatcher: poll error');
|
||||
});
|
||||
}
|
||||
|
||||
function concurrencyKey(task: { id: string; session_id: string | null }): string {
|
||||
return task.session_id ?? `task:${task.id}`;
|
||||
}
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
if (running || stopping) return;
|
||||
|
||||
// Grab one pending task
|
||||
const rows = await sql<{
|
||||
id: string;
|
||||
project_id: string;
|
||||
input: string;
|
||||
agent: string | null;
|
||||
model: string | null;
|
||||
mode_id: string | null;
|
||||
thinking_option_id: string | null;
|
||||
session_id: string | null;
|
||||
}[]>`
|
||||
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
|
||||
FROM tasks
|
||||
WHERE state = 'pending'
|
||||
ORDER BY created_at
|
||||
LIMIT 1
|
||||
`;
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const task = rows[0]!;
|
||||
running = true;
|
||||
inflightPromise = runTask(task).finally(() => {
|
||||
running = false;
|
||||
inflightPromise = null;
|
||||
});
|
||||
// `polling` serializes poll() execution itself (timer + NOTIFY can fire
|
||||
// concurrently) so we never double-select a task. It does NOT serialize task
|
||||
// execution — that's what `inflight` (keyed per session) governs.
|
||||
if (polling || stopping) return;
|
||||
polling = true;
|
||||
try {
|
||||
// Oldest-first; start every pending task whose session isn't already busy.
|
||||
const rows = await sql<{
|
||||
id: string;
|
||||
project_id: string;
|
||||
input: string;
|
||||
agent: string | null;
|
||||
model: string | null;
|
||||
mode_id: string | null;
|
||||
thinking_option_id: string | null;
|
||||
session_id: string | null;
|
||||
chat_id: string | null;
|
||||
}[]>`
|
||||
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id, chat_id
|
||||
FROM tasks
|
||||
WHERE state = 'pending'
|
||||
ORDER BY created_at
|
||||
LIMIT 50
|
||||
`;
|
||||
for (const task of rows) {
|
||||
if (stopping) break;
|
||||
const key = concurrencyKey(task);
|
||||
if (inflight.has(key)) continue; // this session already has an in-flight turn
|
||||
// Register synchronously (before any await) so a later row in this pass
|
||||
// with the same key is skipped and a concurrent poll can't re-pick it.
|
||||
const p = runTask(task).finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, p);
|
||||
}
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runTask(task: {
|
||||
@@ -87,6 +111,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
mode_id: string | null;
|
||||
thinking_option_id: string | null;
|
||||
session_id: string | null;
|
||||
chat_id: string | null;
|
||||
}): Promise<void> {
|
||||
const taskId = task.id;
|
||||
|
||||
@@ -96,7 +121,13 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent}
|
||||
`;
|
||||
if (agentRow) {
|
||||
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
|
||||
// v2.6 (1.7): opencode routes to the warm pool backend; every other
|
||||
// external agent keeps the existing one-shot ACP/PTY path untouched.
|
||||
if (task.agent === 'opencode') {
|
||||
await runOpenCodeServerTask(task, agentRow.install_path);
|
||||
} else {
|
||||
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Agent specified but not available — fall through to Path A with a warning
|
||||
@@ -456,6 +487,306 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Path B (opencode): warm OpenCode server backend (v2.6 1.7 + 1.10) ───────
|
||||
|
||||
// OpenCode runs ONE server per BooCoder process, shared across all sessions
|
||||
// (the backend multiplexes sessions internally), so it's pooled under a fixed
|
||||
// key rather than per-session. Warm ACP backends (Phase 2) will be per-session.
|
||||
const OPENCODE_POOL_KEY = '__opencode_server__';
|
||||
|
||||
function getOpenCodeBackend(installPath: string | null): AgentBackend {
|
||||
let backend = agentPool.get(OPENCODE_POOL_KEY, 'opencode');
|
||||
if (!backend) {
|
||||
backend = new OpenCodeServerBackend({ sql, log, opencodeBinary: installPath ?? 'opencode' });
|
||||
agentPool.register(OPENCODE_POOL_KEY, 'opencode', backend);
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
|
||||
async function runOpenCodeServerTask(
|
||||
task: {
|
||||
id: string;
|
||||
project_id: string;
|
||||
input: string;
|
||||
agent: string | null;
|
||||
model: string | null;
|
||||
mode_id: string | null;
|
||||
thinking_option_id: string | null;
|
||||
session_id: string | null;
|
||||
chat_id: string | null;
|
||||
},
|
||||
installPath: string | null,
|
||||
): Promise<void> {
|
||||
const taskId = task.id;
|
||||
const agent = 'opencode';
|
||||
log.info({ taskId, agent }, 'dispatcher: starting task (path B — opencode server)');
|
||||
|
||||
const [project] = await sql<{ path: string | null }[]>`
|
||||
SELECT path FROM projects WHERE id = ${task.project_id}
|
||||
`;
|
||||
const projectPath = project?.path;
|
||||
if (!projectPath) {
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no path — cannot create worktree'
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const ac = new AbortController();
|
||||
|
||||
try {
|
||||
// execution_path = 'acp' — the schema CHECK has no 'opencode_server' value
|
||||
// (schema is frozen at Phase 0); the warm-vs-one-shot distinction lives in
|
||||
// agent_sessions.backend. Reuse the closest existing value.
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'running', started_at = clock_timestamp(), execution_path = 'acp'
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
|
||||
// Resolve session + chat. P1.5-b: the chat (tab) is the context key, so the
|
||||
// chat_id MUST be non-null and stable before ensureSession. The coder message
|
||||
// route + skills route stamp task.chat_id with the frontend tab's chat — use
|
||||
// it directly. Session-less creators (arena, MCP, new_task, generic
|
||||
// /api/tasks) leave it null; fall back to resolving/creating a real chat so
|
||||
// ensureSession never receives a degenerate (null, agent) key.
|
||||
let sessionId: string;
|
||||
let chatId: string;
|
||||
if (task.chat_id && task.session_id) {
|
||||
sessionId = task.session_id;
|
||||
chatId = task.chat_id;
|
||||
} else if (task.session_id) {
|
||||
sessionId = task.session_id;
|
||||
const chats = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
|
||||
`;
|
||||
if (chats.length === 0) {
|
||||
const [chat] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, 'External agent execution', 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
chatId = chat!.id;
|
||||
} else {
|
||||
chatId = chats[0]!.id;
|
||||
}
|
||||
} else {
|
||||
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`;
|
||||
const [session] = await sql<{ id: string }[]>`
|
||||
INSERT INTO sessions (project_id, name, model, status)
|
||||
VALUES (${task.project_id}, ${sessionName}, ${task.model ?? config.DEFAULT_MODEL}, 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
sessionId = session!.id;
|
||||
const [chat] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, 'External agent execution', 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
chatId = chat!.id;
|
||||
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
|
||||
}
|
||||
|
||||
if (!task.session_id) {
|
||||
await sql`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
|
||||
`;
|
||||
}
|
||||
|
||||
// Persistent, session-keyed worktree (shared across turns; NOT torn down
|
||||
// per turn — Phase 3 reaps it). Captures base_commit for a stable diff.
|
||||
const { worktreeId, worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
|
||||
signal: ac.signal,
|
||||
});
|
||||
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
|
||||
|
||||
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const assistantId = assistantMsg!.id;
|
||||
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_started',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
role: 'assistant',
|
||||
} as WsFrame);
|
||||
|
||||
const manifestCommands = getManifestCommands(agent);
|
||||
if (manifestCommands.length > 0) {
|
||||
setTaskCommands(taskId, manifestCommands);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'agent_commands',
|
||||
task_id: taskId,
|
||||
session_id: sessionId,
|
||||
commands: manifestCommands,
|
||||
} as WsFrame);
|
||||
}
|
||||
|
||||
// Accumulate the turn's stream for persistence + the final message content.
|
||||
const textChunks: string[] = [];
|
||||
const reasoningChunks: string[] = [];
|
||||
const toolSnaps = new Map<string, AcpToolSnapshot>();
|
||||
// opencode's dcp plugin appends <dcp-message-id>…</dcp-message-id> to the
|
||||
// text, streamed split across deltas — a per-chunk regex misses it (see
|
||||
// dcp-strip.ts). Buffer text through a cross-chunk stripper so neither the
|
||||
// live `delta` frames nor the persisted content ever carry the tag.
|
||||
const dcp = makeDcpStreamStripper();
|
||||
|
||||
// Map transport-agnostic AgentEvents → the SAME WS frames the ACP path emits.
|
||||
// This boundary is where message_id/chat_id get attached (the backend never
|
||||
// owns them).
|
||||
const onEvent = (e: AgentEvent): void => {
|
||||
switch (e.type) {
|
||||
case 'text': {
|
||||
const safe = dcp.push(e.text);
|
||||
if (safe) {
|
||||
textChunks.push(safe);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'delta',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
content: safe,
|
||||
} as WsFrame);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'reasoning':
|
||||
reasoningChunks.push(e.text);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'reasoning_delta',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
content: e.text,
|
||||
} as WsFrame);
|
||||
break;
|
||||
case 'tool_call':
|
||||
case 'tool_update':
|
||||
toolSnaps.set(e.toolCall.toolCallId, e.toolCall);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'tool_call',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
tool_call: snapshotToWireToolCall(e.toolCall),
|
||||
} as WsFrame);
|
||||
break;
|
||||
case 'commands':
|
||||
// opencode-server doesn't emit these today; ignore if it ever does.
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// opencode expects provider-prefixed model ids (e.g. 'llama-swap/qwen3.6-35b…').
|
||||
// DEFAULT_MODEL is bare (no prefix) because native inference uses it directly
|
||||
// against llama-swap. Coalesce empty string (frontend sends '' when no models
|
||||
// listed) and prefix bare ids so parseModel always succeeds.
|
||||
const rawModel = (task.model && task.model.trim()) || config.DEFAULT_MODEL;
|
||||
const model = rawModel.includes('/') ? rawModel : `llama-swap/${rawModel}`;
|
||||
const backend = getOpenCodeBackend(installPath);
|
||||
const handle = await backend.ensureSession(sessionId, {
|
||||
agent,
|
||||
model,
|
||||
chatId,
|
||||
worktreePath,
|
||||
worktreeId,
|
||||
projectId: task.project_id,
|
||||
});
|
||||
const result = await backend.prompt(handle, task.input, {
|
||||
worktreePath,
|
||||
model,
|
||||
signal: ac.signal,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
// Flush any text held back mid-tag at stream end (complete tags stripped).
|
||||
const dcpTail = dcp.flush();
|
||||
if (dcpTail) {
|
||||
textChunks.push(dcpTail);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'delta',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
content: dcpTail,
|
||||
} as WsFrame);
|
||||
}
|
||||
|
||||
const assistantContent = textChunks.join('').slice(0, 50_000);
|
||||
const reasoningText = reasoningChunks.join('').slice(0, 200_000);
|
||||
const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'opencode turn failed').slice(0, 500);
|
||||
|
||||
await persistExternalAgentTurn(sql, assistantId, [...toolSnaps.values()], reasoningText);
|
||||
|
||||
await sql`
|
||||
UPDATE messages
|
||||
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
|
||||
WHERE id = ${assistantId}
|
||||
`;
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
} as WsFrame);
|
||||
|
||||
if (stopping) {
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
|
||||
// 1.10: diff the persistent worktree against its captured baseline and
|
||||
// SUPERSEDE the session's prior pending row (latest-wins, one accumulating
|
||||
// diff) instead of stacking. Stamp agent for DiffPanel attribution.
|
||||
const diff = await diffWorktree(worktreePath, projectPath, {
|
||||
signal: ac.signal,
|
||||
baseRef: baseCommit ?? 'HEAD',
|
||||
});
|
||||
if (diff) {
|
||||
await sql`
|
||||
DELETE FROM pending_changes WHERE session_id = ${sessionId} AND status = 'pending'
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||||
VALUES (${sessionId}, ${taskId}, ${projectPath}, 'edit', ${diff}, ${agent})
|
||||
`;
|
||||
log.info({ taskId, diffLength: diff.length }, 'dispatcher: diff superseded prior pending change');
|
||||
} else {
|
||||
log.info({ taskId }, 'dispatcher: no changes detected in session worktree');
|
||||
}
|
||||
|
||||
// NO worktree cleanup — it's persistent (Phase 3 reaps it). Backend stays warm.
|
||||
|
||||
const [extCostRow] = await sql<{ total: number | null }[]>`
|
||||
SELECT SUM(tokens_used)::int AS total
|
||||
FROM messages
|
||||
WHERE session_id = ${sessionId} AND tokens_used IS NOT NULL
|
||||
`;
|
||||
const extCostTokens = extCostRow?.total ?? null;
|
||||
|
||||
const finalState = result.ok ? 'completed' : 'failed';
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = ${finalState}, ended_at = clock_timestamp(), output_summary = ${outputSummary}, cost_tokens = ${extCostTokens}
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, agent, finalState, costTokens: extCostTokens }, 'dispatcher: task finished (opencode server)');
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
log.error({ taskId, agent, err: errMsg }, 'dispatcher: opencode server error');
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId}
|
||||
`.catch(() => {});
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function waitForCompletion(assistantId: string): Promise<string> {
|
||||
@@ -514,9 +845,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
});
|
||||
listener = null;
|
||||
}
|
||||
if (inflightPromise) {
|
||||
log.info('dispatcher: waiting for in-flight task');
|
||||
await inflightPromise;
|
||||
if (inflight.size > 0) {
|
||||
log.info({ count: inflight.size }, 'dispatcher: waiting for in-flight tasks');
|
||||
await Promise.allSettled([...inflight.values()]);
|
||||
}
|
||||
log.info('dispatcher: stopped');
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ interface AgentRow {
|
||||
last_probed_at: string | Date | null;
|
||||
}
|
||||
|
||||
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
||||
export async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
||||
try {
|
||||
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
|
||||
if (!res.ok) return [];
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* After the agent completes, we diff the worktree against HEAD and
|
||||
* queue the diff into pending_changes.
|
||||
*/
|
||||
import type { Sql } from '../db.js';
|
||||
import { hostExec } from './host-exec.js';
|
||||
|
||||
const WORKTREE_BASE = '/tmp/booworktrees';
|
||||
@@ -45,7 +46,7 @@ export async function createWorktree(
|
||||
export async function diffWorktree(
|
||||
worktreePath: string,
|
||||
projectPath: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
opts?: { signal?: AbortSignal; baseRef?: string },
|
||||
): Promise<string> {
|
||||
// First, commit any uncommitted changes in the worktree so we can diff branches
|
||||
// Stage all changes
|
||||
@@ -74,9 +75,13 @@ export async function diffWorktree(
|
||||
{ signal: opts?.signal, timeoutMs: 15_000 },
|
||||
);
|
||||
|
||||
// Diff the worktree branch against the parent commit (HEAD of main tree)
|
||||
// Diff the worktree branch against the baseline. Per-task callers default to the
|
||||
// main tree's current HEAD; the session-worktree (opencode) path passes the
|
||||
// captured base_commit so the accumulated diff is stable across turns even if
|
||||
// project HEAD advances.
|
||||
const baseRef = opts?.baseRef ?? 'HEAD';
|
||||
const diffResult = await hostExec(
|
||||
`git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
|
||||
`git -C ${shellEscape(projectPath)} diff ${shellEscape(baseRef)}...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
|
||||
{ signal: opts?.signal, timeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
@@ -111,6 +116,246 @@ export async function cleanupWorktree(
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
// ─── v2.6: session-keyed persistent worktree ────────────────────────────────
|
||||
|
||||
export interface SessionWorktree {
|
||||
/** P1.5-b: the `worktrees.id` — stored on agent_sessions informationally. */
|
||||
worktreeId: string;
|
||||
worktreePath: string;
|
||||
baseCommit: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.6 / P1.5-b: create-or-reuse ONE worktree per BooCode session (shared across
|
||||
* all tabs/agents in the session), recorded in `worktrees` (was the superseded
|
||||
* `session_worktrees`). Persists — NOT torn down per turn (cleanup is Phase 3) —
|
||||
* and now survives session delete (`worktrees.session_id` is ON DELETE SET NULL).
|
||||
* Captures the project's current HEAD as `base_commit` for a stable diff baseline.
|
||||
*
|
||||
* Distinct path namespace (`session-<id>` branch, `/sess-<id>` dir) so it never
|
||||
* collides with the per-task worktrees that arena/new_task/MCP still use.
|
||||
*/
|
||||
export async function ensureSessionWorktree(
|
||||
sql: Sql,
|
||||
projectPath: string,
|
||||
sessionId: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<SessionWorktree> {
|
||||
const [existing] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
|
||||
SELECT id, path, base_commit FROM worktrees
|
||||
WHERE session_id = ${sessionId} AND status = 'active'
|
||||
LIMIT 1
|
||||
`;
|
||||
if (existing) {
|
||||
return { worktreeId: existing.id, worktreePath: existing.path, baseCommit: existing.base_commit };
|
||||
}
|
||||
|
||||
const worktreePath = `${WORKTREE_BASE}/sess-${sessionId}`;
|
||||
const branchName = `session-${sessionId}`;
|
||||
|
||||
await hostExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal });
|
||||
|
||||
// Capture the baseline commit BEFORE branching, so the diff is stable even if
|
||||
// project HEAD later advances.
|
||||
const headResult = await hostExec(
|
||||
`git -C ${shellEscape(projectPath)} rev-parse HEAD`,
|
||||
{ signal: opts?.signal, timeoutMs: 10_000 },
|
||||
);
|
||||
const baseCommit = headResult.exitCode === 0 ? headResult.stdout.trim() || null : null;
|
||||
|
||||
const result = await hostExec(
|
||||
`git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`,
|
||||
{ signal: opts?.signal, timeoutMs: 30_000 },
|
||||
);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`Failed to create session worktree: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
|
||||
// Insert-or-get: WHERE NOT EXISTS keeps the first writer's row if two turns race
|
||||
// the create (the partial unique on active path also backstops it).
|
||||
const [inserted] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
|
||||
INSERT INTO worktrees (session_id, path, branch, base_commit, status)
|
||||
SELECT ${sessionId}, ${worktreePath}, ${branchName}, ${baseCommit}, 'active'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM worktrees WHERE session_id = ${sessionId} AND status = 'active'
|
||||
)
|
||||
RETURNING id, path, base_commit
|
||||
`;
|
||||
if (inserted) {
|
||||
return { worktreeId: inserted.id, worktreePath: inserted.path, baseCommit: inserted.base_commit };
|
||||
}
|
||||
// Lost the race — another turn inserted first; read its row.
|
||||
const [row] = await sql<{ id: string; path: string; base_commit: string | null }[]>`
|
||||
SELECT id, path, base_commit FROM worktrees
|
||||
WHERE session_id = ${sessionId} AND status = 'active'
|
||||
LIMIT 1
|
||||
`;
|
||||
return {
|
||||
worktreeId: row!.id,
|
||||
worktreePath: row?.path ?? worktreePath,
|
||||
baseCommit: row?.base_commit ?? baseCommit,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Session-delete work-loss guard ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Risk report for a single worktree, returned by checkWorktreeWorkAtRisk.
|
||||
* `atRisk` is the gate the server reads before allowing a session delete.
|
||||
* A git error never silently passes — it forces `atRisk` true and surfaces
|
||||
* the message in `error` (fail-closed).
|
||||
*/
|
||||
export interface RiskReport {
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
dirty: boolean; // uncommitted working-tree changes (incl. untracked)
|
||||
unpushed: number; // commits ahead of upstream, or -1 if no upstream is set
|
||||
unmerged: number; // commits on this branch not in the project default branch
|
||||
atRisk: boolean; // dirty || unmerged > 0 || (upstream && unpushed > 0) || git error
|
||||
error?: string; // populated on a git failure; presence forces atRisk
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the project's default branch as a git-usable ref (e.g. "origin/main").
|
||||
*
|
||||
* `refs/remotes/origin/HEAD` lives in the repo's COMMON git dir and is shared
|
||||
* across every linked worktree, so reading it from the session worktree returns
|
||||
* the REMOTE's default branch — never this worktree's own `session-<id>` branch
|
||||
* (that would be `symbolic-ref HEAD`, a different ref). Falls back to probing
|
||||
* common defaults by verified existence when origin/HEAD isn't set (e.g. a repo
|
||||
* that never ran `git remote set-head`). Returns null if none resolve, in which
|
||||
* case the unmerged check is skipped (dirty + unpushed still protect the work).
|
||||
*/
|
||||
async function detectDefaultBranchRef(
|
||||
worktreePath: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<string | null> {
|
||||
const head = await hostExec(
|
||||
`git -C ${shellEscape(worktreePath)} symbolic-ref --short refs/remotes/origin/HEAD`,
|
||||
{ signal: opts?.signal, timeoutMs: 10_000 },
|
||||
);
|
||||
if (head.exitCode === 0) {
|
||||
const ref = head.stdout.trim(); // e.g. "origin/main"
|
||||
if (ref) {
|
||||
const verify = await hostExec(
|
||||
`git -C ${shellEscape(worktreePath)} rev-parse --verify --quiet ${shellEscape(ref + '^{commit}')}`,
|
||||
{ signal: opts?.signal, timeoutMs: 10_000 },
|
||||
);
|
||||
if (verify.exitCode === 0 && verify.stdout.trim()) return ref;
|
||||
}
|
||||
}
|
||||
// origin/HEAD unset or unresolvable — probe common defaults. Prefer the
|
||||
// remote-tracking ref (always resolvable in a fresh worktree) over the local
|
||||
// head, which may not exist if the default branch lives only in the main tree.
|
||||
for (const cand of ['origin/main', 'origin/master', 'main', 'master']) {
|
||||
const verify = await hostExec(
|
||||
`git -C ${shellEscape(worktreePath)} rev-parse --verify --quiet ${shellEscape(cand + '^{commit}')}`,
|
||||
{ signal: opts?.signal, timeoutMs: 10_000 },
|
||||
);
|
||||
if (verify.exitCode === 0 && verify.stdout.trim()) return cand;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect a worktree for work that would be lost if its session were deleted.
|
||||
* Three checks, all via the audited hostExec + shellEscape path (every
|
||||
* interpolated value — paths, refs — is single-quote-escaped; no bare
|
||||
* interpolation). Any unexpected git failure is treated as at-risk, never a
|
||||
* silent pass.
|
||||
*/
|
||||
export async function checkWorktreeWorkAtRisk(
|
||||
worktreePath: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<RiskReport> {
|
||||
// Branch name — also doubles as the "is this still a git worktree?" probe.
|
||||
const br = await hostExec(
|
||||
`git -C ${shellEscape(worktreePath)} rev-parse --abbrev-ref HEAD`,
|
||||
{ signal: opts?.signal, timeoutMs: 10_000 },
|
||||
);
|
||||
if (br.exitCode !== 0) {
|
||||
return {
|
||||
worktreePath,
|
||||
branch: '',
|
||||
dirty: false,
|
||||
unpushed: 0,
|
||||
unmerged: 0,
|
||||
atRisk: true,
|
||||
error: `git rev-parse failed: ${br.stderr.trim() || 'not a git worktree'}`,
|
||||
};
|
||||
}
|
||||
const branch = br.stdout.trim();
|
||||
|
||||
// (a) Uncommitted (dirty working tree, including untracked files).
|
||||
const st = await hostExec(
|
||||
`git -C ${shellEscape(worktreePath)} status --porcelain`,
|
||||
{ signal: opts?.signal, timeoutMs: 15_000 },
|
||||
);
|
||||
if (st.exitCode !== 0) {
|
||||
return {
|
||||
worktreePath,
|
||||
branch,
|
||||
dirty: false,
|
||||
unpushed: 0,
|
||||
unmerged: 0,
|
||||
atRisk: true,
|
||||
error: `git status failed: ${st.stderr.trim()}`,
|
||||
};
|
||||
}
|
||||
const dirty = st.stdout.trim().length > 0;
|
||||
|
||||
// (b) Unpushed commits. No upstream configured => work exists only locally;
|
||||
// treat as unpushed-by-definition (-1) rather than an error.
|
||||
const up = await hostExec(
|
||||
`git -C ${shellEscape(worktreePath)} rev-list --count ${shellEscape('@{u}..HEAD')}`,
|
||||
{ signal: opts?.signal, timeoutMs: 15_000 },
|
||||
);
|
||||
const unpushed = up.exitCode === 0 ? (parseInt(up.stdout.trim() || '0', 10) || 0) : -1;
|
||||
|
||||
// (c) Unmerged commits — on this branch but not in the project default branch.
|
||||
const defaultRef = await detectDefaultBranchRef(worktreePath, opts);
|
||||
let unmerged = 0;
|
||||
if (defaultRef) {
|
||||
const rl = await hostExec(
|
||||
`git -C ${shellEscape(worktreePath)} rev-list --count ${shellEscape(defaultRef + '..HEAD')}`,
|
||||
{ signal: opts?.signal, timeoutMs: 15_000 },
|
||||
);
|
||||
if (rl.exitCode === 0) unmerged = parseInt(rl.stdout.trim() || '0', 10) || 0;
|
||||
}
|
||||
|
||||
// unpushed only contributes when an upstream actually exists. Session branches
|
||||
// (session-<id>) never have one (unpushed === -1), and any real local-only work
|
||||
// there already surfaces as unmerged > 0 — so the no-upstream case adds no
|
||||
// protection, only friction (it flagged every pristine worktree-backed session).
|
||||
// The unpushed > 0 arm stays forward-compatible with P1.5 pushable branches.
|
||||
const hasUpstream = unpushed !== -1;
|
||||
const atRisk = dirty || unmerged > 0 || (hasUpstream && unpushed > 0);
|
||||
return { worktreePath, branch, dirty, unpushed, unmerged, atRisk };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stash a worktree's uncommitted changes (including untracked, via -u) so the
|
||||
* working tree is clean. Stash entries live in the repo's common git dir, so
|
||||
* they survive worktree-dir removal — this is the recoverable, safe-by-default
|
||||
* escape. Note it only clears the *dirty* risk; unpushed/unmerged commits
|
||||
* remain on the branch, so a re-attempted delete may still block on those.
|
||||
*/
|
||||
export async function stashWorktree(
|
||||
worktreePath: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<{ stashed: boolean; error?: string }> {
|
||||
const r = await hostExec(
|
||||
`git -C ${shellEscape(worktreePath)} stash push -u -m ${shellEscape('boocode: pre-delete stash')}`,
|
||||
{ signal: opts?.signal, timeoutMs: 30_000 },
|
||||
);
|
||||
if (r.exitCode !== 0) {
|
||||
return { stashed: false, error: r.stderr.trim() || r.stdout.trim() };
|
||||
}
|
||||
// "No local changes to save" => exit 0, nothing stashed — not an error.
|
||||
const stashed = !/no local changes to save/i.test(r.stdout);
|
||||
return { stashed };
|
||||
}
|
||||
|
||||
/** Minimal shell escape for paths (single-quote wrapping). */
|
||||
function shellEscape(s: string): string {
|
||||
// Replace single quotes with escaped version, wrap in single quotes
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import type { Broker } from '../services/broker.js';
|
||||
import type { Session } from '../types/api.js';
|
||||
import type { Session, WorktreeRiskReport } from '../types/api.js';
|
||||
import { getSetting } from './settings.js';
|
||||
|
||||
const CreateBody = z.object({
|
||||
@@ -28,18 +28,20 @@ const HtmlArtifactStateZ = z.object({
|
||||
title: z.string().max(500),
|
||||
});
|
||||
|
||||
const PaneKindZ = z.enum([
|
||||
'chat',
|
||||
'terminal',
|
||||
'coder',
|
||||
'agent', // legacy alias — normalized to coder on write
|
||||
'empty',
|
||||
'settings',
|
||||
'markdown_artifact',
|
||||
'html_artifact',
|
||||
]);
|
||||
|
||||
const WorkspacePaneZ = z.object({
|
||||
id: z.string().min(1).max(200),
|
||||
kind: z.enum([
|
||||
'chat',
|
||||
'terminal',
|
||||
'coder',
|
||||
'agent', // legacy alias — normalized to coder on write
|
||||
'empty',
|
||||
'settings',
|
||||
'markdown_artifact',
|
||||
'html_artifact',
|
||||
]),
|
||||
kind: PaneKindZ,
|
||||
chatId: z.string().min(1).max(200).optional(),
|
||||
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||
activeChatIdx: z.number().int(),
|
||||
@@ -47,8 +49,27 @@ const WorkspacePaneZ = z.object({
|
||||
html_artifact_state: HtmlArtifactStateZ.optional(),
|
||||
});
|
||||
|
||||
// v2.6.x: workspace_panes column widened from a bare WorkspacePane[] to a
|
||||
// WorkspaceState envelope (panes + stable session-scoped tab numbering +
|
||||
// reopen stack). closedPaneStack entries are lighter than full panes — just
|
||||
// the kind + chat ids needed to recreate a closed pane on reopen.
|
||||
const ClosedPaneEntryZ = z.object({
|
||||
kind: PaneKindZ,
|
||||
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||
activeChatIdx: z.number().int(),
|
||||
});
|
||||
|
||||
const WorkspaceStateZ = z.object({
|
||||
panes: z.array(WorkspacePaneZ).max(10),
|
||||
tabNumbers: z.record(z.string(), z.number().int()).default({}),
|
||||
nextTabNumber: z.number().int().default(1),
|
||||
closedPaneStack: z.array(ClosedPaneEntryZ).max(10).default([]),
|
||||
});
|
||||
|
||||
// Accept either the legacy bare array OR the envelope. The handler normalizes
|
||||
// to a full envelope before storing (see MIGRATION rule in the PATCH handler).
|
||||
const WorkspacePanesBody = z.object({
|
||||
workspace_panes: z.array(WorkspacePaneZ).max(10),
|
||||
workspace_panes: z.union([z.array(WorkspacePaneZ).max(10), WorkspaceStateZ]),
|
||||
});
|
||||
|
||||
const PatchBody = z.object({
|
||||
@@ -308,12 +329,20 @@ export function registerSessionRoutes(
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
const workspacePanes = parsed.data.workspace_panes.map((pane) =>
|
||||
// v2.6.x MIGRATION: the body is either a legacy bare WorkspacePane[] or
|
||||
// the WorkspaceState envelope. Normalize to a full envelope so the column
|
||||
// always stores the envelope shape going forward.
|
||||
const body = parsed.data.workspace_panes;
|
||||
const envelope = Array.isArray(body)
|
||||
? { panes: body, tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] }
|
||||
: body;
|
||||
// agent → coder normalization on the panes array (unchanged write rule).
|
||||
envelope.panes = envelope.panes.map((pane) =>
|
||||
pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane,
|
||||
);
|
||||
const rows = await sql<Session[]>`
|
||||
UPDATE sessions
|
||||
SET workspace_panes = ${sql.json(workspacePanes as never)},
|
||||
SET workspace_panes = ${sql.json(envelope as never)},
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${req.params.id}
|
||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||
@@ -426,10 +455,55 @@ export function registerSessionRoutes(
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
app.delete<{ Params: { id: string }; Querystring: { force?: string } }>(
|
||||
'/api/sessions/:id',
|
||||
async (req, reply) => {
|
||||
const id = req.params.id;
|
||||
const force = req.query.force === 'true' || req.query.force === '1';
|
||||
|
||||
// Session-delete work-loss guard. The check MUST run BEFORE the DELETE:
|
||||
// worktrees.session_id is ON DELETE SET NULL (P1.5-b), so once the session
|
||||
// is gone the worktree rows no longer point back to it — read them while
|
||||
// the link still exists.
|
||||
//
|
||||
// Optimization: read worktrees (P1.5-b — was session_worktrees) from our
|
||||
// own (shared) DB first. No row => chat-only session => nothing on disk =>
|
||||
// delete immediately, zero round-trip. Only worktree-backed sessions pay
|
||||
// the host git check.
|
||||
if (!force) {
|
||||
const worktrees = await sql<{ path: string }[]>`
|
||||
SELECT path FROM worktrees WHERE session_id = ${id}
|
||||
`;
|
||||
if (worktrees.length > 0) {
|
||||
// Worktree dirs live on the host; only BooCoder can run git on them.
|
||||
const origin = process.env.BOOCODER_URL ?? 'http://boocoder:3000';
|
||||
let reports: WorktreeRiskReport[];
|
||||
try {
|
||||
const res = await fetch(`${origin}/api/sessions/${id}/worktree-risk`);
|
||||
if (!res.ok) {
|
||||
// Fail-closed: can't verify => don't risk silent loss. Force escapes.
|
||||
reply.code(409);
|
||||
return {
|
||||
error: 'could not verify worktree safety (BooCoder check failed). Use force to delete anyway.',
|
||||
reports: [] as WorktreeRiskReport[],
|
||||
};
|
||||
}
|
||||
reports = ((await res.json()) as { reports?: WorktreeRiskReport[] }).reports ?? [];
|
||||
} catch {
|
||||
// Fail-closed: BooCoder unreachable. Force bypasses this path entirely.
|
||||
reply.code(409);
|
||||
return {
|
||||
error: 'BooCoder unreachable; cannot verify worktree safety. Use force to delete anyway.',
|
||||
reports: [] as WorktreeRiskReport[],
|
||||
};
|
||||
}
|
||||
if (reports.some((r) => r.atRisk)) {
|
||||
reply.code(409);
|
||||
return { error: 'This session has work at risk in its worktree.', reports };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleted = await sql<{ project_id: string }[]>`
|
||||
DELETE FROM sessions WHERE id = ${id} RETURNING project_id
|
||||
`;
|
||||
|
||||
@@ -37,9 +37,11 @@ export async function maybeAutoNameChat(
|
||||
if ((counts[0]?.n ?? 0) < 1) return;
|
||||
|
||||
const chatRows = await ctx.sql<
|
||||
{ id: string; name: string | null; session_id: string }[]
|
||||
{ id: string; name: string | null; session_id: string; model: string | null }[]
|
||||
>`
|
||||
SELECT id, name, session_id FROM chats WHERE id = ${chatId}
|
||||
SELECT c.id, c.name, c.session_id, s.model
|
||||
FROM chats c JOIN sessions s ON s.id = c.session_id
|
||||
WHERE c.id = ${chatId}
|
||||
`;
|
||||
const chat = chatRows[0];
|
||||
if (!chat) return;
|
||||
@@ -67,6 +69,7 @@ export async function maybeAutoNameChat(
|
||||
user: namingInput,
|
||||
maxTokens: 30,
|
||||
temperature: 0.3,
|
||||
fallbackModel: chat.model ?? undefined,
|
||||
});
|
||||
const name = cleanTitle(raw);
|
||||
if (!name) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Agent, Session, ToolCall } from '../../types/api.js';
|
||||
import * as modelContext from '../model-context.js';
|
||||
import { PathScopeError } from '../path_guard.js';
|
||||
import { TOOLS_BY_NAME } from '../tools.js';
|
||||
import type { ToolExecCtx } from '../tools.js';
|
||||
import { matchToolGlob } from '../agents.js';
|
||||
import { maybeFlagForCompaction } from './payload.js';
|
||||
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
|
||||
@@ -31,6 +32,7 @@ async function executeToolCall(
|
||||
projectRoot: string,
|
||||
toolCall: ToolCall,
|
||||
extraRoots: readonly string[],
|
||||
toolCtx?: ToolExecCtx,
|
||||
): Promise<{ output: unknown; truncated: boolean; error?: string }> {
|
||||
const tool = TOOLS_BY_NAME[toolCall.name];
|
||||
if (!tool) {
|
||||
@@ -65,7 +67,7 @@ async function executeToolCall(
|
||||
};
|
||||
}
|
||||
try {
|
||||
const output = await tool.execute(parsed.data, projectRoot, extraRoots);
|
||||
const output = await tool.execute(parsed.data, projectRoot, extraRoots, toolCtx);
|
||||
const truncated =
|
||||
typeof output === 'object' && output !== null && 'truncated' in output
|
||||
? Boolean((output as { truncated: unknown }).truncated)
|
||||
@@ -289,7 +291,10 @@ export async function executeToolPhase(
|
||||
});
|
||||
return;
|
||||
}
|
||||
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
|
||||
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths, {
|
||||
sql: ctx.sql,
|
||||
sessionId,
|
||||
});
|
||||
if (SYNTHESIS_TOOLS.has(tc.name)) {
|
||||
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });
|
||||
}
|
||||
|
||||
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 { resolve, basename, relative } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import { pathGuard, PathScopeError } from './path_guard.js';
|
||||
import { isSecretPath, SecretBlockedError, filterSecretEntries } from './secret_guard.js';
|
||||
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from './file_ops.js';
|
||||
@@ -30,6 +31,9 @@ import {
|
||||
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||
// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
|
||||
import { requestReadAccess } from './request_read_access.js';
|
||||
// v2.6.x: read-only tool that reads a tab's transcript by its session-scoped
|
||||
// tab number. Needs DB/session context (ToolExecCtx 4th arg).
|
||||
import { readTabByNumber } from './read_tab_by_number.js';
|
||||
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const DEFAULT_VIEW_LINES = 200;
|
||||
@@ -48,6 +52,16 @@ export interface ToolJsonSchema {
|
||||
};
|
||||
}
|
||||
|
||||
// v2.6.x: optional DB/session context threaded into a tool's execute(). Only
|
||||
// tools that need to read session-scoped DB state (e.g. read_tab_by_number)
|
||||
// use it; every other tool ignores the 4th arg. Kept optional so existing
|
||||
// 3-arg execute() implementations stay assignable (apps/coder consumes this
|
||||
// type from the compiled dist — the optional param keeps it backward-compatible).
|
||||
export interface ToolExecCtx {
|
||||
sql: Sql;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface ToolDef<TInput> {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -59,7 +73,15 @@ export interface ToolDef<TInput> {
|
||||
// view_truncated_output) forward it to pathGuard; other tools accept the
|
||||
// arg and ignore it. The execute signature stays compatible with
|
||||
// pre-v1.13.17 callsites because the parameter is optional.
|
||||
execute(input: TInput, projectRoot: string, extraRoots?: readonly string[]): Promise<unknown>;
|
||||
// v2.6.x: optional 4th param toolCtx carries DB/session context for tools
|
||||
// that read session-scoped state (read_tab_by_number). Optional so 3-arg
|
||||
// implementations remain assignable.
|
||||
execute(
|
||||
input: TInput,
|
||||
projectRoot: string,
|
||||
extraRoots?: readonly string[],
|
||||
toolCtx?: ToolExecCtx,
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
const ViewFileInput = z.object({
|
||||
@@ -694,6 +716,9 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
|
||||
// state change is appending to sessions.allowed_read_paths via the
|
||||
// grant endpoint, gated by user consent.
|
||||
requestReadAccess as ToolDef<unknown>,
|
||||
// v2.6.x: read a tab's transcript by its session-scoped tab number.
|
||||
// Read-only; uses the ToolExecCtx 4th arg for DB/session access.
|
||||
readTabByNumber as ToolDef<unknown>,
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// v1.8.2: forward-compatible read-only whitelist. An agent whose `tools` is
|
||||
@@ -734,6 +759,9 @@ export const READ_ONLY_TOOL_NAMES = [
|
||||
// state directly (the grant endpoint appends to sessions.allowed_read_paths
|
||||
// only with user consent). Belongs in the read-only budget tier.
|
||||
'request_read_access',
|
||||
// v2.6.x: reads a tab's transcript from session-scoped DB state; never
|
||||
// writes. Belongs in the read-only budget tier.
|
||||
'read_tab_by_number',
|
||||
] as const;
|
||||
|
||||
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||
|
||||
@@ -25,6 +25,20 @@ export interface AvailableProject {
|
||||
|
||||
export type SessionStatus = 'open' | 'archived';
|
||||
|
||||
// Session-delete work-loss guard. Returned (as `reports`) in the 409 body when
|
||||
// a delete is blocked because the session's worktree holds work at risk. The
|
||||
// shape is produced by BooCoder's checkWorktreeWorkAtRisk and passed through
|
||||
// verbatim; mirrored byte-for-byte in apps/web/src/api/types.ts for the dialog.
|
||||
export interface WorktreeRiskReport {
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
dirty: boolean;
|
||||
unpushed: number; // commits ahead of upstream, or -1 if no upstream
|
||||
unmerged: number; // commits not in the project default branch
|
||||
atRisk: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
project_id: string;
|
||||
|
||||
@@ -203,7 +203,12 @@ export const SessionDeletedFrame = z.object({
|
||||
export const SessionWorkspaceUpdatedFrame = z.object({
|
||||
type: z.literal('session_workspace_updated'),
|
||||
session_id: Uuid,
|
||||
workspace_panes: z.array(OpaqueObject),
|
||||
// v2.6.x: widened from z.array — the payload is now either the legacy bare
|
||||
// WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
|
||||
// nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
|
||||
// every envelope frame at validation. MUST be mirrored in the server's
|
||||
// byte-identical copy (parity test).
|
||||
workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
|
||||
});
|
||||
|
||||
export const ChatCreatedFrame = z.object({
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
CoderTaskDetail,
|
||||
PermissionPrompt,
|
||||
AgentCommand,
|
||||
WorkspaceState,
|
||||
} from './types';
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -151,8 +152,17 @@ export const api = {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
remove: (id: string) =>
|
||||
request<void>(`/api/sessions/${id}`, { method: 'DELETE' }),
|
||||
// force=true bypasses the server-side worktree work-loss guard. A blocked
|
||||
// delete throws ApiError(409) whose body carries { error, reports }.
|
||||
remove: (id: string, force = false) =>
|
||||
request<void>(`/api/sessions/${id}${force ? '?force=true' : ''}`, { method: 'DELETE' }),
|
||||
// Stash the session's worktree (uncommitted changes) on the host, via the
|
||||
// BooCoder proxy. Recoverable escape from the work-at-risk dialog.
|
||||
worktreeStash: (id: string) =>
|
||||
request<{ results: { worktreePath: string; stashed: boolean; error?: string }[] }>(
|
||||
`/api/coder/sessions/${id}/worktree-stash`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
archive: (id: string) =>
|
||||
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
|
||||
unarchive: (id: string) =>
|
||||
@@ -166,10 +176,10 @@ export const api = {
|
||||
),
|
||||
openChatsCount: (id: string) =>
|
||||
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
|
||||
updateWorkspacePanes: (id: string, panes: Session['workspace_panes']) =>
|
||||
updateWorkspacePanes: (id: string, state: WorkspaceState) =>
|
||||
request<Session>(`/api/sessions/${id}/workspace`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ workspace_panes: panes }),
|
||||
body: JSON.stringify({ workspace_panes: state }),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -345,6 +355,10 @@ export const api = {
|
||||
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
|
||||
getTask: (taskId: string) =>
|
||||
request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`),
|
||||
// Cancel a pending/running coder task (cancels permission wait + inference;
|
||||
// server sets state='cancelled'). Used by CoderPane's stop button.
|
||||
cancelTask: (taskId: string) =>
|
||||
request<{ cancelled: boolean }>(`/api/coder/tasks/${taskId}/cancel`, { method: 'POST' }),
|
||||
listMessages: (sessionId: string, chatId?: string) =>
|
||||
request<CoderMessageWire[]>(
|
||||
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
||||
|
||||
@@ -34,6 +34,19 @@ export interface AvailableProject {
|
||||
|
||||
export type SessionStatus = 'open' | 'archived';
|
||||
|
||||
// Session-delete work-loss guard. Mirror of WorktreeRiskReport in
|
||||
// apps/server/src/types/api.ts — edit both copies together. Arrives as the
|
||||
// `reports` field of the 409 body when a delete is blocked.
|
||||
export interface WorktreeRiskReport {
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
dirty: boolean;
|
||||
unpushed: number; // commits ahead of upstream, or -1 if no upstream
|
||||
unmerged: number; // commits not in the project default branch
|
||||
atRisk: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
project_id: string;
|
||||
@@ -47,7 +60,10 @@ export interface Session {
|
||||
// v1.9: null = inherit from project.default_web_search_enabled.
|
||||
web_search_enabled: boolean | null;
|
||||
// v1.12.1: server-authoritative pane layout, replaces localStorage.
|
||||
workspace_panes: WorkspacePane[];
|
||||
// A value may be the legacy bare WorkspacePane[] (older rows) OR the new
|
||||
// WorkspaceState envelope (panes + tab numbering + reopen stack). Normalize
|
||||
// on read via useWorkspacePanes' toWorkspaceState.
|
||||
workspace_panes: WorkspacePane[] | WorkspaceState;
|
||||
// v1.13.17: paths the agent has been granted read access to via the
|
||||
// request_read_access tool. Empty by default. Settings UI surfaces the
|
||||
// list with per-row revoke; the grant flow itself appends through the
|
||||
@@ -498,6 +514,30 @@ export interface WorkspacePane {
|
||||
html_artifact_state?: HtmlArtifactState;
|
||||
}
|
||||
|
||||
// Reopen LIFO stack entry. Shape unchanged from the prior module-level stack;
|
||||
// now persisted inside the WorkspaceState envelope so the reopen-pane stack
|
||||
// survives a reload / cross-device sync.
|
||||
export interface ClosedPaneEntry {
|
||||
kind: WorkspacePane['kind'];
|
||||
chatIds: string[];
|
||||
activeChatIdx: number;
|
||||
}
|
||||
|
||||
// Envelope persisted to sessions.workspace_panes. Supersedes the bare
|
||||
// WorkspacePane[] shape (still accepted on read for legacy rows — see the
|
||||
// migration in useWorkspacePanes.toWorkspaceState). The server accepts either
|
||||
// shape; the frontend always emits this envelope going forward.
|
||||
export interface WorkspaceState {
|
||||
panes: WorkspacePane[];
|
||||
// Stable, session-scoped tab number per chat id. Numbers only ever increase
|
||||
// and are never reused (retired entries are pruned on tab close).
|
||||
tabNumbers: { [chatId: string]: number };
|
||||
// Next number to hand out; starts at 1; ONLY increments.
|
||||
nextTabNumber: number;
|
||||
// Reopen LIFO stack, max 10, most-recent last.
|
||||
closedPaneStack: ClosedPaneEntry[];
|
||||
}
|
||||
|
||||
export type WsFrame =
|
||||
| { type: 'snapshot'; messages: Message[] }
|
||||
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole }
|
||||
|
||||
@@ -203,7 +203,12 @@ export const SessionDeletedFrame = z.object({
|
||||
export const SessionWorkspaceUpdatedFrame = z.object({
|
||||
type: z.literal('session_workspace_updated'),
|
||||
session_id: Uuid,
|
||||
workspace_panes: z.array(OpaqueObject),
|
||||
// v2.6.x: widened from z.array — the payload is now either the legacy bare
|
||||
// WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
|
||||
// nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
|
||||
// every envelope frame at validation. MUST be mirrored in the server's
|
||||
// byte-identical copy (parity test).
|
||||
workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
|
||||
});
|
||||
|
||||
export const ChatCreatedFrame = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEvent } from 'react';
|
||||
import { Check, Plus, Send } from 'lucide-react';
|
||||
import { Check, ListPlus, Plus, Send, Square } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -51,6 +51,11 @@ interface Props {
|
||||
webSearchEnabled?: boolean | null;
|
||||
onSend: (content: string) => void | Promise<void>;
|
||||
onForceSend?: (content: string) => void | Promise<void>;
|
||||
// When the assistant/agent is generating, the send button morphs: empty draft
|
||||
// → Stop (calls onStop); non-empty draft → Queue (submits, which the caller
|
||||
// queues while busy). Omitting onStop falls back to a (disabled) Send button.
|
||||
generating?: boolean;
|
||||
onStop?: () => void | Promise<void>;
|
||||
// Batch 9.6: slash-command dispatch. When the input parses to a known skill,
|
||||
// ChatInput calls this with the skill name + the post-name args (possibly
|
||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||
@@ -78,7 +83,7 @@ interface Props {
|
||||
modelContextLimit?: number | null;
|
||||
}
|
||||
|
||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const [value, setValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -651,14 +656,38 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
rows={3}
|
||||
className="resize-none min-h-[68px] max-h-[240px]"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
disabled={disabled || busy || (!value.trim() && attachments.length === 0)}
|
||||
size="icon-lg"
|
||||
aria-label="Send"
|
||||
>
|
||||
<Send />
|
||||
</Button>
|
||||
{(() => {
|
||||
const hasContent = value.trim().length > 0 || attachments.length > 0;
|
||||
// While generating with an empty draft, the button stops generation.
|
||||
if (generating && onStop && !hasContent) {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => void onStop()}
|
||||
size="icon-lg"
|
||||
variant="outline"
|
||||
aria-label="Stop generating"
|
||||
title="Stop generating"
|
||||
>
|
||||
<Square className="fill-current size-3.5" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
// With a draft, submit. While generating the caller queues it, so the
|
||||
// button reads as Queue; otherwise it's a normal Send.
|
||||
const queueing = !!generating && hasContent;
|
||||
return (
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
disabled={disabled || busy || !hasContent}
|
||||
size="icon-lg"
|
||||
variant={queueing ? 'secondary' : 'default'}
|
||||
aria-label={queueing ? 'Queue message' : 'Send'}
|
||||
title={queueing ? 'Queue message' : 'Send'}
|
||||
>
|
||||
{queueing ? <ListPlus /> : <Send />}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<AttachmentPreviewModal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Code, History, MessageSquare, Plus, Terminal, X } from 'lucide-react';
|
||||
import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Terminal, X } from 'lucide-react';
|
||||
import type { Chat, WorkspacePane } from '@/api/types';
|
||||
import { StatusDot } from '@/components/StatusDot';
|
||||
import {
|
||||
@@ -16,17 +16,23 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
pane: WorkspacePane;
|
||||
tabs: Chat[];
|
||||
// v2.6.x (Batch 3a): stable session-scoped tab number per chat id. Keyed by
|
||||
// chat.id, NEVER by tab position.
|
||||
tabNumbers: Record<string, number>;
|
||||
onSwitchTab: (tabIdx: number) => void;
|
||||
onRemoveTab: (chatId: string) => void;
|
||||
onCloseOthers: (chatId: string) => void;
|
||||
onCloseToRight: (chatId: string) => void;
|
||||
onCloseAll: () => void;
|
||||
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||
onNewTab: () => void;
|
||||
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||
onReopenPane?: () => void;
|
||||
onShowHistory: () => void;
|
||||
onRename: (chatId: string, name: string) => Promise<void>;
|
||||
onRemovePane?: () => void;
|
||||
@@ -35,12 +41,15 @@ interface Props {
|
||||
export function ChatTabBar({
|
||||
pane,
|
||||
tabs,
|
||||
tabNumbers,
|
||||
onSwitchTab,
|
||||
onRemoveTab,
|
||||
onCloseOthers,
|
||||
onCloseToRight,
|
||||
onCloseAll,
|
||||
onAddPane,
|
||||
onNewTab,
|
||||
onSplitPane,
|
||||
onReopenPane,
|
||||
onShowHistory,
|
||||
onRename,
|
||||
onRemovePane,
|
||||
@@ -79,6 +88,9 @@ export function ChatTabBar({
|
||||
const isLast = tabIdx === tabs.length - 1;
|
||||
const onlyTab = tabs.length === 1;
|
||||
const label = chat.name ?? 'New chat';
|
||||
// v2.6.x: stable tab number keyed by chat.id (NOT tab position).
|
||||
// Omit gracefully when not yet assigned.
|
||||
const tabNumber = tabNumbers[chat.id];
|
||||
return (
|
||||
<ContextMenu key={chat.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
@@ -113,8 +125,11 @@ export function ChatTabBar({
|
||||
className="bg-transparent border-b border-border text-xs outline-none w-28"
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate max-w-[140px]" title={label}>
|
||||
{label}
|
||||
<span
|
||||
className="truncate max-w-[140px]"
|
||||
title={tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
|
||||
>
|
||||
{tabNumber !== undefined ? `${tabNumber} · ${label}` : label}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
@@ -131,9 +146,16 @@ export function ChatTabBar({
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => onAddPane('chat')}>
|
||||
<ContextMenuItem onSelect={onNewTab}>
|
||||
New chat
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id })
|
||||
}
|
||||
>
|
||||
Open in new pane
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onSelect={() => startRename(chat.id, chat.name)}>
|
||||
Rename
|
||||
@@ -175,24 +197,60 @@ export function ChatTabBar({
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="New pane"
|
||||
title="New pane"
|
||||
aria-label="New chat, terminal, or coder"
|
||||
title="New chat / terminal / coder"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit">
|
||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
||||
{/* 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={() => onAddPane('terminal')}>
|
||||
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
|
||||
<Terminal size={14} /> New BooTerm
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
|
||||
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
|
||||
<Code size={14} /> New BooCode
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Split pane"
|
||||
title="Split pane"
|
||||
>
|
||||
<Columns2 size={12} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-fit">
|
||||
<DropdownMenuItem onSelect={() => onSplitPane('chat')}>
|
||||
<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>
|
||||
{onReopenPane && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReopenPane}
|
||||
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Reopen closed pane"
|
||||
title="Reopen closed pane"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowHistory}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen, Brain } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, Brain } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Chat, ErrorReason, Message } from '@/api/types';
|
||||
import { api, ApiError } from '@/api/client';
|
||||
import { api } from '@/api/client';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
|
||||
import { CapHitSentinel } from './CapHitSentinel';
|
||||
@@ -105,18 +105,6 @@ const ERROR_REASON_LABELS: Record<ErrorReason, string> = {
|
||||
// moved to apps/web/src/components/MarkdownRenderer.tsx so the new artifact
|
||||
// panes can render assistant content with the same Shiki + remark-gfm setup.
|
||||
|
||||
// Pane-header title derivation for a markdown artifact. Order matches the
|
||||
// server slug logic in services/artifacts.ts: first `# ` heading → first 6
|
||||
// words of the body → 'Markdown artifact'. Truncated to keep the pane header
|
||||
// readable.
|
||||
function deriveMarkdownTitle(content: string): string {
|
||||
const headingMatch = content.match(/^\s*#\s+(.+?)\s*$/m);
|
||||
if (headingMatch && headingMatch[1]) return headingMatch[1].slice(0, 80);
|
||||
const words = content.trim().split(/\s+/).slice(0, 6).join(' ');
|
||||
if (words) return words.slice(0, 80);
|
||||
return 'Markdown artifact';
|
||||
}
|
||||
|
||||
export interface MessageActions {
|
||||
onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
|
||||
onResend?: (chatId: string, content: string) => Promise<void>;
|
||||
@@ -129,8 +117,8 @@ interface Props {
|
||||
sessionChats?: Chat[];
|
||||
capHitInfo?: { position: number; isLatest: boolean };
|
||||
actions?: MessageActions;
|
||||
/** Hide actions that don't apply (fork, delete, open-in-pane). */
|
||||
hideActions?: ('fork' | 'delete' | 'openInPane')[];
|
||||
/** Hide actions that don't apply (fork, delete). */
|
||||
hideActions?: ('fork' | 'delete')[];
|
||||
}
|
||||
|
||||
function StatsLine({ message }: { message: Message }) {
|
||||
@@ -226,7 +214,7 @@ function ActionRow({
|
||||
} else {
|
||||
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
|
||||
sessionEvents.emit({ type: 'refetch_messages' });
|
||||
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
|
||||
sessionEvents.emit({ type: 'open_chat_in_new_pane', chat_id: chat.id });
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'fork failed');
|
||||
@@ -258,54 +246,6 @@ function ActionRow({
|
||||
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
|
||||
const canFork = message.status === 'complete';
|
||||
const canDelete = message.status !== 'streaming';
|
||||
const [openingPane, setOpeningPane] = useState(false);
|
||||
|
||||
// v1.14.x-html-artifact-panes: probe for an html_artifact part. If present,
|
||||
// open the HTML pane variant; otherwise fall back to the markdown variant.
|
||||
// Title derivation for markdown: first `# ` heading → first 6 words of the
|
||||
// body → 'Markdown artifact' (mirrors the slug logic in
|
||||
// services/artifacts.ts).
|
||||
async function openInPane() {
|
||||
if (openingPane || message.status === 'streaming') return;
|
||||
setOpeningPane(true);
|
||||
try {
|
||||
try {
|
||||
const payload = await api.messages.getHtmlArtifact(
|
||||
message.chat_id,
|
||||
message.id,
|
||||
);
|
||||
sessionEvents.emit({
|
||||
type: 'open_html_artifact_pane',
|
||||
state: {
|
||||
chat_id: message.chat_id,
|
||||
message_id: message.id,
|
||||
title: payload.title,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
// 404 (no html_artifact part) is the expected fall-through path —
|
||||
// markdown variant opens below. Any other error (network, 500) is
|
||||
// a real failure; toast and bail rather than masquerading as markdown.
|
||||
const status = err instanceof ApiError ? err.status : null;
|
||||
if (status !== 404) {
|
||||
toast.error(err instanceof Error ? err.message : 'open in pane failed');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const title = deriveMarkdownTitle(message.content);
|
||||
sessionEvents.emit({
|
||||
type: 'open_markdown_artifact_pane',
|
||||
state: {
|
||||
chat_id: message.chat_id,
|
||||
message_id: message.id,
|
||||
title,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setOpeningPane(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -330,18 +270,6 @@ function ActionRow({
|
||||
<RefreshCw className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
{isAssistant && !hiddenSet.has('openInPane') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openInPane()}
|
||||
disabled={openingPane || message.status === 'streaming'}
|
||||
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Open in pane"
|
||||
title="Open in pane"
|
||||
>
|
||||
<PanelRightOpen className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
{isAssistant && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -19,12 +19,12 @@ import {
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { AddProjectModal } from './AddProjectModal';
|
||||
import { api } from '@/api/client';
|
||||
import { api, ApiError } from '@/api/client';
|
||||
import { useSidebar } from '@/hooks/useSidebar';
|
||||
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
|
||||
import type { SidebarProject } from '@/api/types';
|
||||
import type { SidebarProject, WorktreeRiskReport } from '@/api/types';
|
||||
import { giteaUrlFor } from '@/lib/projectUrls';
|
||||
import { isCoderSessionName } from '@/lib/coder-session';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -110,6 +110,16 @@ export function ProjectSidebar() {
|
||||
const [renamingProject, setRenamingProject] = useState<string | null>(null);
|
||||
const [renameProjectValue, setRenameProjectValue] = useState('');
|
||||
const [archiveProjectConfirm, setArchiveProjectConfirm] = useState<{ id: string; name: string } | null>(null);
|
||||
// Work-at-risk dialog: shown when a delete is blocked (409) because the
|
||||
// session's worktree holds uncommitted/unpushed/unmerged work.
|
||||
const [riskState, setRiskState] = useState<{
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
message: string;
|
||||
reports: WorktreeRiskReport[];
|
||||
} | null>(null);
|
||||
const [riskBusy, setRiskBusy] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const lastToastedError = useRef<string | null>(null);
|
||||
@@ -174,16 +184,81 @@ export function ProjectSidebar() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSession(sessionId: string, projectId: string) {
|
||||
async function handleDeleteSession(
|
||||
sessionId: string,
|
||||
projectId: string,
|
||||
name: string,
|
||||
force = false,
|
||||
) {
|
||||
try {
|
||||
await api.sessions.remove(sessionId);
|
||||
await api.sessions.remove(sessionId, force);
|
||||
// Server publishes session_deleted via WS; useUserEvents delivers it.
|
||||
setRiskState(null);
|
||||
if (activeSession === sessionId) navigate(`/project/${projectId}`);
|
||||
} catch (err) {
|
||||
// 409 => the server's work-loss guard blocked the delete. Open the
|
||||
// work-at-risk dialog with the per-worktree reports instead of toasting.
|
||||
if (
|
||||
err instanceof ApiError &&
|
||||
err.status === 409 &&
|
||||
err.body && typeof err.body === 'object' && 'reports' in err.body
|
||||
) {
|
||||
const body = err.body as { error?: string; reports?: WorktreeRiskReport[] };
|
||||
setRiskState({
|
||||
sessionId,
|
||||
projectId,
|
||||
name,
|
||||
message: body.error ?? 'This session has work at risk.',
|
||||
reports: body.reports ?? [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.error(err instanceof Error ? err.message : 'failed to delete session');
|
||||
}
|
||||
}
|
||||
|
||||
// Stash the worktree's uncommitted changes (recoverable), then re-attempt the
|
||||
// delete. If unpushed/unmerged commits remain, the retry 409s again and the
|
||||
// dialog re-renders with the narrowed risk.
|
||||
async function handleStashAndRetry() {
|
||||
if (!riskState || riskBusy) return;
|
||||
setRiskBusy(true);
|
||||
try {
|
||||
const { results } = await api.sessions.worktreeStash(riskState.sessionId);
|
||||
const failed = results.find((r) => r.error);
|
||||
if (failed) {
|
||||
toast.error(`stash failed: ${failed.error}`);
|
||||
return;
|
||||
}
|
||||
await handleDeleteSession(riskState.sessionId, riskState.projectId, riskState.name, false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'stash failed');
|
||||
} finally {
|
||||
setRiskBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit, destructive override — deletes despite work at risk.
|
||||
async function handleForceDelete() {
|
||||
if (!riskState || riskBusy) return;
|
||||
setRiskBusy(true);
|
||||
try {
|
||||
await handleDeleteSession(riskState.sessionId, riskState.projectId, riskState.name, true);
|
||||
} finally {
|
||||
setRiskBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Route the user to commit it themselves — never auto-commit. Opens the
|
||||
// session workspace where they can use a terminal or agent pane.
|
||||
function handleGoCommit() {
|
||||
if (!riskState) return;
|
||||
const sessionId = riskState.sessionId;
|
||||
setRiskState(null);
|
||||
navigate(`/session/${sessionId}`);
|
||||
toast.info('Open a terminal or agent in this session, commit and push your work, then delete again.');
|
||||
}
|
||||
|
||||
async function handleRenameSession(sessionId: string) {
|
||||
const trimmed = renameValue.trim();
|
||||
setRenamingSession(null);
|
||||
@@ -216,6 +291,20 @@ export function ProjectSidebar() {
|
||||
)
|
||||
: 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen';
|
||||
|
||||
// Work-at-risk dialog framing. The server returns 409 in two distinct
|
||||
// situations: (1) work genuinely at risk (reports has ≥1 atRisk entry), or
|
||||
// (2) it couldn't verify (BooCoder down/errored → reports is empty). These
|
||||
// are different user stories — "your work is in danger" vs "the checker is
|
||||
// offline" — so the dialog must not show one generic message for both.
|
||||
const atRiskReports = riskState?.reports.filter((r) => r.atRisk) ?? [];
|
||||
const verifyFailed = riskState !== null && atRiskReports.length === 0;
|
||||
const anyDirty = atRiskReports.some((r) => r.dirty);
|
||||
// Commit-based risk (unpushed/unmerged) that stash can NOT clear. When this is
|
||||
// all that remains (e.g. after a stash cleared the dirty changes), the dialog
|
||||
// explains why it re-blocked and hides the Stash button so it doesn't look
|
||||
// like stash "didn't work".
|
||||
const anyCommits = atRiskReports.some((r) => r.unpushed !== 0 || r.unmerged > 0);
|
||||
|
||||
return (
|
||||
<aside className={asideCls}>
|
||||
<div className="px-4 py-3 border-b flex items-center justify-between">
|
||||
@@ -499,7 +588,7 @@ export function ProjectSidebar() {
|
||||
const projectId = projects.find((p) =>
|
||||
p.recent_sessions.some((s) => s.id === deleteConfirm.id)
|
||||
)?.id;
|
||||
if (projectId) void handleDeleteSession(deleteConfirm.id, projectId);
|
||||
if (projectId) void handleDeleteSession(deleteConfirm.id, projectId, deleteConfirm.name);
|
||||
}
|
||||
setDeleteConfirm(null);
|
||||
}}
|
||||
@@ -509,6 +598,77 @@ export function ProjectSidebar() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={riskState !== null} onOpenChange={(open) => { if (!open && !riskBusy) setRiskState(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{verifyFailed ? 'Could not verify worktree safety' : 'This session has work at risk'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{verifyFailed ? (
|
||||
<>
|
||||
{riskState?.message ?? 'The worktree safety check is unavailable.'} Your work may be
|
||||
fine, but it couldn't be checked — only force-delete if you're sure.
|
||||
</>
|
||||
) : anyDirty && anyCommits ? (
|
||||
<>
|
||||
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan uncommitted
|
||||
changes <em>and</em> commits that aren't pushed or merged. Stash clears the
|
||||
changes (recoverable), but the commits will still block — push them or force-delete.
|
||||
</>
|
||||
) : anyDirty ? (
|
||||
<>
|
||||
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan uncommitted
|
||||
changes in its worktree. Stash them (recoverable), commit them, or force-delete.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan commits that
|
||||
aren't pushed or merged. Stashing won't recover these — push them, or
|
||||
force-delete.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!verifyFailed && (
|
||||
<div className="flex flex-col gap-2 py-1 text-sm">
|
||||
{atRiskReports.map((r) => (
|
||||
<div key={r.worktreePath} className="rounded border border-border/60 px-3 py-2">
|
||||
<div className="font-mono text-xs text-muted-foreground truncate" title={r.worktreePath}>
|
||||
{r.branch || r.worktreePath}
|
||||
</div>
|
||||
<ul className="mt-1 list-disc pl-5 text-foreground/90">
|
||||
{r.error && <li className="text-destructive">git error: {r.error}</li>}
|
||||
{r.dirty && <li>uncommitted changes</li>}
|
||||
{r.unpushed === -1 && <li>local-only branch (no upstream)</li>}
|
||||
{r.unpushed > 0 && <li>{r.unpushed} unpushed commit{r.unpushed === 1 ? '' : 's'}</li>}
|
||||
{r.unmerged > 0 && <li>{r.unmerged} unmerged commit{r.unmerged === 1 ? '' : 's'}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 justify-end pt-2">
|
||||
<Button variant="outline" disabled={riskBusy} onClick={() => setRiskState(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{!verifyFailed && (
|
||||
<Button variant="outline" disabled={riskBusy} onClick={handleGoCommit}>
|
||||
Commit…
|
||||
</Button>
|
||||
)}
|
||||
{!verifyFailed && anyDirty && (
|
||||
<Button variant="outline" disabled={riskBusy} onClick={() => void handleStashAndRetry()}>
|
||||
Stash & delete
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="destructive" disabled={riskBusy} onClick={() => void handleForceDelete()}>
|
||||
Force delete
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Archive, MessageSquare, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { ChatInput } from '@/components/ChatInput';
|
||||
import { api } from '@/api/client';
|
||||
import type { Chat } from '@/api/types';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
@@ -13,6 +16,30 @@ interface Props {
|
||||
// the skill — same transition the text send uses. See useSessionChats.
|
||||
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
|
||||
createChat: () => Promise<{ id: string }>;
|
||||
// Session history: the session's open chats (live), and callbacks to open one
|
||||
// in THIS pane / restore an archived one. Archived chats are fetched here
|
||||
// (the default open-only list excludes them).
|
||||
chats: Chat[];
|
||||
onOpenChat: (chatId: string) => void;
|
||||
onUnarchiveChat: (chatId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '';
|
||||
const s = Math.max(0, Math.round((Date.now() - then) / 1000));
|
||||
if (s < 60) return 'just now';
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.round(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
function byRecent(a: Chat, b: Chat): number {
|
||||
return (b.updated_at ?? '').localeCompare(a.updated_at ?? '');
|
||||
}
|
||||
|
||||
export function SessionLandingPage({
|
||||
@@ -23,8 +50,24 @@ export function SessionLandingPage({
|
||||
onSend,
|
||||
onSkillInvoke,
|
||||
createChat,
|
||||
chats,
|
||||
onOpenChat,
|
||||
onUnarchiveChat,
|
||||
}: Props) {
|
||||
const [chatId, setChatId] = useState<string | null>(null);
|
||||
const [archived, setArchived] = useState<Chat[]>([]);
|
||||
|
||||
// Archived chats aren't in the default (open-only) list, so fetch them. One
|
||||
// shot on session change — the history view is transient (pick a chat and
|
||||
// it's gone), so slight staleness is fine; reopening the pane refetches.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.chats
|
||||
.listForSession(sessionId, { status: 'archived' })
|
||||
.then((list) => { if (!cancelled) setArchived(list); })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [sessionId]);
|
||||
|
||||
const ensureChat = useCallback(async (): Promise<string> => {
|
||||
if (chatId) return chatId;
|
||||
@@ -57,12 +100,87 @@ export function SessionLandingPage({
|
||||
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
|
||||
}, [onSkillInvoke]);
|
||||
|
||||
const restoreAndOpen = useCallback(async (id: string) => {
|
||||
try {
|
||||
await onUnarchiveChat(id);
|
||||
onOpenChat(id);
|
||||
} catch {
|
||||
// onUnarchiveChat surfaces its own toast.
|
||||
}
|
||||
}, [onUnarchiveChat, onOpenChat]);
|
||||
|
||||
const openChats = [...chats.filter((c) => c.status === 'open')].sort(byRecent);
|
||||
const openIds = new Set(openChats.map((c) => c.id));
|
||||
const archivedChats = archived.filter((c) => !openIds.has(c.id)).sort(byRecent);
|
||||
const isEmpty = openChats.length === 0 && archivedChats.length === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex-1 flex items-center justify-center px-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Send a message to start.
|
||||
</p>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="max-w-[760px] mx-auto w-full px-4 py-4">
|
||||
{isEmpty ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No conversations yet. Send a message to start.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{openChats.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground px-1 mb-1.5">
|
||||
Conversations
|
||||
</h3>
|
||||
<div className="space-y-0.5 mb-4">
|
||||
{openChats.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => onOpenChat(c.id)}
|
||||
className="w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm max-md:min-h-[44px]"
|
||||
>
|
||||
<MessageSquare size={14} className="shrink-0 text-muted-foreground" />
|
||||
<span className="truncate shrink-0 max-w-[45%]">{c.name ?? 'New chat'}</span>
|
||||
{c.last_message_preview && (
|
||||
<span className="truncate flex-1 text-xs text-muted-foreground hidden sm:block">
|
||||
{c.last_message_preview}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 ml-auto text-xs text-muted-foreground">
|
||||
{formatRelative(c.updated_at)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{archivedChats.length > 0 && (
|
||||
<>
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground px-1 mb-1.5">
|
||||
Archived
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{archivedChats.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => void restoreAndOpen(c.id)}
|
||||
title="Restore and open"
|
||||
className="group/arch w-full flex items-center gap-2 text-left px-2 py-1.5 rounded hover:bg-muted text-sm text-muted-foreground max-md:min-h-[44px]"
|
||||
>
|
||||
<Archive size={14} className="shrink-0" />
|
||||
<span className="truncate flex-1">{c.name ?? 'New chat'}</span>
|
||||
<span className="shrink-0 text-xs">{formatRelative(c.updated_at)}</span>
|
||||
<RotateCcw
|
||||
size={13}
|
||||
className="shrink-0 opacity-0 group-hover/arch:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChatInput
|
||||
disabled={false}
|
||||
|
||||
@@ -54,6 +54,7 @@ export function Workspace({
|
||||
}: Props) {
|
||||
const {
|
||||
panes,
|
||||
tabNumbers,
|
||||
activePaneIdx,
|
||||
setActivePaneIdx,
|
||||
openChatInPane,
|
||||
@@ -65,6 +66,8 @@ export function Workspace({
|
||||
showLandingPage,
|
||||
addSplitPane,
|
||||
removePane,
|
||||
reopenPane,
|
||||
hasClosedPanes,
|
||||
isPaneChatPending,
|
||||
handlePaneDragStart,
|
||||
handlePaneDragOver,
|
||||
@@ -202,15 +205,15 @@ export function Workspace({
|
||||
<ChatTabBar
|
||||
pane={pane}
|
||||
tabs={chatsForPane(pane)}
|
||||
tabNumbers={tabNumbers}
|
||||
onSwitchTab={(tabIdx) => switchTab(idx, tabIdx)}
|
||||
onRemoveTab={(chatId) => removeTab(idx, chatId)}
|
||||
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
||||
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
|
||||
onCloseAll={() => closeAllTabs(idx)}
|
||||
onAddPane={(kind) => {
|
||||
if (kind === 'chat') void createChat(idx);
|
||||
else onAddPane(kind);
|
||||
}}
|
||||
onNewTab={() => void createChat(idx)}
|
||||
onSplitPane={(kind) => onAddPane(kind)}
|
||||
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
||||
onShowHistory={() => showLandingPage(idx)}
|
||||
onRename={renameChat}
|
||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||
@@ -389,6 +392,9 @@ export function Workspace({
|
||||
createChat={() => api.chats.create(sessionId)}
|
||||
onSend={(content) => void handleLandingSend(idx, content)}
|
||||
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
|
||||
chats={chats}
|
||||
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
|
||||
onUnarchiveChat={unarchiveChat}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Pencil, Send, Square, X } from 'lucide-react';
|
||||
import { Pencil, Send, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import { useSessionStream } from '@/hooks/useSessionStream';
|
||||
@@ -248,22 +248,6 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stop button when streaming */}
|
||||
{streaming && (
|
||||
<div className="border-t py-1">
|
||||
<div className="max-w-[1000px] mx-auto w-full flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleStop()}
|
||||
className="flex items-center gap-1.5 text-xs px-3 py-1 rounded-full border hover:bg-muted text-muted-foreground hover:text-foreground max-md:min-h-[44px] max-md:px-5"
|
||||
>
|
||||
<Square size={10} className="fill-current" />
|
||||
Stop generating
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stale && streamingId && (
|
||||
<StaleStreamBanner
|
||||
onRetry={() => void handleRetryStale()}
|
||||
@@ -280,6 +264,8 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
||||
webSearchEnabled={webSearchEnabled}
|
||||
onSend={handleSend}
|
||||
onForceSend={streaming ? handleForceSend : undefined}
|
||||
generating={streaming}
|
||||
onStop={handleStop}
|
||||
onSlashCommand={handleSlashCommand}
|
||||
chatId={chatId}
|
||||
chatLabel={sessionChats?.find((c) => c.id === chatId)?.name ?? 'Chat'}
|
||||
|
||||
@@ -149,7 +149,7 @@ interface Props {
|
||||
actions?: MessageActions;
|
||||
}
|
||||
|
||||
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete' | 'openInPane')[] = ['fork', 'openInPane'];
|
||||
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete')[] = ['fork'];
|
||||
|
||||
export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -581,6 +581,10 @@ export function CoderPane({
|
||||
const [queue, setQueue] = useState<string[]>([]);
|
||||
const queueProcessing = useRef(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
// The agent is "generating" during the dispatch POST (sending) AND while its
|
||||
// task runs (activeTaskId). sending alone is too brief — it clears the moment
|
||||
// dispatch returns — so queueing/stop must key on this combined signal.
|
||||
const generating = sending || activeTaskId !== null;
|
||||
|
||||
// Refresh pending changes when a message_complete arrives
|
||||
useEffect(() => {
|
||||
@@ -760,24 +764,35 @@ export function CoderPane({
|
||||
}
|
||||
}, [sessionId, paneId, chatId, agentConfig, setMessages]);
|
||||
|
||||
// Drain queue when not busy
|
||||
// Drain queue once the agent is idle (not just past the dispatch POST).
|
||||
useEffect(() => {
|
||||
if (sending || queue.length === 0 || queueProcessing.current) return;
|
||||
if (generating || queue.length === 0 || queueProcessing.current) return;
|
||||
queueProcessing.current = true;
|
||||
const next = queue[0]!;
|
||||
setQueue((prev) => prev.slice(1));
|
||||
sendOneMessage(next).finally(() => { queueProcessing.current = false; });
|
||||
}, [sending, queue, sendOneMessage]);
|
||||
}, [generating, queue, sendOneMessage]);
|
||||
|
||||
const handleChatInputSend = useCallback(async (content: string) => {
|
||||
const text = content.trim();
|
||||
if (!text || !chatId) return;
|
||||
if (sending) {
|
||||
if (generating) {
|
||||
setQueue((prev) => [...prev, text]);
|
||||
return;
|
||||
}
|
||||
await sendOneMessage(text);
|
||||
}, [sending, chatId, sendOneMessage]);
|
||||
}, [generating, chatId, sendOneMessage]);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
const taskId = activeTaskId;
|
||||
if (!taskId) return;
|
||||
try {
|
||||
await api.coder.cancelTask(taskId);
|
||||
setActiveTaskId(null); // optimistic; WS/poll terminal-state also clears it
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'stop failed');
|
||||
}
|
||||
}, [activeTaskId]);
|
||||
|
||||
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
||||
if (!chatId) return;
|
||||
@@ -867,9 +882,11 @@ export function CoderPane({
|
||||
{/* Composer + input */}
|
||||
<div className="shrink-0 border-t border-border">
|
||||
<ChatInput
|
||||
disabled={sending || !chatId || chatPending}
|
||||
disabled={!chatId || chatPending}
|
||||
projectId={projectPath ?? ''}
|
||||
onSend={handleChatInputSend}
|
||||
generating={generating}
|
||||
onStop={handleStop}
|
||||
onSlashCommand={handleChatInputSlash}
|
||||
slashGroups={slashGroups}
|
||||
chatId={chatId ?? undefined}
|
||||
|
||||
@@ -51,7 +51,11 @@ export interface SessionUpdatedEvent {
|
||||
export interface SessionWorkspaceUpdatedEvent {
|
||||
type: 'session_workspace_updated';
|
||||
session_id: string;
|
||||
workspace_panes: import('@/api/types').WorkspacePane[];
|
||||
// Legacy bare array OR the new envelope — useWorkspacePanes normalizes both
|
||||
// via toWorkspaceState.
|
||||
workspace_panes:
|
||||
| import('@/api/types').WorkspacePane[]
|
||||
| import('@/api/types').WorkspaceState;
|
||||
}
|
||||
|
||||
export interface SessionLoadedEvent {
|
||||
@@ -75,6 +79,14 @@ export interface OpenChatInActivePaneEvent {
|
||||
chat_id: string;
|
||||
}
|
||||
|
||||
// Open a whole chat in a fresh split pane (vs the active pane). Emitted by the
|
||||
// ChatTabBar tab context menu ("Open in new pane") and by MessageBubble.fork()
|
||||
// so a fork lands beside the original. useWorkspacePanes subscribes.
|
||||
export interface OpenChatInNewPaneEvent {
|
||||
type: 'open_chat_in_new_pane';
|
||||
chat_id: string;
|
||||
}
|
||||
|
||||
// v1.14.x-html-artifact-panes: ActionRow's "Open in pane" button emits one of
|
||||
// these; useWorkspacePanes subscribes and inserts the corresponding artifact
|
||||
// pane (or focuses an existing one keyed by message_id).
|
||||
@@ -178,6 +190,7 @@ export type SessionEvent =
|
||||
| OpenFileInBrowserEvent
|
||||
| AttachChatFileEvent
|
||||
| OpenChatInActivePaneEvent
|
||||
| OpenChatInNewPaneEvent
|
||||
| OpenMarkdownArtifactPaneEvent
|
||||
| OpenHtmlArtifactPaneEvent
|
||||
| OpenSettingsPaneEvent
|
||||
|
||||
@@ -152,6 +152,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
||||
case 'attach_chat_file':
|
||||
return prev;
|
||||
case 'open_chat_in_active_pane':
|
||||
case 'open_chat_in_new_pane':
|
||||
// Consumed by Workspace; sidebar has no business with pane state.
|
||||
return prev;
|
||||
case 'open_markdown_artifact_pane':
|
||||
|
||||
@@ -3,9 +3,11 @@ import type { DragEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type {
|
||||
ClosedPaneEntry,
|
||||
HtmlArtifactState,
|
||||
MarkdownArtifactState,
|
||||
WorkspacePane,
|
||||
WorkspaceState,
|
||||
} from '@/api/types';
|
||||
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
@@ -32,6 +34,37 @@ function chatPane(chatId: string): WorkspacePane {
|
||||
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
||||
}
|
||||
|
||||
// v2.6.x: reopen stack cap. The stack now lives in React state (persisted in
|
||||
// the WorkspaceState envelope), not a module-level array. `appendClosed` is the
|
||||
// pure state-updater helper.
|
||||
const MAX_CLOSED = 10;
|
||||
|
||||
// Pure helper: append a closed-pane entry derived from `pane` to `stack`,
|
||||
// capped at MAX_CLOSED (most-recent last). Returns the SAME reference when the
|
||||
// pane is not eligible (empty/settings/no chats) so callers can skip setState.
|
||||
function appendClosed(stack: ClosedPaneEntry[], pane: WorkspacePane): ClosedPaneEntry[] {
|
||||
if (pane.kind === 'empty' || pane.kind === 'settings') return stack;
|
||||
if (pane.chatIds.length === 0) return stack;
|
||||
const entry = { kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx };
|
||||
// Dedupe a value-identical top entry. This is called via setClosedPaneStack
|
||||
// inside the setPanes updater in removePane; React StrictMode double-invokes
|
||||
// that updater in dev, which would otherwise push two identical entries.
|
||||
// Real closes never collide (one chat lives in at most one pane).
|
||||
const top = stack[stack.length - 1];
|
||||
if (
|
||||
top &&
|
||||
top.kind === entry.kind &&
|
||||
top.activeChatIdx === entry.activeChatIdx &&
|
||||
top.chatIds.length === entry.chatIds.length &&
|
||||
top.chatIds.every((id, i) => id === entry.chatIds[i])
|
||||
) {
|
||||
return stack;
|
||||
}
|
||||
const next = [...stack, entry];
|
||||
if (next.length > MAX_CLOSED) next.splice(0, next.length - MAX_CLOSED);
|
||||
return next;
|
||||
}
|
||||
|
||||
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
|
||||
return kind === 'coder' ? 'BooCoder' : 'Terminal';
|
||||
}
|
||||
@@ -95,6 +128,26 @@ function persistablePanes(panes: WorkspacePane[]): WorkspacePane[] {
|
||||
return normalizePanes(panes).filter((p) => p.kind !== 'settings');
|
||||
}
|
||||
|
||||
// v2.6.x: LOCKED migration — a value read from session.workspace_panes (or the
|
||||
// session_workspace_updated frame) may be EITHER the legacy bare
|
||||
// WorkspacePane[] OR the new WorkspaceState envelope. Normalize to the
|
||||
// envelope. Must match the server's normalization byte-for-byte.
|
||||
function toWorkspaceState(raw: unknown): WorkspaceState {
|
||||
if (Array.isArray(raw)) {
|
||||
return { panes: raw as WorkspacePane[], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
|
||||
}
|
||||
if (raw && typeof raw === 'object' && Array.isArray((raw as WorkspaceState).panes)) {
|
||||
const env = raw as WorkspaceState;
|
||||
return {
|
||||
panes: env.panes,
|
||||
tabNumbers: env.tabNumbers ?? {},
|
||||
nextTabNumber: env.nextTabNumber ?? 1,
|
||||
closedPaneStack: env.closedPaneStack ?? [],
|
||||
};
|
||||
}
|
||||
return { panes: [], tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] };
|
||||
}
|
||||
|
||||
// v1.9: per recon decision (c), settings panes don't count toward MAX_PANES.
|
||||
// Helper used at every pane-insertion site so the rule lives in one place.
|
||||
function nonSettingsCount(panes: WorkspacePane[]): number {
|
||||
@@ -117,6 +170,9 @@ function readLegacyPanes(sessionId: string): WorkspacePane[] | null {
|
||||
|
||||
export interface UseWorkspacePanesResult {
|
||||
panes: WorkspacePane[];
|
||||
// v2.6.x: stable session-scoped tab number per chat id (Batch 3a). Keyed by
|
||||
// chat.id, NEVER by tab position.
|
||||
tabNumbers: Record<string, number>;
|
||||
activePaneIdx: number;
|
||||
setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
|
||||
activePaneIdxRef: React.MutableRefObject<number>;
|
||||
@@ -137,6 +193,8 @@ export interface UseWorkspacePanesResult {
|
||||
// falls back to an empty pane to preserve the "always one pane" invariant.
|
||||
toggleSettingsPane: () => string | null;
|
||||
removePane: (idx: number) => void;
|
||||
reopenPane: () => void;
|
||||
hasClosedPanes: boolean;
|
||||
removeChatFromPanes: (chatId: string) => void;
|
||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||
validatePanes: (validChatIds: Set<string>) => void;
|
||||
@@ -154,6 +212,12 @@ export interface UseWorkspacePanesResult {
|
||||
export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
const [panes, setPanes] = useState<WorkspacePane[]>(() => [emptyPane()]);
|
||||
const [activePaneIdx, setActivePaneIdx] = useState(0);
|
||||
// v2.6.x envelope state. Persisted alongside `panes` in the WorkspaceState
|
||||
// envelope. `tabNumbers` is the stable session-scoped tab number per chat id;
|
||||
// `nextTabNumber` only ever increments; `closedPaneStack` is the reopen LIFO.
|
||||
const [tabNumbers, setTabNumbers] = useState<Record<string, number>>({});
|
||||
const [nextTabNumber, setNextTabNumber] = useState(1);
|
||||
const [closedPaneStack, setClosedPaneStack] = useState<ClosedPaneEntry[]>([]);
|
||||
const draggingIdxRef = useRef<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
// v1.12.1: skip PATCH while hydrating from the server. Without this, the
|
||||
@@ -220,27 +284,42 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
try {
|
||||
const session = await api.sessions.get(sessionId);
|
||||
if (cancelled) return;
|
||||
let initial: WorkspacePane[] = Array.isArray(session.workspace_panes)
|
||||
? normalizePanes(session.workspace_panes)
|
||||
: [];
|
||||
let env = toWorkspaceState(session.workspace_panes);
|
||||
let initial: WorkspacePane[] = normalizePanes(env.panes);
|
||||
// One-time migration: if server is empty but legacy localStorage has
|
||||
// a layout, seed the server and delete the local key.
|
||||
// a layout, seed the server (as an envelope) and delete the local key.
|
||||
if (initial.length === 0) {
|
||||
const legacy = readLegacyPanes(sessionId);
|
||||
if (legacy && legacy.length > 0) {
|
||||
try {
|
||||
const updated = await api.sessions.updateWorkspacePanes(sessionId, legacy);
|
||||
const seedState: WorkspaceState = {
|
||||
panes: persistablePanes(legacy),
|
||||
tabNumbers: {},
|
||||
nextTabNumber: 1,
|
||||
closedPaneStack: [],
|
||||
};
|
||||
const updated = await api.sessions.updateWorkspacePanes(sessionId, seedState);
|
||||
if (cancelled) return;
|
||||
initial = updated.workspace_panes;
|
||||
env = toWorkspaceState(updated.workspace_panes);
|
||||
initial = normalizePanes(env.panes);
|
||||
localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
|
||||
} catch {
|
||||
initial = legacy;
|
||||
env = { ...env, panes: legacy };
|
||||
initial = normalizePanes(legacy);
|
||||
}
|
||||
}
|
||||
}
|
||||
const next = initial.length > 0 ? initial : [emptyPane()];
|
||||
lastRemoteJsonRef.current = JSON.stringify(persistablePanes(next));
|
||||
lastRemoteJsonRef.current = JSON.stringify({
|
||||
panes: persistablePanes(next),
|
||||
tabNumbers: env.tabNumbers,
|
||||
nextTabNumber: env.nextTabNumber,
|
||||
closedPaneStack: env.closedPaneStack,
|
||||
});
|
||||
setPanes(next);
|
||||
setTabNumbers(env.tabNumbers);
|
||||
setNextTabNumber(env.nextTabNumber);
|
||||
setClosedPaneStack(env.closedPaneStack);
|
||||
setActivePaneIdx(0);
|
||||
seedEmptyScopedPanes(next);
|
||||
} finally {
|
||||
@@ -256,15 +335,25 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
return sessionEvents.subscribe((ev) => {
|
||||
if (ev.type !== 'session_workspace_updated') return;
|
||||
if (ev.session_id !== sessionId) return;
|
||||
const incoming = normalizePanes(
|
||||
Array.isArray(ev.workspace_panes) ? ev.workspace_panes : [],
|
||||
);
|
||||
const json = JSON.stringify(incoming);
|
||||
const env = toWorkspaceState(ev.workspace_panes);
|
||||
const incoming = normalizePanes(env.panes);
|
||||
// Echo-dedup on the FULL envelope so tabNumber / stack-only changes are
|
||||
// not mistaken for our own write echo.
|
||||
const json = JSON.stringify({
|
||||
panes: persistablePanes(incoming),
|
||||
tabNumbers: env.tabNumbers,
|
||||
nextTabNumber: env.nextTabNumber,
|
||||
closedPaneStack: env.closedPaneStack,
|
||||
});
|
||||
if (json === lastRemoteJsonRef.current) return;
|
||||
lastRemoteJsonRef.current = json;
|
||||
setPanes(incoming.length > 0 ? incoming : [emptyPane()]);
|
||||
const nextPanes = incoming.length > 0 ? incoming : [emptyPane()];
|
||||
setPanes(nextPanes);
|
||||
setTabNumbers(env.tabNumbers);
|
||||
setNextTabNumber(env.nextTabNumber);
|
||||
setClosedPaneStack(env.closedPaneStack);
|
||||
setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
|
||||
seedEmptyScopedPanes(incoming.length > 0 ? incoming : [emptyPane()]);
|
||||
seedEmptyScopedPanes(nextPanes);
|
||||
});
|
||||
}, [sessionId, seedEmptyScopedPanes]);
|
||||
|
||||
@@ -316,18 +405,75 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
// before saving (ephemeral per v1.9).
|
||||
useEffect(() => {
|
||||
if (!hydratedRef.current) return;
|
||||
const payload = persistablePanes(panes);
|
||||
const json = JSON.stringify(payload);
|
||||
// v2.6.x: persist the full WorkspaceState envelope. The dedup ref compares
|
||||
// the whole envelope so tabNumber / reopen-stack changes also persist.
|
||||
const envelope: WorkspaceState = {
|
||||
panes: persistablePanes(panes),
|
||||
tabNumbers,
|
||||
nextTabNumber,
|
||||
closedPaneStack,
|
||||
};
|
||||
const json = JSON.stringify(envelope);
|
||||
if (json === lastRemoteJsonRef.current) return;
|
||||
const timer = setTimeout(() => {
|
||||
lastRemoteJsonRef.current = json;
|
||||
api.sessions.updateWorkspacePanes(sessionId, payload).catch(() => {
|
||||
api.sessions.updateWorkspacePanes(sessionId, envelope).catch(() => {
|
||||
// Non-fatal: next change retries. Persistent failures surface via
|
||||
// the network layer's existing reconnect toast.
|
||||
});
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [sessionId, panes]);
|
||||
}, [sessionId, panes, tabNumbers, nextTabNumber, closedPaneStack]);
|
||||
|
||||
// v2.6.x (Batch 3a): maintain stable, session-scoped tab numbers. Collect the
|
||||
// chat ids that appear in CHAT-kind panes in deterministic order (pane index,
|
||||
// then tab index). Assign numbers to any without one (global per session,
|
||||
// only ever increasing, never reused) and prune entries whose chat is no
|
||||
// longer in any chat-kind pane. Guarded against render loops: only setState
|
||||
// when something actually changed.
|
||||
useEffect(() => {
|
||||
const liveChatIds: string[] = [];
|
||||
const liveSet = new Set<string>();
|
||||
for (const pane of panes) {
|
||||
if (pane.kind !== 'chat') continue;
|
||||
for (const id of pane.chatIds) {
|
||||
if (!liveSet.has(id)) {
|
||||
liveSet.add(id);
|
||||
liveChatIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign: walk live ids in deterministic order, handing out numbers.
|
||||
let counter = nextTabNumber;
|
||||
const additions: Record<string, number> = {};
|
||||
for (const id of liveChatIds) {
|
||||
if (tabNumbers[id] === undefined && additions[id] === undefined) {
|
||||
additions[id] = counter;
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Prune: retire numbers for chats no longer in any chat-kind pane.
|
||||
const removals: string[] = [];
|
||||
for (const id of Object.keys(tabNumbers)) {
|
||||
if (!liveSet.has(id)) removals.push(id);
|
||||
}
|
||||
|
||||
const hasAdditions = Object.keys(additions).length > 0;
|
||||
const hasRemovals = removals.length > 0;
|
||||
if (!hasAdditions && !hasRemovals) return;
|
||||
|
||||
setTabNumbers((prev) => {
|
||||
const next: Record<string, number> = {};
|
||||
for (const [id, n] of Object.entries(prev)) {
|
||||
if (!removals.includes(id)) next[id] = n;
|
||||
}
|
||||
Object.assign(next, additions);
|
||||
return next;
|
||||
});
|
||||
if (hasAdditions) setNextTabNumber(counter);
|
||||
}, [panes, tabNumbers, nextTabNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
const active = panes[activePaneIdx];
|
||||
@@ -374,6 +520,37 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
setActivePaneIdx(paneIdx);
|
||||
}, []);
|
||||
|
||||
// Open a whole chat in its own fresh pane (focused). Detaches the chat from
|
||||
// any pane currently showing it so it lives in exactly one pane (preserves
|
||||
// the one-chat-per-pane model), dropping a source pane left with no tabs. For
|
||||
// fork the chat isn't in any pane yet, so the detach is a no-op (pure append).
|
||||
const openChatInNewPane = useCallback((chatId: string) => {
|
||||
setPanes((prev) => {
|
||||
const detached = prev.flatMap((p) => {
|
||||
if (!p.chatIds.includes(chatId)) return [p];
|
||||
const nextIds = p.chatIds.filter((id) => id !== chatId);
|
||||
if (nextIds.length === 0) return [];
|
||||
const ai = Math.min(p.activeChatIdx, nextIds.length - 1);
|
||||
return [{ ...p, kind: 'chat' as const, chatId: nextIds[ai], chatIds: nextIds, activeChatIdx: ai }];
|
||||
});
|
||||
if (nonSettingsCount(detached) >= MAX_PANES) {
|
||||
toast.error(`Maximum ${MAX_PANES} panes`);
|
||||
return prev;
|
||||
}
|
||||
const next = [...detached, chatPane(chatId)];
|
||||
setActivePaneIdx(next.length - 1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ChatTabBar's "Open in new pane" + MessageBubble.fork() emit this.
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((ev) => {
|
||||
if (ev.type !== 'open_chat_in_new_pane') return;
|
||||
openChatInNewPane(ev.chat_id);
|
||||
});
|
||||
}, [openChatInNewPane]);
|
||||
|
||||
const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
|
||||
setPanes((prev) => {
|
||||
const next = [...prev];
|
||||
@@ -391,6 +568,14 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
const pane = next[paneIdx]!;
|
||||
const nextIds = pane.chatIds.filter((id) => id !== chatId);
|
||||
if (nextIds.length === 0) {
|
||||
if (next.length > 1) {
|
||||
// Last tab closed and other panes exist — remove the whole pane
|
||||
// instead of leaving an orphaned empty panel.
|
||||
setClosedPaneStack((stack) => appendClosed(stack, pane));
|
||||
const spliced = next.filter((_, i) => i !== paneIdx);
|
||||
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
|
||||
return spliced;
|
||||
}
|
||||
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||
} else {
|
||||
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
||||
@@ -522,7 +707,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
setPanes((prev) => {
|
||||
if (prev.length <= 1) {
|
||||
// Settings is the only kind that can be the last pane and still need
|
||||
// closing (X / Esc / sidebar toggle). Fall back to empty.
|
||||
// closing (X / Esc / sidebar toggle). Fall back to empty. One-pane
|
||||
// edge: no relocation — there is no other pane.
|
||||
if (prev[idx]?.kind === 'settings') {
|
||||
setActivePaneIdx(0);
|
||||
return [emptyPane()];
|
||||
@@ -534,15 +720,102 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
// The endpoint is idempotent (404 on missing session) so a strict-mode
|
||||
// double-invoke of the updater is safe.
|
||||
const removed = prev[idx];
|
||||
// Push the original pane (with its chatIds intact) to the reopen stack.
|
||||
if (removed) setClosedPaneStack((stack) => appendClosed(stack, removed));
|
||||
if (removed?.kind === 'terminal') {
|
||||
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
|
||||
}
|
||||
const next = prev.filter((_, i) => i !== idx);
|
||||
|
||||
// v2.6.x (Batch 1): relocate a closing CHAT pane's tabs to the oldest
|
||||
// remaining pane that can host chat tabs, so chats aren't lost on close.
|
||||
// Only chat panes relocate — terminal/coder panes own a scoped chat bound
|
||||
// to the pane, so those close exactly as before (no relocation).
|
||||
let working = prev;
|
||||
if (removed && removed.kind === 'chat' && removed.chatIds.length > 0) {
|
||||
// "Oldest remaining": lowest index, excluding `idx`, that is a chat or
|
||||
// empty pane (the only kinds that can host arbitrary chat tabs). Skip
|
||||
// terminal/coder/settings/artifact panes.
|
||||
let targetIdx = -1;
|
||||
for (let i = 0; i < prev.length; i += 1) {
|
||||
if (i === idx) continue;
|
||||
const p = prev[i]!;
|
||||
if (p.kind === 'chat' || p.kind === 'empty') {
|
||||
targetIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetIdx >= 0) {
|
||||
working = prev.map((p, i) => {
|
||||
if (i !== targetIdx) return p;
|
||||
const mergedIds = [...p.chatIds, ...removed.chatIds];
|
||||
// Preserve the target's existing focus — append, don't force-focus
|
||||
// the moved tabs. Clamp only when the target had no active tab.
|
||||
const ai = p.activeChatIdx >= 0 ? p.activeChatIdx : 0;
|
||||
return {
|
||||
...p,
|
||||
kind: 'chat' as const,
|
||||
chatIds: mergedIds,
|
||||
activeChatIdx: ai,
|
||||
chatId: mergedIds[ai],
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const next = working.filter((_, i) => i !== idx);
|
||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||
return next;
|
||||
});
|
||||
}, [sessionId]);
|
||||
|
||||
const hasClosedPanes = closedPaneStack.length > 0;
|
||||
|
||||
const reopenPane = useCallback(() => {
|
||||
// Read the top entry from the current render's stack (not inside the
|
||||
// updater) so a StrictMode double-invoke can't pop two entries. The pop
|
||||
// setState is idempotent: filtering by reference removes exactly this entry.
|
||||
const e = closedPaneStack[closedPaneStack.length - 1];
|
||||
if (!e) return;
|
||||
setClosedPaneStack((stack) => (stack[stack.length - 1] === e ? stack.slice(0, -1) : stack));
|
||||
setPanes((prev) => {
|
||||
// v2.6.x (Batch 4): reversible reopen. The closed tabs may have been
|
||||
// relocated into another pane on close (Batch 1). Strip e.chatIds from
|
||||
// every existing pane first so reopening never duplicates a tab —
|
||||
// whether or not it was relocated (a no-op strip when it wasn't). Mirror
|
||||
// removeTab's emptiness handling: a chat pane emptied by the strip is
|
||||
// dropped when other panes remain, else turned empty.
|
||||
const stripped: WorkspacePane[] = [];
|
||||
for (const p of prev) {
|
||||
const idxs = p.chatIds.filter((id) => !e.chatIds.includes(id));
|
||||
if (idxs.length === p.chatIds.length) {
|
||||
stripped.push(p);
|
||||
continue;
|
||||
}
|
||||
if (idxs.length === 0) {
|
||||
if (p.kind === 'chat') {
|
||||
// Drop the now-empty chat pane (we still have the restored pane plus
|
||||
// possibly others). If it would leave zero panes, turn it empty.
|
||||
continue;
|
||||
}
|
||||
stripped.push({ ...p, chatId: undefined, chatIds: [], activeChatIdx: -1 });
|
||||
continue;
|
||||
}
|
||||
const ai = Math.min(p.activeChatIdx, idxs.length - 1);
|
||||
stripped.push({ ...p, chatIds: idxs, activeChatIdx: ai < 0 ? 0 : ai, chatId: idxs[ai < 0 ? 0 : ai] });
|
||||
}
|
||||
const restored: WorkspacePane = {
|
||||
id: generateId(),
|
||||
kind: e.kind,
|
||||
chatId: e.chatIds[e.activeChatIdx] ?? e.chatIds[0],
|
||||
chatIds: e.chatIds,
|
||||
activeChatIdx: Math.min(e.activeChatIdx, e.chatIds.length - 1),
|
||||
};
|
||||
const next = [...stripped, restored];
|
||||
setActivePaneIdx(next.length - 1);
|
||||
return next;
|
||||
});
|
||||
}, [closedPaneStack]);
|
||||
|
||||
// Replaces a single empty default pane with a chat pane. Used by the initial
|
||||
// chat fetch to land on the most-recent open chat if no saved pane state.
|
||||
const initializeFirstChatIfEmpty = useCallback((chatId: string) => {
|
||||
@@ -659,6 +932,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
|
||||
return {
|
||||
panes,
|
||||
tabNumbers,
|
||||
activePaneIdx,
|
||||
setActivePaneIdx,
|
||||
activePaneIdxRef,
|
||||
@@ -672,6 +946,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
addSplitPane,
|
||||
toggleSettingsPane,
|
||||
removePane,
|
||||
reopenPane,
|
||||
hasClosedPanes,
|
||||
removeChatFromPanes,
|
||||
initializeFirstChatIfEmpty,
|
||||
validatePanes,
|
||||
|
||||
@@ -56,19 +56,26 @@ export function inferLanguage(filename: string): string | null {
|
||||
|
||||
export function flattenToMessage(attachments: Attachment[], text: string): string {
|
||||
if (attachments.length === 0) return text;
|
||||
const blocks = attachments.map(a => {
|
||||
// Pasted text is raw context, not code from a file — insert it verbatim with
|
||||
// no ``` fence or provenance header. The chip only exists to keep the textarea
|
||||
// tidy while composing; on send it should be exactly what the user pasted.
|
||||
// Pasted text is raw context, not code from a file — insert it verbatim with no
|
||||
// ``` fence or provenance header. It trails the typed text with a leading space
|
||||
// so a leading slash command / prompt stays first and the paste reads as its
|
||||
// continuation. File/line chips stay fenced provenance blocks, appended after.
|
||||
const pasteBlocks: string[] = [];
|
||||
const fencedBlocks: string[] = [];
|
||||
for (const a of attachments) {
|
||||
if (a.kind === 'paste') {
|
||||
return a.content;
|
||||
pasteBlocks.push(a.content);
|
||||
continue;
|
||||
}
|
||||
const fence = '```' + (a.language ?? '');
|
||||
const header =
|
||||
a.kind === 'lines'
|
||||
? `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`
|
||||
: `// from: ${a.filename}`;
|
||||
return `${fence}\n${header}\n${a.content}\n\`\`\``;
|
||||
});
|
||||
return [...blocks, text].filter(Boolean).join('\n\n');
|
||||
fencedBlocks.push(`${fence}\n${header}\n${a.content}\n\`\`\``);
|
||||
}
|
||||
// Typed text + pasted content on the same logical line (space-joined), then
|
||||
// any fenced file blocks as separate paragraphs.
|
||||
const lead = [text, ...pasteBlocks].filter(Boolean).join(' ');
|
||||
return [lead, ...fencedBlocks].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Agents
|
||||
|
||||
Operating rules for every agent in this registry. Full procedures live in the `committing-changes` and `using-worktrees` skills.
|
||||
|
||||
**Committing** — Commit only on Sam's explicit command, never autonomously and never on apply; never `git push` (Sam pushes manually, Gitea + GitHub mirror). Stage by concern (named files or `git add -p`), never `git add -A`; never stage Sam's unrelated work. Identity `indifferentketchup` / `sam@indifferentketchup.com`, never a personal Gmail. Freeform scope-prefix messages, explain *why* for non-obvious changes, no emojis. Full workflow: invoke `committing-changes`.
|
||||
|
||||
**Worktrees** — Isolate work in a worktree when it is parallel to in-progress work, risky/experimental, a hotfix interrupting other work, or splits into independent units — just create when clear, propose in one line when ambiguous, skip quick/small single-stream work. Branch from a stable base (default branch); worktrees persist (never auto-remove or auto-merge); they isolate code state, not runtime (ports/DBs/services still collide). Full heuristic: invoke `using-worktrees`.
|
||||
|
||||
## Code Reviewer
|
||||
---
|
||||
temperature: 0.6
|
||||
|
||||
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.
|
||||
61
data/skills/boocode/systematic-debugging/SKILL.md
Normal file
61
data/skills/boocode/systematic-debugging/SKILL.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: systematic-debugging
|
||||
description: Guided root-cause debugging. Use when encountering any bug, test failure, unexpected behavior, or performance problem. Enforces investigation before fixes.
|
||||
---
|
||||
|
||||
# Systematic Debugging
|
||||
|
||||
No fixes without root cause. Symptom fixes mask real bugs and waste time.
|
||||
|
||||
## The Rule
|
||||
|
||||
Complete Phase 1 before proposing ANY fix. If you haven't investigated, you cannot fix.
|
||||
|
||||
## Phase 1: Root Cause Investigation
|
||||
|
||||
1. **Read error messages carefully.** Stack traces, line numbers, error codes. Don't skip past them.
|
||||
2. **Reproduce consistently.** Exact steps, every time. If not reproducible, gather more data instead of guessing.
|
||||
3. **Check recent changes.** Git diff, recent commits, new deps, config changes, env differences.
|
||||
4. **Trace data flow.** Where does the bad value originate? Trace backward through the call stack to the source. Fix at the source, not the symptom.
|
||||
5. **Multi-component systems:** Before fixing, add diagnostic logging at each component boundary (what enters, what exits). Run once to locate the failing layer, THEN investigate that layer.
|
||||
|
||||
## Phase 2: Pattern Analysis
|
||||
|
||||
1. Find working examples of similar code in the same codebase.
|
||||
2. Compare working vs broken — list every difference.
|
||||
3. If implementing a pattern, read the reference implementation completely, not skimmed.
|
||||
4. Understand all dependencies, config, and assumptions.
|
||||
|
||||
## Phase 3: Hypothesis and Testing
|
||||
|
||||
1. State one hypothesis clearly: "X is the root cause because Y."
|
||||
2. Make the smallest possible change to test it. One variable at a time.
|
||||
3. If it didn't work, form a NEW hypothesis. Don't stack more fixes on top.
|
||||
4. After 3 failed fixes: STOP. Question the architecture, not the symptoms.
|
||||
|
||||
## Phase 4: Implementation
|
||||
|
||||
1. Create a failing test case first (simplest reproduction).
|
||||
2. Implement a single fix addressing the root cause.
|
||||
3. Verify: test passes, no regressions, issue actually resolved.
|
||||
4. If the fix doesn't work and you've tried 3+: the problem is architectural. Discuss before attempting more.
|
||||
|
||||
## Red Flags — STOP and return to Phase 1
|
||||
|
||||
- "Quick fix for now, investigate later"
|
||||
- "Just try changing X and see"
|
||||
- "I don't fully understand but this might work"
|
||||
- "One more fix attempt" after 2+ failures
|
||||
- Proposing solutions before tracing data flow
|
||||
- Each fix reveals a new problem in a different place
|
||||
|
||||
## Apply This Skill
|
||||
|
||||
Use these tools to investigate before proposing changes:
|
||||
|
||||
- `view_file` to read error sites and suspect code paths
|
||||
- `grep` to find all callers / references to the failing function
|
||||
- `find_files` to locate related config, test fixtures, schema
|
||||
- `list_dir` to understand the module layout around the bug
|
||||
|
||||
Report your Phase 1 findings (what you observed, what you traced, what you ruled out) before moving to Phase 3.
|
||||
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
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -63,6 +63,9 @@ importers:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.29.0
|
||||
version: 1.29.0(zod@3.25.76)
|
||||
'@opencode-ai/sdk':
|
||||
specifier: ~1.15.0
|
||||
version: 1.15.12
|
||||
fastify:
|
||||
specifier: ^4.28.1
|
||||
version: 4.29.1
|
||||
@@ -920,6 +923,9 @@ packages:
|
||||
'@open-draft/until@2.1.0':
|
||||
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
|
||||
|
||||
'@opencode-ai/sdk@1.15.12':
|
||||
resolution: {integrity: sha512-lOaBNX93dkakZe6C42ttX1bkSx3K2c6+Yv+w8Qv02v5rPlu1vCXbmdfYDh9/bw+oq+NKPSaBm9d6kPA19hA5Lg==}
|
||||
|
||||
'@opentelemetry/api@1.9.1':
|
||||
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -4702,6 +4708,10 @@ snapshots:
|
||||
|
||||
'@open-draft/until@2.1.0': {}
|
||||
|
||||
'@opencode-ai/sdk@1.15.12':
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
|
||||
'@opentelemetry/api@1.9.1': {}
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
Reference in New Issue
Block a user