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:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user