Compare commits

...

8 Commits

Author SHA1 Message Date
d66948c925 docs(changelog): v2.6.6-claude-md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:44:33 +00:00
58d0c0f132 docs(claude-md): v2.6.5 session learnings
Capture four recurring gotchas from the panes/tabs/composer batch: the
workspace_panes WorkspaceState envelope (+ legacy-array migration on hydrate and
the union-accepting server PATCH validator); the optional ToolExecCtx
({ sql, sessionId }) 4th arg on ToolDef.execute for DB/session-aware tools
(read_tab_by_number reference); the two-schema-files-one-DB ownership split
(apps/coder owns agent_sessions/worktrees/pending_changes/available_agents) plus
the idempotent confdeltype FK-action-flip pattern; and that React StrictMode is
on, so a setState called inside another setState's updater double-fires in dev.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:44:20 +00:00
7b4f41b26f docs: roadmap shipping-state update + external code-review v2 findings
Update boocode_roadmap.md's shipped section through v2.6.4 (provider lifecycle,
persistent agent sessions, cursor/copilot retirement) and add
boocode_code_review_v2.md — a point-in-time external-fork lift/cross-check
findings doc (Paseo + opencode + llama.cpp + the second fork sweep), companion
to the standing boocode_code_review.md inventory.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 02:13:40 +00:00
24 changed files with 1160 additions and 211 deletions

View File

@@ -2,6 +2,14 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.6.6-claude-md — 2026-05-31
Docs-only — CLAUDE.md session-learnings update, no code. Captures four recurring gotchas surfaced while shipping `v2.6.5-panes-tabs-composer`: (1) `sessions.workspace_panes` is now a `WorkspaceState` envelope (`panes` + `tabNumbers`/`nextTabNumber` + `closedPaneStack`), migrated from the legacy bare `WorkspacePane[]` on both frontend hydrate (`toWorkspaceState`) and the union-accepting server PATCH validator; (2) DB/session-aware tools take an optional `ToolExecCtx` (`{ sql, sessionId }`) 4th arg on `ToolDef.execute`, plumbed through the tool phase, with `read_tab_by_number` as the reference; (3) the two-schema-files-one-DB ownership split — `apps/coder/src/schema.sql` owns `agent_sessions`/`worktrees`/`pending_changes`/`available_agents` and extends `tasks`, distinct from BooChat's `apps/server/src/schema.sql` — plus the idempotent `confdeltype` FK-action-flip pattern (guard `ON DELETE` changes on `pg_constraint.confdeltype` so re-runs no-op); and (4) React StrictMode is on, so a `setState` called inside another `setState`'s updater double-fires in dev and must be made idempotent. Pairs with `v2.6.5-panes-tabs-composer`.
## 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.

View File

@@ -126,11 +126,11 @@ Font / CSS pipeline (apps/web):
### Multi-pane workspace
Sessions hold 15 panes (chat / empty / placeholder terminal+agent). v1.12.1 moved pane state from per-device localStorage to `sessions.workspace_panes jsonb` for cross-device sync. `PATCH /api/sessions/:id/workspace` persists; `session_workspace_updated` user-channel frame broadcasts to every device watching the session. `useWorkspacePanes` debounces saves 300ms and dedups echoes by JSON string. Legacy localStorage key `boocode.workspace.panes.<sessionId>` is read once on first hydrate (one-time seed-and-delete migration when server is empty but localStorage has data); no longer written. The deprecated `session_panes` table was dropped. `validatePanes(validChatIds)` prunes panes referencing chat IDs that no longer exist (called by `useSessionChats` after the chat list fetch lands). Each chat lives in at most one pane; tab strip is per-pane and tracks `chatIds[]` + `activeChatIdx`. Tab reorder via native HTML5 drag events.
Sessions hold 15 panes (chat / empty / placeholder terminal+agent). v1.12.1 moved pane state from per-device localStorage to `sessions.workspace_panes jsonb` for cross-device sync. `PATCH /api/sessions/:id/workspace` persists; `session_workspace_updated` user-channel frame broadcasts to every device watching the session. `useWorkspacePanes` debounces saves 300ms and dedups echoes by JSON string. Legacy localStorage key `boocode.workspace.panes.<sessionId>` is read once on first hydrate (one-time seed-and-delete migration when server is empty but localStorage has data); no longer written. The deprecated `session_panes` table was dropped. `validatePanes(validChatIds)` prunes panes referencing chat IDs that no longer exist (called by `useSessionChats` after the chat list fetch lands). Each chat lives in at most one pane; tab strip is per-pane and tracks `chatIds[]` + `activeChatIdx`. Tab reorder via native HTML5 drag events. v2.6.5: `workspace_panes` is now a `WorkspaceState` envelope `{panes, tabNumbers (chatId→stable session-scoped tab number, assigned on chat-pane open, retired on close, never reused), nextTabNumber, closedPaneStack (reopen LIFO, max 10, persisted so it survives reload)}` — not a bare `WorkspacePane[]`. Hydrate (`toWorkspaceState`) and the server PATCH validator (`z.union([array, envelope])` in `routes/sessions.ts`) both accept the legacy array and normalize to the envelope on read/write. Closing a chat pane relocates its tabs to the oldest chat/empty pane; `reopenPane` strips the restored chatIds from all live panes first (no duplication). `read_tab_by_number` resolves a number→chatId through `tabNumbers`.
## Database
PostgreSQL 16. Database name: `boochat` (renamed from `boocode` in v2.0.0-alpha; Docker service name stays `boocode_db`). Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `message_parts` (v1.13.0), `pending_changes` (v2.0.0), `tasks` (v2.0.0), `available_agents` (v2.0.0). Views: `messages_with_parts` (v1.13.1-B parts-merge read path), `tool_cost_stats` (v1.13.10 per-tool 100-call rolling window), `human_inbox` (v2.0.0 — tasks WHERE state IN blocked/failed). (`session_panes` was dropped in v1.12.1; workspace pane state lives in `sessions.workspace_panes jsonb`.) Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`. The older anonymous `messages_status_check` (without 'cancelled') and `messages_role_check` (without 'system') were dropped in v1.12.1; only the `_chk` variants remain.
PostgreSQL 16. Database name: `boochat` (renamed from `boocode` in v2.0.0-alpha; Docker service name stays `boocode_db`). Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `message_parts` (v1.13.0), `pending_changes` (v2.0.0), `tasks` (v2.0.0), `available_agents` (v2.0.0). Views: `messages_with_parts` (v1.13.1-B parts-merge read path), `tool_cost_stats` (v1.13.10 per-tool 100-call rolling window), `human_inbox` (v2.0.0 — tasks WHERE state IN blocked/failed). (`session_panes` was dropped in v1.12.1; workspace pane state lives in `sessions.workspace_panes jsonb`.) Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`. The older anonymous `messages_status_check` (without 'cancelled') and `messages_role_check` (without 'system') were dropped in v1.12.1; only the `_chk` variants remain. **Two schema files, one DB:** `apps/server/src/schema.sql` owns `sessions`/`chats`/`messages`/`message_parts`; `apps/coder/src/schema.sql` (applied by the boocoder host service) owns `agent_sessions`, `worktrees`, `pending_changes`, `available_agents` and extends `tasks`. Both apply idempotently to the one `boochat` DB — so e.g. an `agent_sessions` FK change goes in the **coder** schema, not the server one. Idempotent FK-action flips (e.g. `ON DELETE CASCADE``SET NULL`) guard on `pg_constraint.confdeltype` so a re-run/fresh-deploy is a no-op (see the `session_worktrees`/`agent_sessions` defang blocks).
Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ... DROP CONSTRAINT IF EXISTS <system_name>` (inline `CREATE TABLE` checks get `<table>_<column>_check`), (2) `UPDATE` rows to new values, (3) wrap new constraint ADD in `DO $$ ... pg_constraint` guard — that block is the only way to get `ADD CONSTRAINT IF NOT EXISTS`.
@@ -188,8 +188,10 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
- A scrollable list inside a Dialog on mobile: cap `DialogContent` (`max-h-[85vh]` + `grid-rows-[auto_minmax(0,1fr)_auto]`) and make the list the single scroll region with `overscroll-contain` — otherwise touch-scroll drags the whole fixed modal / chains to the page.
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.
- **New tools** live in their own `services/<name>.ts` file (see `web_search.ts`, `web_fetch.ts`) — exports a pure `executeFoo(input, ...deps)` for direct test access plus a `ToolDef` wrapper that `loadConfig()`s its real dependencies. Register the ToolDef in `tools.ts` `ALL_TOOLS` (and `READ_ONLY_TOOL_NAMES` if applicable). Inject `fetcher: typeof fetch = fetch` rather than `vi.spyOn(globalThis, 'fetch')` — cleanup is simpler and the production call site stays unchanged.
- **DB/session-aware tools** take an optional 4th `ToolExecCtx { sql, sessionId }` arg on `ToolDef.execute`, plumbed `executeToolPhase``executeToolCall``execute`. It's optional so the filesystem tools and the `apps/coder` `ALL_TOOLS` consumer stay compatible; filesystem tools ignore it. `read_tab_by_number` (reads `sessions.workspace_panes` + the chat's messages via `sql`) is the reference.
- **Sentinels** are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. A new kind requires arms in `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts`, plus a render branch in `apps/web/src/components/MessageBubble.tsx`.
- **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).
- React **StrictMode is on** (`main.tsx`): an updater passed to one `setState` that itself calls another `setState` (e.g. `setClosedPaneStack` inside a `setPanes` updater) is double-invoked in dev. Make such nested updates idempotent — `useWorkspacePanes`'s `appendClosed` dedupes a value-identical top entry for exactly this reason.
- 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.

View File

@@ -28,18 +28,20 @@ const HtmlArtifactStateZ = z.object({
title: z.string().max(500),
});
const PaneKindZ = z.enum([
'chat',
'terminal',
'coder',
'agent', // legacy alias — normalized to coder on write
'empty',
'settings',
'markdown_artifact',
'html_artifact',
]);
const WorkspacePaneZ = z.object({
id: z.string().min(1).max(200),
kind: z.enum([
'chat',
'terminal',
'coder',
'agent', // legacy alias — normalized to coder on write
'empty',
'settings',
'markdown_artifact',
'html_artifact',
]),
kind: PaneKindZ,
chatId: z.string().min(1).max(200).optional(),
chatIds: z.array(z.string().min(1).max(200)).max(50),
activeChatIdx: z.number().int(),
@@ -47,8 +49,27 @@ const WorkspacePaneZ = z.object({
html_artifact_state: HtmlArtifactStateZ.optional(),
});
// v2.6.x: workspace_panes column widened from a bare WorkspacePane[] to a
// WorkspaceState envelope (panes + stable session-scoped tab numbering +
// reopen stack). closedPaneStack entries are lighter than full panes — just
// the kind + chat ids needed to recreate a closed pane on reopen.
const ClosedPaneEntryZ = z.object({
kind: PaneKindZ,
chatIds: z.array(z.string().min(1).max(200)).max(50),
activeChatIdx: z.number().int(),
});
const WorkspaceStateZ = z.object({
panes: z.array(WorkspacePaneZ).max(10),
tabNumbers: z.record(z.string(), z.number().int()).default({}),
nextTabNumber: z.number().int().default(1),
closedPaneStack: z.array(ClosedPaneEntryZ).max(10).default([]),
});
// Accept either the legacy bare array OR the envelope. The handler normalizes
// to a full envelope before storing (see MIGRATION rule in the PATCH handler).
const WorkspacePanesBody = z.object({
workspace_panes: z.array(WorkspacePaneZ).max(10),
workspace_panes: z.union([z.array(WorkspacePaneZ).max(10), WorkspaceStateZ]),
});
const PatchBody = z.object({
@@ -308,12 +329,20 @@ export function registerSessionRoutes(
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const workspacePanes = parsed.data.workspace_panes.map((pane) =>
// v2.6.x MIGRATION: the body is either a legacy bare WorkspacePane[] or
// the WorkspaceState envelope. Normalize to a full envelope so the column
// always stores the envelope shape going forward.
const body = parsed.data.workspace_panes;
const envelope = Array.isArray(body)
? { panes: body, tabNumbers: {}, nextTabNumber: 1, closedPaneStack: [] }
: body;
// agent → coder normalization on the panes array (unchanged write rule).
envelope.panes = envelope.panes.map((pane) =>
pane.kind === 'agent' ? { ...pane, kind: 'coder' as const } : pane,
);
const rows = await sql<Session[]>`
UPDATE sessions
SET workspace_panes = ${sql.json(workspacePanes as never)},
SET workspace_panes = ${sql.json(envelope as never)},
updated_at = clock_timestamp()
WHERE id = ${req.params.id}
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ import type {
CoderTaskDetail,
PermissionPrompt,
AgentCommand,
WorkspaceState,
} from './types';
export class ApiError extends Error {
@@ -175,10 +176,10 @@ export const api = {
),
openChatsCount: (id: string) =>
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
updateWorkspacePanes: (id: string, panes: Session['workspace_panes']) =>
updateWorkspacePanes: (id: string, state: WorkspaceState) =>
request<Session>(`/api/sessions/${id}/workspace`, {
method: 'PATCH',
body: JSON.stringify({ workspace_panes: panes }),
body: JSON.stringify({ workspace_panes: state }),
}),
},
@@ -354,6 +355,10 @@ export const api = {
request<{ taskId: string; commands: AgentCommand[] }>(`/api/coder/tasks/${taskId}/commands`),
getTask: (taskId: string) =>
request<CoderTaskDetail>(`/api/coder/tasks/${taskId}`),
// Cancel a pending/running coder task (cancels permission wait + inference;
// server sets state='cancelled'). Used by CoderPane's stop button.
cancelTask: (taskId: string) =>
request<{ cancelled: boolean }>(`/api/coder/tasks/${taskId}/cancel`, { method: 'POST' }),
listMessages: (sessionId: string, chatId?: string) =>
request<CoderMessageWire[]>(
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

249
boocode_code_review_v2.md Normal file
View File

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

View File

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