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>
This commit is contained in:
2026-06-05 15:14:21 +00:00
parent da36344d0b
commit e04d0fdaa8
9 changed files with 180 additions and 21 deletions

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. 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 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. 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

@@ -1,8 +1,8 @@
# Current focus # Current focus
Last updated: 2026-06-03 Last updated: 2026-06-05
- **Last shipped:** `v2.7.17-orchestrator` (2026-06-03) — in-app multi-agent Orchestrator on local Qwen. - **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` - **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. - **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.

View File

@@ -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 };
}, },

View File

@@ -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,
}; };
} }

View File

@@ -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}

View File

@@ -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,

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) { 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,
}; };
} }

View File

@@ -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
/> />
)} )}

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;
}