Compare commits

...

7 Commits

Author SHA1 Message Date
e04d0fdaa8 feat(coder): unified Plan/Ask/Bypass permission picker
Replace the raw per-agent mode dropdown in the BooCoder composer with a
curated three-option permission ladder mapped generically onto each
provider's native modes: `plan` id -> Plan, default -> Ask, isUnattended
-> Bypass (claude bypassPermissions, qwen yolo, opencode full-access).
modeId stays the single wire field; the active unified mode is derived
from it (no contracts change).

Native BooCode gains its own mode set: Ask stages to the pending-changes
queue (today's behavior), Bypass auto-applies the queue to disk after the
turn (interactive messages path + task dispatcher path), Plan falls back
to Ask. The shared apps/server inference engine is left untouched.

Also preserve isUnattended on live-probed ACP modes so opencode's bypass
mode stays detectable from the wire.

Coder 373 tests green; coder + web typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:14:21 +00:00
da36344d0b style(web): outline the slash-picker chevron buttons
Give the expand chevrons the BooCoder outline-button look (border-border
bg-background, hover:bg-muted, filled when expanded) instead of the borderless
ghost style. Applies to both BooChat's flat menu and BooCoder's grouped menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:00:49 +00:00
875cae0843 fix(coder): parse YAML block-scalar descriptions in slash command discovery
Most plugin/han SKILL.md and command files write `description:` as a folded
block scalar (`>` / `|`) with the text on the following indented lines. The
old single-line frontmatter reader captured the literal `>`, so the slash
menu showed garbage/blank descriptions for nearly all of them. frontmatterField
now collapses folded blocks (join with spaces) and preserves literal blocks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:00:49 +00:00
4caa5f91ff docs: CLAUDE.md notes for Orchestrator + gitignored .env.host
Document the in-app Orchestrator engine and its load-bearing read-only
invariant in apps/coder/CLAUDE.md, and note that apps/coder/.env.host is
now gitignored (recreated from .env.example with CLAUDE_SDK_BACKEND=1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:48:50 +00:00
1d416d0cf9 docs: refresh README + CURRENT.md for v2.7.17 (Orchestrator)
Bring README current (was v2.2.1): add the Orchestrator, the Files/Git diff
panel, persistent agent sessions + claude Agent-SDK, fix the provider list
(5 — cursor/copilot retired), drop the broken AGENTS.md link, update latest
release + planned. Refresh CURRENT.md to v2.7.17 on main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:43:53 +00:00
bfda61e27e chore: stop tracking apps/coder/.env.host
Untrack the host env file (git rm --cached, kept on disk for the boocoder
service) and widen .gitignore to .env.* (re-including .env.example) so env
files no longer get committed. The file's prior contents (dev DB password +
internal Tailscale URLs; no API keys) remain in history — left as-is given the
single-user Tailscale-only threat model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:32:03 +00:00
a734615480 docs: archive shipped openspec changes, refresh roadmap + DEFERRED-WORK
Move openspec/changes/{contracts-ssot,orchestrator} → archived/ (both shipped,
v2.7.13 and v2.7.17). Mark the roadmap's "Write/edit robustness" and "Claude
provider SDK" milestones as shipped (fuzzy-match.ts + checkpoints.ts; the
claude-sdk backend is live via CLAUDE_SDK_BACKEND in .env.host) and add a
v2.7.12–v2.7.17 shipped summary. Flag DEFERRED-WORK.md as superseded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:30:01 +00:00
27 changed files with 247 additions and 57 deletions

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
node_modules
dist
.env
.env.*
!.env.example
# Claude / Cursor (local agent & IDE config — CLAUDE.md and AGENTS.md stay tracked)
.claude/

View File

@@ -2,6 +2,10 @@
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.7.18-permission-modes — 2026-06-05
Adds a unified **permission picker** to the BooCoder composer — Plan / Ask Permission / Bypass — replacing the old raw per-agent mode dropdown that exposed each agent's full native vocabulary with inconsistent labels. The three options map generically onto every provider's existing mode metadata: the `plan`-id mode → Plan, the default mode → Ask, the `isUnattended` mode → Bypass (claude `bypassPermissions`, qwen `yolo`, opencode `full-access`); goose has no modes so it shows no picker, exactly as before. `modeId` stays the single wire field — the active unified mode is derived from it, so no contracts change was needed. Native BooCode gains its own mode set (registered in the manifest and exposed by the snapshot): **Ask** stages edits to the pending-changes queue as today, **Bypass** auto-applies the queue to disk after the turn (both the interactive messages path and the task-based dispatcher path), and **Plan** falls back to Ask — the shared `apps/server` inference engine is deliberately left untouched. A supporting fix preserves the `isUnattended` flag on live-probed ACP modes (`acp-derive.ts`) so opencode's bypass mode is still detectable from the wire. Coder 373 tests green, coder + web typecheck clean. Built on `v2.7.17-orchestrator`.
## v2.7.17-orchestrator — 2026-06-03
Brings the deterministic multi-agent "conductor" into the app as the **Orchestrator**: launch any read-only Han flow (research, code-review, investigate, architectural-analysis, security-review, …) from BooChat or BooCoder and watch each specialist agent stream live in a Paseo-style run pane, ending with an evidence-disciplined, adversarially-validated report — all on free local Qwen, persisted and resumable. Built and audited end-to-end via `paseo-epic` in an isolated worktree, on top of the prior `/opt/boocode/conductor` standalone CLI: the conductor's 22 flow definitions, Spine factory, and Han evidence/YAGNI contracts were re-homed into `apps/coder/src/conductor`, and a new DB-backed flow-runner (`flow_runs`/`flow_steps`) dispatches each step as a real BooCoder task through the existing dispatcher — reusing its streaming→WS-frame pipeline and worktree-as-read-snapshot, with an `onTaskTerminal` hook that advances the wave and a startup resume that re-dispatches in-flight steps after a coder restart. Read-only is enforced hard: every step is dispatched `qwen --approval-mode plan`, an adversarial-security review caught and closed a bypass where a qwen-unavailable task silently fell through to write-capable native inference (now fails closed), and the ACP path's mode-set was made fail-closed too. The UI adds a fourth `orchestrator` pane kind (collapsed agent roster, expand-one live stream, report on top), a Workflow button + slash flows on the shared `ChatInput` for full BooChat/BooCoder parity, a "New Orchestrator" entry in the + and split menus, a category-grouped launcher dialog, runs history, and export (copy / save-to-file / send-to-chat) — fed by two new `flow_run_*` WS frames on a coder user channel. Qwen-only by design (Claude Code remains the Claude path); the existing model-competition Arena stays a separate feature. The flow launcher and the `/` slash menu both carry chevron-expandable per-item explanations (an always-on one-liner expands to a 12 sentence what-it-does / when-to-use blurb, condensed from each Han skill's own description), with a "read-only" pill pinned in the launcher and the fast/concise toggle wired through to the workers. Spec/plan in `openspec/changes/orchestrator`; coder 373 tests green (42 new scheduler/resume/read-only decision tests), contracts/coder/server builds + web tsc clean. Built on `v2.7.16-container-git-safedir`; pairs conceptually with the earlier `v2.7.12-audit-cleanup` multi-agent orchestration.

View File

@@ -74,7 +74,7 @@ Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ...
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only add-existing scope), `BOOTSTRAP_ROOT` (/opt/projects, writable bootstrap mkdir target — host must `mkdir -p` it before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale; the public host is behind Authelia, unusable from server context), `BOOCODE_TOOLS` (`core`|`standard`|`all`, default `all`; a ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (default `/data/mcp.json`, opencode `mcpServers` shape; missing = no MCP), `CONTEXT7_API_KEY` (the Context7 MCP key, referenced from `data/mcp.json` as `"{env:CONTEXT7_API_KEY}"`). `data/mcp.json` is **gitignored** but no longer holds secrets — string values support opencode-style `{env:VAR}` substitution (`mcp-config.ts:substituteEnvVars`, applied before Zod validation; unset var → `''` + warn), so real keys live in `.env`; template `data/mcp.example.json`. A config-only edit there needs only `docker compose restart boocode` (data/ is bind-mounted); changing a referenced secret edits `.env`. MCP loads at server startup with per-server graceful degradation; the coder does NOT load MCP (BooChat only).
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Its env file `apps/coder/.env.host` is gitignored (`.env.*`, with `!.env.example`) — a fresh host recreates it from `.env.example` (incl. `CLAUDE_SDK_BACKEND=1` for the Claude Agent-SDK backend). Deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
- `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL. Set to a small llama-swap model (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls.
- Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — `-p` runs autonomously without prompts. ACP bridge is an HTTP daemon (not stdio); use PTY dispatch.

View File

@@ -1,9 +1,9 @@
# Current focus
Last updated: 2026-06-02
Last updated: 2026-06-05
- **Last shipped:** `v2.7.8-ember-coder-tabs-model-chips` (2026-06-01)
- **Branch:** `codebase-audit-cleanup` (audit + cleanup epic, off main HEAD)
- **In progress:** Phase 3 — stale comments + docs refresh
- **Last shipped:** `v2.7.18-permission-modes` (2026-06-05) — unified Plan/Ask/Bypass permission picker in the BooCoder composer (incl. native-BooCode auto-apply on Bypass).
- **Branch:** `main`
- **In progress:** nothing committed — dogfooding the Orchestrator to surface the next real backlog. Claude Agent-SDK backend enabled (`CLAUDE_SDK_BACKEND`). Optional/exploratory: verify-gate ensembler over pending changes.
See `CHANGELOG.md` for the full shipped history. That file is always authoritative; this file is a quick orientation pointer only.

View File

@@ -1,10 +1,10 @@
# boocode
Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals).
Self-hosted single-user developer chat app. 3-app monorepo: BooChat (read-only chat), BooCoder (write tools + agent dispatch), BooTerm (PTY terminals) — plus the in-app **Orchestrator**, a deterministic multi-agent conductor that runs read-only Han analysis/review flows on local Qwen.
**Latest release:** `v2.2.1-pane-scoped-chats` (2026-05-26) · [`CHANGELOG.md`](CHANGELOG.md) · **Current focus:** [`CURRENT.md`](CURRENT.md)
**Latest release:** `v2.7.17-orchestrator` (2026-06-03) · [`CHANGELOG.md`](CHANGELOG.md) · **Current focus:** [`CURRENT.md`](CURRENT.md)
**Agent navigation:** [`AGENTS.md`](AGENTS.md) · **Architecture:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · **Engineering reference:** [`CLAUDE.md`](CLAUDE.md)
**Architecture:** [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) · **Engineering reference:** [`CLAUDE.md`](CLAUDE.md) · **Roadmap:** [`boocode_roadmap.md`](boocode_roadmap.md)
## Stack
@@ -75,15 +75,16 @@ curl http://100.114.205.53:9502/api/health
## What's shipped
See [`boocode_roadmap.md`](boocode_roadmap.md) for full version history. Highlights as of **v2.2.1**:
See [`boocode_roadmap.md`](boocode_roadmap.md) and [`CHANGELOG.md`](CHANGELOG.md) for full version history. Highlights as of **v2.7.17**:
- **BooChat**: streaming chat, file-read tools, compaction, reasoning support, HTML/Markdown artifact panes, cross-repo read grants, MCP client (multi-server + stdio), tool-cost tracking, skills system, builtin agent registry, multi-pane workspace (chat / terminal / coder)
- **BooChat**: streaming chat, file-read tools, compaction, reasoning support, HTML/Markdown artifact panes, cross-repo read grants, MCP client (multi-server + stdio), tool-cost tracking, skills system, builtin agent registry, multi-pane workspace (chat / terminal / coder / orchestrator)
- **BooTerm**: in-browser terminal panes via tmux + xterm.js, per-session tmux sessions, SSH-out support
- **BooCoder (v2.2)**: write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`), pending-changes queue with diff UI, Paseo-style provider snapshot (7 providers: boocode, cursor, claude, opencode, goose, qwen, copilot), `AgentComposerBar` (provider / mode / model / thinking), ACP dispatch with inline permission prompts + tool/reasoning streaming, PTY fallback, Arena, MCP server (6 tools, stdio), CLI client, human inbox, Boomerang orchestration, path-guard fuzz suite, **pane-scoped chats** (v2.2.1 — each coder/terminal pane owns its chat)
- **BooCoder**: write tools (`edit_file` with fuzzy matching, `create_file`, `delete_file`, `apply_pending`, `rewind`, git-ref checkpoints), pending-changes queue + a **Files/Git diff panel** (stage / commit / discard), provider snapshot (5 providers: boocode, claude, opencode, goose, qwen — cursor/copilot retired), `AgentComposerBar`, warm ACP + **persistent agent sessions** (opencode HTTP server; claude via the Agent SDK with native session resume) + PTY fallback, config-backed provider lifecycle, Arena (same task → N models), MCP server, CLI client, human inbox, Boomerang orchestration, pane-scoped chats
- **Orchestrator** (v2.7.17): launch any of 22 read-only Han flows (research, code-review, investigate, architectural-analysis, …) from BooChat or BooCoder via the Workflow button, a slash command, or **+ menu → New Orchestrator**; each step runs as a bounded agent on local Qwen (hard read-only via `qwen --approval-mode plan`), streaming live in a Paseo-style run pane with an evidence-disciplined, adversarially-validated report. Persisted + resumable. `@boocode/contracts` single-sources the cross-app wire contracts (v2.7.13).
## Planned
- **v2.3 provider lifecycle** — config-backed provider registry (`/data/coder-providers.json`), enable/disable toggles, two-tier probe (openspec drafted). See [`CURRENT.md`](CURRENT.md).
Most prior roadmap milestones have shipped (see [`boocode_roadmap.md`](boocode_roadmap.md)). What remains is optional/exploratory — e.g. a verify-gate ensembler over pending changes (majority-vote diff ranking). No committed milestones currently in flight.
## License

View File

@@ -1,17 +0,0 @@
NODE_ENV=production
PORT=9502
HOST=100.114.205.53
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boochat
LLAMA_SWAP_URL=http://100.101.41.16:8401
PROJECT_ROOT_WHITELIST=/opt
BOOTSTRAP_ROOT=/opt/projects
DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4
LOG_LEVEL=info
SEARXNG_URL=http://100.114.205.53:8888
GITEA_BASE_URL=https://git.indifferentketchup.com
GITEA_USER=indifferentketchup
GITEA_SSH_HOST=100.114.205.53:2222
MCP_CONFIG_PATH=/data/mcp.json
SKILLS_ROOT=/opt/boocode/data/skills
CODER_PROVIDERS_PATH=/opt/boocode/data/coder-providers.json
CLAUDE_SDK_BACKEND=1

View File

@@ -32,3 +32,8 @@
- **Claude SDK backend tool RESULTS arrive as `type:'user'` SDK messages** (tool_result content blocks): `mapSdkMessage` (`claude-sdk-map.ts`) MUST map the `user` case → a terminal `tool_update` (completed/failed + output), else the tool_call persists `status:'running'` and the UI spinner never stops. The dispatcher's `tool_update` path then publishes + persists it.
- **ACP command discovery is async**: `acp-probe.ts` must poll after `newSession` for `available_commands_update` (commands arrive in a later notification; reading synchronously captures 0). PTY providers (claude) discover from disk via `claude-command-discovery.ts` (`~/.claude/commands` + `enabledPlugins`, bare names, deduped). `AgentCommand.kind` tags `'command'` vs `'skill'`; `CoderPane`'s `slashGroups` splits them into icon'd groups. `SlashCommandPicker`'s `groups?` prop is opt-in.
- **A new per-message coder field silently drops unless you update every mapper**: the HTTP read SELECT + `mapCoderMessageRow` (`apps/coder/src/routes/messages.ts`), **the WS `snapshot` SELECT (`apps/coder/src/routes/ws.ts`)** — it has its OWN column list and the client's `snapshot` handler `setMessages`-overwrites the HTTP load, so a field present in the HTTP route but absent here shows live yet vanishes on refresh — `CoderPane.tsx` (`RawCoderMessage`/`CoderMessage`/`mapCoderTimelineRow` + the live `message_complete` WS reducer), `CoderMessageWire` (`CoderMessageList.tsx`), and `api/types.ts`. The client `mapCoderTimelineRow` whitelists fields — easiest to forget. This bit `model` twice: the client chain (`v2.7.9`) and then the WS snapshot SELECT (`v2.7.11`) — the chip showed live but vanished on coder refresh until both were fixed.
## Orchestrator (v2.7.17)
- **In-app multi-agent conductor**: `services/flow-runner.ts` runs a flow by inserting each step as a `tasks` row (the existing dispatcher runs it) and advancing on a new `onTaskTerminal` dispatcher-deps hook; persisted in `flow_runs`/`flow_steps` (resumed at startup via `initResume`). The 22 conductor flow defs + Spine factory are re-homed under `src/conductor/`. Pure scheduler/resume helpers in `flow-runner-decisions.ts`. Full design: `openspec/changes/archived/orchestrator/`.
- **Read-only is load-bearing — don't add a dispatch path that bypasses it.** Every step dispatches `agent='qwen', mode_id='plan'`; `dispatcher.ts` force-routes qwen+plan to the PTY `--approval-mode plan` gate and HARD-FAILS the task (never falls to write-capable native inference) when qwen is unavailable (`shouldFailOnMissingAgent`). `BOOCODE_TOOLS` gates BooChat's NATIVE inference tools only — it does NOT govern an external CLI agent (qwen/opencode bring their own write tools); read-only for a dispatched agent is the agent-layer mode (PTY `--approval-mode plan`; ACP `setSessionMode` is fail-OPEN by default, fail-CLOSED for `plan` via `READ_ONLY_MODE_IDS` in `acp-dispatch.ts`).

View File

@@ -4,6 +4,7 @@ import type { Sql } from '../db.js';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import { resolveChatId } from './chat-resolve.js';
import { applyAll } from '../services/pending_changes.js';
const AnswerUserInputBody = z.object({
tool_call_id: z.string().min(1),
@@ -247,6 +248,35 @@ export function registerMessageRoutes(
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default');
// Bypass permission mode (native BooCode): auto-apply staged edits to disk
// once the turn settles. `enqueue` registers synchronously, so hasActive is
// true immediately; poll until it clears, apply, then re-publish
// message_complete so the DiffPanel reflects the now-applied (non-pending)
// state. Best-effort — failures stay in the pending queue for manual apply.
if (mode_id === 'bypass') {
const projectId = sessionRows[0]!.project_id;
const assistantId = assistantMsg!.id;
void (async () => {
try {
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${projectId}`;
if (!proj?.path) return;
for (let i = 0; i < 1200 && inference.hasActive(chatId); i++) {
await new Promise((r) => setTimeout(r, 1000));
}
const applied = await applyAll(sql, sessionId, proj.path);
if (applied.length > 0) {
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: assistantId,
chat_id: chatId,
} as unknown as WsFrame);
}
} catch {
/* best-effort auto-apply — leave staged changes for manual apply */
}
})();
}
reply.code(202);
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
},

View File

@@ -68,11 +68,18 @@ export function deriveModesFromACP(
): { modes: ProviderMode[]; currentModeId: string | null } {
if (modeState?.availableModes?.length) {
return {
modes: modeState.availableModes.map((mode) => ({
id: mode.id,
label: mode.name,
description: mode.description ?? undefined,
})),
// ACP omits the unattended flag; inherit it from the manifest fallback by
// id so the unified permission picker can still detect each agent's bypass
// mode (e.g. opencode `full-access`) from live-probed modes.
modes: modeState.availableModes.map((mode) => {
const fb = fallbackModes.find((f) => f.id === mode.id);
return {
id: mode.id,
label: mode.name,
description: mode.description ?? undefined,
...(fb?.isUnattended ? { isUnattended: true } : {}),
};
}),
currentModeId: modeState.currentModeId ?? null,
};
}

View File

@@ -12,12 +12,48 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import type { AgentCommand } from './provider-types.js';
/** Minimal frontmatter reader — single-line `key: value` between `---` fences. */
/**
* Frontmatter reader between `---` fences. Handles single-line `key: value`
* AND YAML block scalars (`key: >` folded / `key: |` literal) whose value
* spans the following more-indented lines — the shape most plugin SKILL.md
* descriptions use (`description: >`).
*/
function frontmatterField(content: string, field: string): string | undefined {
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!block?.[1]) return undefined;
const m = block[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
return m?.[1]?.trim().replace(/^["']|["']$/g, '') || undefined;
const lines = block[1].split(/\r?\n/);
const keyRe = new RegExp(`^(\\s*)${field}:\\s*(.*)$`);
for (let i = 0; i < lines.length; i++) {
const m = lines[i]?.match(keyRe);
if (!m) continue;
const keyIndent = (m[1] ?? '').length;
const inline = (m[2] ?? '').trim();
// Block scalar: `>` (folded) or `|` (literal), optional chomping `+`/`-`.
if (/^[>|][+-]?$/.test(inline)) {
const folded = inline[0] === '>';
const body: string[] = [];
for (let j = i + 1; j < lines.length; j++) {
const line = lines[j] ?? '';
if (line.trim() === '') {
body.push('');
continue;
}
const indent = line.length - line.trimStart().length;
if (indent <= keyIndent) break; // dedent ends the block
body.push(line.slice(keyIndent + 1));
}
const joined = folded
? body
.map((l) => l.trim())
.join(' ')
.replace(/\s+/g, ' ')
.trim()
: body.join('\n').replace(/\n+$/, '');
return joined || undefined;
}
return inline.replace(/^["']|["']$/g, '').trim() || undefined;
}
return undefined;
}
function readCommandDir(dir: string): AgentCommand[] {

View File

@@ -4,6 +4,7 @@ import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/contracts/ws-frames';
import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
import { applyAll } from './pending_changes.js';
import { createCheckpoint } from './checkpoints.js';
import { makeDcpStreamStripper } from './dcp-strip.js';
import { dispatchViaAcp } from './acp-dispatch.js';
@@ -305,7 +306,7 @@ export function createDispatcher(deps: Deps): {
// ─── Path A: Native Inference ───────────────────────────────────────────────
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }): Promise<void> {
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; mode_id: string | null; session_id: string | null }): Promise<void> {
const taskId = task.id;
log.info({ taskId }, 'dispatcher: starting task (path A — native)');
@@ -385,6 +386,22 @@ export function createDispatcher(deps: Deps): {
WHERE id = ${taskId}
`;
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
// Bypass permission mode: auto-apply the staged edits to disk after the
// turn. Ask/Plan leave them in the pending-changes queue for review.
if (task.mode_id === 'bypass') {
try {
const [proj] = await sql<{ path: string }[]>`SELECT path FROM projects WHERE id = ${task.project_id}`;
if (proj?.path) {
const applied = await applyAll(sql, sessionId, proj.path);
log.info({ taskId, applied: applied.length }, 'dispatcher: native bypass auto-applied pending changes');
}
} catch (applyErr) {
log.warn(
{ taskId, err: applyErr instanceof Error ? applyErr.message : String(applyErr) },
'dispatcher: native bypass auto-apply failed',
);
}
}
} else {
const [msg] = await sql<{ content: string | null }[]>`
SELECT content FROM messages WHERE id = ${assistantId}

View File

@@ -32,6 +32,18 @@ const QWEN_PTY_MODES: ProviderMode[] = [
{ id: 'yolo', label: 'YOLO', description: 'Auto-approve all tools', isUnattended: true },
];
// Native BooCode (llama-swap) has no agent-native mode vocabulary, so we define
// one that matches the unified permission ladder. `bypass` is the only mode that
// changes behavior (auto-apply staged edits after the turn — dispatcher.ts);
// `plan` falls back to `ask` semantics for native (writes still stage to the
// pending-changes queue). External agents map the same three unified modes onto
// THEIR native ids via the `plan`-id / default / `isUnattended` shape.
const BOOCODE_MODES: ProviderMode[] = [
{ id: 'plan', label: 'Plan', description: 'Read-only analysis (native BooCode falls back to Ask)' },
{ id: 'ask', label: 'Ask Permission', description: 'Stage edits to the pending-changes queue for review' },
{ id: 'bypass', label: 'Bypass', description: 'Auto-apply edits to disk after the turn', isUnattended: true },
];
const CLAUDE_THINKING = [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
@@ -41,6 +53,10 @@ const CLAUDE_THINKING = [
];
export const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> = {
boocode: {
defaultModeId: 'ask',
modes: BOOCODE_MODES,
},
claude: {
defaultModeId: 'default',
modes: CLAUDE_MODES,

View File

@@ -122,12 +122,14 @@ async function buildProviderEntry(
};
}
// 2. Native boocode → always ready (llama-swap models).
// 2. Native boocode → always ready (llama-swap models). Exposes the unified
// permission modes (plan/ask/bypass) so the composer's permission picker works
// for native BooCode too; `bypass` auto-applies staged edits (dispatcher.ts).
if (isNative) {
return {
name, label: resolved.label, transport, status: 'ready',
enabled: true, installed: true, models: withConfigModels(llamaModels), modes: [],
defaultModeId: null, commands: manifestCommands,
enabled: true, installed: true, models: withConfigModels(llamaModels),
modes: fallbackModes, defaultModeId, commands: manifestCommands,
};
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bot } from 'lucide-react';
import { Check, ChevronDown, RefreshCw, Loader2, Shield, ShieldAlert, Eye, Brain, Bot } from 'lucide-react';
import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
@@ -14,8 +14,22 @@ import {
import { BottomSheet } from '@/components/BottomSheet';
import { useViewport } from '@/hooks/useViewport';
import { formatModelLabel } from '@/lib/model-label';
import {
availablePermissionModes,
permissionForModeId,
nativeModeForPermission,
type PermissionMode,
} from '@/lib/permission-mode';
import { cn } from '@/lib/utils';
// Permission picker icon — varies with the active mode so the (icon-only) control
// is glanceable: Eye = Plan (read-only), Shield = Ask, ShieldAlert = Bypass.
function permissionIcon(mode: PermissionMode): React.ReactNode {
if (mode === 'plan') return <Eye className="size-3 shrink-0" />;
if (mode === 'bypass') return <ShieldAlert className="size-3 shrink-0 text-amber-500" />;
return <Shield className="size-3 shrink-0" />;
}
const PREFS_KEY = 'boocode.coder.agent-prefs';
@@ -350,7 +364,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
}
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
// Unified permission ladder (Plan / Ask / Bypass) mapped onto this provider's
// native modes. `value.modeId` stays the wire field; the active unified mode is
// derived from it.
const permissionModes = availablePermissionModes(currentEntry?.modes ?? []);
const currentPermission = permissionForModeId(value.modeId, currentEntry?.modes ?? []);
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: formatModelLabel(m.label) }));
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
@@ -380,15 +398,25 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
</>
}
/>
{/* Mode (shield) only when the provider actually exposes modes. Native
BooCoder has none, so it's hidden rather than shown disabled. */}
{modeOptions.length > 0 && (
{/* Permission ladder (Plan / Ask / Bypass) — shown when the provider exposes
modes. Picks the unified mode; we resolve it to the provider's native
modeId. Icon varies with the active mode (Bypass is amber). */}
{permissionModes.length > 0 && (
<CompactPicker
label="Mode"
value={value.modeId ?? ''}
options={modeOptions}
onPick={(modeId) => persist({ ...value, modeId })}
icon={<Shield className="size-3 shrink-0" />}
label="Permission"
value={currentPermission}
options={permissionModes}
onPick={(perm) =>
persist({
...value,
modeId: nativeModeForPermission(
perm as PermissionMode,
currentEntry?.modes ?? [],
currentEntry?.defaultModeId ?? null,
),
})
}
icon={permissionIcon(currentPermission)}
iconOnly
/>
)}

View File

@@ -225,7 +225,7 @@ export function SlashCommandPicker({
setHighlightIndex(i);
setExpandedIndex((prev) => (prev === i ? null : i));
}}
className="-mr-1 -mt-0.5 flex shrink-0 items-center justify-center rounded p-1 text-muted-foreground/60 transition-colors hover:bg-foreground/10 hover:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
className="-mr-1 -mt-0.5 flex shrink-0 items-center justify-center rounded-md border border-border bg-background p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground max-md:min-h-[36px] max-md:min-w-[36px]"
>
<ChevronRight
className={cn(

View File

@@ -0,0 +1,55 @@
// Unified permission ladder shown in the composer's permission picker. Maps a
// curated three-option control (Plan / Ask Permission / Bypass) onto each
// provider's native mode vocabulary, derived purely from the snapshot's mode
// metadata (`plan` id, the default mode, and the `isUnattended` bypass mode).
// `modeId` stays the single wire field sent to the dispatcher — there is no
// separate persisted permission field; the active unified mode is derived from
// the current `modeId`.
import type { ProviderMode } from '@/api/types';
export type PermissionMode = 'plan' | 'ask' | 'bypass';
export const PERMISSION_LABELS: Record<PermissionMode, string> = {
plan: 'Plan',
ask: 'Ask Permission',
bypass: 'Bypass',
};
/** The native modeId for a unified permission, or null when the provider has no
* modes (e.g. goose). `plan` → the `plan`-id mode; `bypass` → the `isUnattended`
* mode; `ask` → the non-unattended default. Falls back to defaultModeId. */
export function nativeModeForPermission(
mode: PermissionMode,
modes: ProviderMode[],
defaultModeId: string | null,
): string | null {
if (modes.length === 0) return null;
if (mode === 'plan') return modes.find((m) => m.id === 'plan')?.id ?? defaultModeId;
if (mode === 'bypass') return modes.find((m) => m.isUnattended)?.id ?? defaultModeId;
return (
modes.find((m) => m.id === defaultModeId && !m.isUnattended)?.id ??
modes.find((m) => !m.isUnattended && m.id !== 'plan')?.id ??
defaultModeId
);
}
/** Which unified permission a native modeId corresponds to (for picker state). */
export function permissionForModeId(modeId: string | null, modes: ProviderMode[]): PermissionMode {
if (!modeId) return 'ask';
if (modeId === 'plan') return 'plan';
if (modes.find((m) => m.id === modeId)?.isUnattended) return 'bypass';
return 'ask';
}
/** The unified permission options a provider supports, in fixed Plan→Ask→Bypass
* order. Empty when the provider exposes no modes (no picker shown). */
export function availablePermissionModes(
modes: ProviderMode[],
): Array<{ id: PermissionMode; label: string }> {
if (modes.length === 0) return [];
const out: Array<{ id: PermissionMode; label: string }> = [];
if (modes.some((m) => m.id === 'plan')) out.push({ id: 'plan', label: PERMISSION_LABELS.plan });
out.push({ id: 'ask', label: PERMISSION_LABELS.ask });
if (modes.some((m) => m.isUnattended)) out.push({ id: 'bypass', label: PERMISSION_LABELS.bypass });
return out;
}

View File

@@ -1,9 +1,11 @@
# BooCode roadmap (v1.xv2.x)
Last updated: 2026-05-31
Last updated: 2026-06-03
> **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.
> **Shipped since this doc's body was written (v2.7.12v2.7.17, 2026-06-02→03; see `CHANGELOG.md` for detail):** `v2.7.12-audit-cleanup` (repo-wide dead-code/dedup pass, ~4,600 LOC), `v2.7.13-contracts-ssot` (the `@boocode/contracts` shared wire-contract package — the "unified types" deferred item), `v2.7.14-backlog-hardening` (5 v2-review items incl. external task-cancel, stall-timeout, retire `:9502` SPA), `v2.7.15-git-diff-panel` + `v2.7.16-container-git-safedir` (Files/Git tab), and `v2.7.17-orchestrator` (the in-app multi-agent Orchestrator on local Qwen). The "Write/edit robustness" and "Claude provider SDK" milestones below — previously marked "planned" — are also now shipped (see those sections).
## Overview
BooCode is a **3-app monorepo** at `/opt/boocode/` (locked 2026-05-22):
@@ -452,9 +454,9 @@ The original plan (kept for record): expose `boocoder acp` (JSON-RPC over stdio)
-----
## Write/edit robustness (planned)
## Write/edit robustness — SHIPPED
**Status: planned, not started.** From the v2 review (`boocode_code_review_v2.md` §5b; `cline/cline`, Apache-2.0 — code-liftable). Two lifts that harden BooCoder's write surface where it's weakest for local quantized models:
**Status: SHIPPED (by v2.7.x).** Both lifts are live: the fuzzy patch applier (`apps/coder/src/services/fuzzy-match.ts`, consumed by `pending_changes.ts``edit_file` is no longer exact-match) and the `git`-ref checkpoint snapshot (`apps/coder/src/services/checkpoints.ts``createCheckpoint`, private `refs/boocode/checkpoints/<id>` ref). The original "planned" note below is retained for provenance. From the v2 review (`boocode_code_review_v2.md` §5b; `cline/cline`, Apache-2.0 — code-liftable). Two lifts that harden BooCoder's write surface where it's weakest for local quantized models:
1. **Fuzzy patch applier for `edit_file`.** BooCoder's `edit_file` is exact-match today (`apps/coder/src/services/pending_changes.ts``if (!content.includes(oldStr)) throw`; no whitespace/unicode tolerance, no multi-occurrence guard). Lift cline's tiered match ladder (exact → `trimEnd``trim` → Levenshtein ≥0.66) + unicode canonicalization (dashes, curly quotes, nbsp) + multi-occurrence guard; unmatched → warning, not throw. `apply-patch-parser.ts:347-431`.
2. **`git stash create` + private-ref checkpoint.** A per-turn workspace snapshot that captures **all** state — including edits made by dispatched external agents (opencode/claude/qwen/goose), build artifacts, test side-effects — which BooCoder's current `rewind` cannot (it only reverse-applies BooCoder's own queued `pending_changes`). Snapshot stored under a private `refs/…/checkpoints/…` ref, restorable with conversation-trim in sync. `checkpoint-hooks.ts:177-253`.
@@ -463,9 +465,9 @@ The original plan (kept for record): expose `boocoder acp` (JSON-RPC over stdio)
-----
## Claude provider — SDK transport + native session resume (planned)
## Claude provider — SDK transport + native session resume — SHIPPED (enabled 2026-06-03)
**Status: planned, not started.** From the v2 review (`boocode_code_review_v2.md` §5h§5i) + a direct read of the published SDK `.d.ts` (`@anthropic-ai/claude-agent-sdk@0.3.158`, reviewed 2026-05-31). Today BooCoder dispatches `claude` one-shot via PTY (`claude --output-format stream-json`) with no continuity. Plan:
**Status: BUILT and ENABLED.** The Agent-SDK backend (`apps/coder/src/services/backends/claude-sdk.ts`) and the `PostgresSessionStore` (`claude-session-store.ts`, keyed `(chat_id, agent)`) are implemented; it was shipped behind the `CLAUDE_SDK_BACKEND` env flag (off by default in code) and is **enabled in `apps/coder/.env.host` (`CLAUDE_SDK_BACKEND=1`, confirmed live in the running host service)** — chat-tab `claude` tasks route through the warm SDK backend with native session resume instead of one-shot PTY. The original "planned" note below is retained for provenance. From the v2 review (`boocode_code_review_v2.md` §5h§5i) + a direct read of the published SDK `.d.ts` (`@anthropic-ai/claude-agent-sdk@0.3.158`, reviewed 2026-05-31). Today BooCoder dispatches `claude` one-shot via PTY (`claude --output-format stream-json`) with no continuity. Plan:
1. **Adopt the Agent SDK** (`@anthropic-ai/claude-agent-sdk`) over the PTY path. `query({ prompt, options })` yields structured `SDKMessage`s — `SDKSystemMessage` (`subtype:'init'`, carries the session id + tool/skill/mcp lists), `SDKPartialAssistantMessage` (`type:'stream_event'` deltas), `SDKResultMessage` (turn end) — no stdout scraping. `happy` (`slopus/happy`) is the working existence-proof.
2. **Native session resume via a pluggable `SessionStore`.** Implement `PostgresSessionStore implements SessionStore` (5 methods: `append`/`load`/`listSessions`/`delete`/`listSubkeys`) over BooCode's Postgres, keyed by `(chat_id, agent)`; drive turns with `query({ options: { sessionStore, resume } })` and the SDK materializes the stored session for the CLI subprocess. **This supersedes happy's SessionStart-hook + jsonl-watcher** — that was a workaround predating the feature (happy pins SDK `^0.2.96`; the `SessionStore` API is `0.3.x`). `importSessionToStore()` migrates an existing local session; `InMemorySessionStore` is the reference shape.

View File

@@ -1,5 +1,7 @@
# Deferred work — post stale cleanup (2026-05-26)
> **⚠️ SUPERSEDED (2026-06-03): most items in this doc have since shipped.** Task cancel → abort ACP/PTY child (v2.7.14), unified `packages/types` (v2.7.13 `@boocode/contracts`), retire `apps/coder/web/` fallback SPA (v2.7.14), `console.debug`→pino in the xml-parser (v2.7.14), and the large-file splits (v2.7.12) are all done; the ACP cold-probe skip shipped earlier (v2.3). Treat this doc as historical — see `CHANGELOG.md` (v2.7.12v2.7.17) for what actually shipped. Kept for the design rationale in the detail sections below.
This document describes work intentionally **not** shipped in the 2026-05-26 stale/simplify batch. Each item needs a product or architecture decision before implementation. See also [`STALE-DEPRECATED.md`](./STALE-DEPRECATED.md) for what was resolved in that batch.
Last updated: 2026-05-29