Compare commits
7 Commits
v2.7.17-or
...
v2.7.18-pe
| Author | SHA1 | Date | |
|---|---|---|---|
| e04d0fdaa8 | |||
| da36344d0b | |||
| 875cae0843 | |||
| 4caa5f91ff | |||
| 1d416d0cf9 | |||
| bfda61e27e | |||
| a734615480 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# Claude / Cursor (local agent & IDE config — CLAUDE.md and AGENTS.md stay tracked)
|
# Claude / Cursor (local agent & IDE config — CLAUDE.md and AGENTS.md stay tracked)
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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 1–2 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.
|
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 1–2 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.
|
||||||
|
|||||||
@@ -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).
|
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.
|
- `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.
|
- 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.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Current focus
|
# 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)
|
- **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:** `codebase-audit-cleanup` (audit + cleanup epic, off main HEAD)
|
- **Branch:** `main`
|
||||||
- **In progress:** Phase 3 — stale comments + docs refresh
|
- **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.
|
See `CHANGELOG.md` for the full shipped history. That file is always authoritative; this file is a quick orientation pointer only.
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -1,10 +1,10 @@
|
|||||||
# boocode
|
# 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
|
## Stack
|
||||||
|
|
||||||
@@ -75,15 +75,16 @@ curl http://100.114.205.53:9502/api/health
|
|||||||
|
|
||||||
## What's shipped
|
## 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
|
- **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
|
## 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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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.
|
- **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.
|
- **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.
|
- **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`).
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Sql } from '../db.js';
|
|||||||
import type { Broker } from '@boocode/server/broker';
|
import type { Broker } from '@boocode/server/broker';
|
||||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||||
import { resolveChatId } from './chat-resolve.js';
|
import { resolveChatId } from './chat-resolve.js';
|
||||||
|
import { applyAll } from '../services/pending_changes.js';
|
||||||
|
|
||||||
const AnswerUserInputBody = z.object({
|
const AnswerUserInputBody = z.object({
|
||||||
tool_call_id: z.string().min(1),
|
tool_call_id: z.string().min(1),
|
||||||
@@ -247,6 +248,35 @@ export function registerMessageRoutes(
|
|||||||
|
|
||||||
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default');
|
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);
|
reply.code(202);
|
||||||
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -68,11 +68,18 @@ export function deriveModesFromACP(
|
|||||||
): { modes: ProviderMode[]; currentModeId: string | null } {
|
): { modes: ProviderMode[]; currentModeId: string | null } {
|
||||||
if (modeState?.availableModes?.length) {
|
if (modeState?.availableModes?.length) {
|
||||||
return {
|
return {
|
||||||
modes: modeState.availableModes.map((mode) => ({
|
// 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,
|
id: mode.id,
|
||||||
label: mode.name,
|
label: mode.name,
|
||||||
description: mode.description ?? undefined,
|
description: mode.description ?? undefined,
|
||||||
})),
|
...(fb?.isUnattended ? { isUnattended: true } : {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
currentModeId: modeState.currentModeId ?? null,
|
currentModeId: modeState.currentModeId ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,48 @@ import { homedir } from 'node:os';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { AgentCommand } from './provider-types.js';
|
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 {
|
function frontmatterField(content: string, field: string): string | undefined {
|
||||||
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||||
if (!block?.[1]) return undefined;
|
if (!block?.[1]) return undefined;
|
||||||
const m = block[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
|
const lines = block[1].split(/\r?\n/);
|
||||||
return m?.[1]?.trim().replace(/^["']|["']$/g, '') || undefined;
|
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[] {
|
function readCommandDir(dir: string): AgentCommand[] {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Broker } from '@boocode/server/broker';
|
|||||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
|
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
|
||||||
|
import { applyAll } from './pending_changes.js';
|
||||||
import { createCheckpoint } from './checkpoints.js';
|
import { createCheckpoint } from './checkpoints.js';
|
||||||
import { makeDcpStreamStripper } from './dcp-strip.js';
|
import { makeDcpStreamStripper } from './dcp-strip.js';
|
||||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||||
@@ -305,7 +306,7 @@ export function createDispatcher(deps: Deps): {
|
|||||||
|
|
||||||
// ─── Path A: Native Inference ───────────────────────────────────────────────
|
// ─── 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;
|
const taskId = task.id;
|
||||||
log.info({ taskId }, 'dispatcher: starting task (path A — native)');
|
log.info({ taskId }, 'dispatcher: starting task (path A — native)');
|
||||||
|
|
||||||
@@ -385,6 +386,22 @@ export function createDispatcher(deps: Deps): {
|
|||||||
WHERE id = ${taskId}
|
WHERE id = ${taskId}
|
||||||
`;
|
`;
|
||||||
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
|
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 {
|
} else {
|
||||||
const [msg] = await sql<{ content: string | null }[]>`
|
const [msg] = await sql<{ content: string | null }[]>`
|
||||||
SELECT content FROM messages WHERE id = ${assistantId}
|
SELECT content FROM messages WHERE id = ${assistantId}
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ const QWEN_PTY_MODES: ProviderMode[] = [
|
|||||||
{ id: 'yolo', label: 'YOLO', description: 'Auto-approve all tools', isUnattended: true },
|
{ 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 = [
|
const CLAUDE_THINKING = [
|
||||||
{ id: 'low', label: 'Low' },
|
{ id: 'low', label: 'Low' },
|
||||||
{ id: 'medium', label: 'Medium' },
|
{ id: 'medium', label: 'Medium' },
|
||||||
@@ -41,6 +53,10 @@ const CLAUDE_THINKING = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> = {
|
export const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> = {
|
||||||
|
boocode: {
|
||||||
|
defaultModeId: 'ask',
|
||||||
|
modes: BOOCODE_MODES,
|
||||||
|
},
|
||||||
claude: {
|
claude: {
|
||||||
defaultModeId: 'default',
|
defaultModeId: 'default',
|
||||||
modes: CLAUDE_MODES,
|
modes: CLAUDE_MODES,
|
||||||
|
|||||||
@@ -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) {
|
if (isNative) {
|
||||||
return {
|
return {
|
||||||
name, label: resolved.label, transport, status: 'ready',
|
name, label: resolved.label, transport, status: 'ready',
|
||||||
enabled: true, installed: true, models: withConfigModels(llamaModels), modes: [],
|
enabled: true, installed: true, models: withConfigModels(llamaModels),
|
||||||
defaultModeId: null, commands: manifestCommands,
|
modes: fallbackModes, defaultModeId, commands: manifestCommands,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
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 { api } from '@/api/client';
|
||||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||||
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||||
@@ -14,8 +14,22 @@ import {
|
|||||||
import { BottomSheet } from '@/components/BottomSheet';
|
import { BottomSheet } from '@/components/BottomSheet';
|
||||||
import { useViewport } from '@/hooks/useViewport';
|
import { useViewport } from '@/hooks/useViewport';
|
||||||
import { formatModelLabel } from '@/lib/model-label';
|
import { formatModelLabel } from '@/lib/model-label';
|
||||||
|
import {
|
||||||
|
availablePermissionModes,
|
||||||
|
permissionForModeId,
|
||||||
|
nativeModeForPermission,
|
||||||
|
type PermissionMode,
|
||||||
|
} from '@/lib/permission-mode';
|
||||||
import { cn } from '@/lib/utils';
|
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';
|
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 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 modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: formatModelLabel(m.label) }));
|
||||||
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.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
|
{/* Permission ladder (Plan / Ask / Bypass) — shown when the provider exposes
|
||||||
BooCoder has none, so it's hidden rather than shown disabled. */}
|
modes. Picks the unified mode; we resolve it to the provider's native
|
||||||
{modeOptions.length > 0 && (
|
modeId. Icon varies with the active mode (Bypass is amber). */}
|
||||||
|
{permissionModes.length > 0 && (
|
||||||
<CompactPicker
|
<CompactPicker
|
||||||
label="Mode"
|
label="Permission"
|
||||||
value={value.modeId ?? ''}
|
value={currentPermission}
|
||||||
options={modeOptions}
|
options={permissionModes}
|
||||||
onPick={(modeId) => persist({ ...value, modeId })}
|
onPick={(perm) =>
|
||||||
icon={<Shield className="size-3 shrink-0" />}
|
persist({
|
||||||
|
...value,
|
||||||
|
modeId: nativeModeForPermission(
|
||||||
|
perm as PermissionMode,
|
||||||
|
currentEntry?.modes ?? [],
|
||||||
|
currentEntry?.defaultModeId ?? null,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
icon={permissionIcon(currentPermission)}
|
||||||
iconOnly
|
iconOnly
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export function SlashCommandPicker({
|
|||||||
setHighlightIndex(i);
|
setHighlightIndex(i);
|
||||||
setExpandedIndex((prev) => (prev === i ? null : 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
|
<ChevronRight
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
55
apps/web/src/lib/permission-mode.ts
Normal file
55
apps/web/src/lib/permission-mode.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
# BooCode roadmap (v1.x–v2.x)
|
# BooCode roadmap (v1.x–v2.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.
|
> **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.12–v2.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
|
## Overview
|
||||||
|
|
||||||
BooCode is a **3-app monorepo** at `/opt/boocode/` (locked 2026-05-22):
|
BooCode is a **3-app monorepo** at `/opt/boocode/` (locked 2026-05-22):
|
||||||
@@ -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`.
|
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`.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Deferred work — post stale cleanup (2026-05-26)
|
# 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.12–v2.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.
|
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
|
Last updated: 2026-05-29
|
||||||
|
|||||||
Reference in New Issue
Block a user