Files
paseo/packages/protocol/src/provider-manifest.ts
T
Matt CowgerandGitHub 9ea58aae0c fix(server): stop OpenCode daemon crash on session close, fix #2014 (#2027)
* fix(server): patch OpenCode SDK unhandled rejection on stream abort

The SDK's SSE abort handler calls reader.cancel() without handling
its rejection. When Paseo aborts the event stream during normal
session close, that promise can reject and crash the daemon with an
unhandled rejection outside any Paseo-owned code path.

Patches both copies the SDK ships (dist/gen and dist/v2/gen, the
latter being what Paseo actually imports). Extends
postinstall-patches.mjs to support patch-package running from a
non-root cwd, since @opencode-ai/sdk lives in packages/server's own
node_modules rather than the hoisted root.

* fix(server): stop assuming OpenCode's default agent is "build"

OpenCode users can rename or delete any agent, including the built-in
"build"/"plan" defaults. Paseo was injecting a hardcoded "build"
agent in several places whenever no mode was explicitly requested:

- The protocol manifest declared defaultModeId: "build" for opencode,
  which AgentManager.normalizeConfig applied to every session
  (including internal metadata-generation sessions) before any
  provider-level logic ran.
- OpenCode's own normalizeOpenCodeModeId defaulted an empty/"default"
  modeId to "build" rather than omitting the agent field.
- Unattended child creates (e.g. subagents spawned by an unattended
  parent) forced modeId: "build" to express unattendedness, even
  though that's already carried by the auto_accept feature.

Now an unset mode stays unset end-to-end: the "agent" field is
omitted from OpenCode prompt/command calls entirely, letting OpenCode
fall back to its own configured default agent instead of Paseo
guessing one that may not exist.

* fix(server): validate agent create mode on the WebSocket session path

resolveAndValidateCreateAgentMode already rejects modes unknown to a
provider's discovered mode list, but it was only wired into the MCP
create path. App-created agents (create_agent_request) skipped it
entirely, so a client's remembered mode preference — which can go
stale when a user renames or deletes OpenCode agents — sailed through
to the provider and failed mid-turn with an opaque error instead of a
clear rejection at creation time.

resolveSessionCreateAgent now calls
providerSnapshotManager.resolveCreateConfig, matching the MCP path,
so an invalid mode now throws 'Invalid mode ... Available modes: ...'
immediately on create.

* chore: address review feedback on create-agent and postinstall script

- Document the cleanup-ordering constraint in resolveSessionCreateAgent:
  mode validation runs after buildSessionConfig (cwd isn't known until
  that completes), so a thrown validation error leaves any
  worktree/workspace buildSessionConfig created for the caller to clean
  up. session.ts already handles this for the worktree path; the
  directory-only workspace path has a pre-existing gap, not introduced
  by this validation.
- Log spawn errors from patch-package in postinstall-patches.mjs
  (e.g. ENOENT if it's missing from PATH) instead of failing silently.

* fix(server): don't fabricate OpenCode modes when discovery finds none

fetchModesFromClient and mergeOpenCodeModes fell back to
DEFAULT_MODES ([build, plan]) whenever OpenCode discovery returned
nothing — e.g. for a freshly-created worktree whose OpenCode server
hasn't loaded the project config yet. That fabricated list let
create-time mode validation accept a stale 'plan' preference that the
provider then rejects at prompt time (Agent not found: 'plan').

Return an empty list instead: OpenCode users can rename or delete any
agent, so any hardcoded fallback risks validating a mode that doesn't
exist. DEFAULT_MODES is retained only for description enrichment and
sort ordering.

* fix(app): reconcile stale selected mode against discovered modes on create

The mode picker displays modeOptions[0] when the stored modeId isn't
in the discovered list (e.g. a globally-remembered 'plan' that a
workspace's OpenCode config no longer defines), but the create request
still submitted the raw stored modeId. Result: UI showed 'Build' while
the request sent 'plan', which the daemon then rejected.

Reconcile the selected mode against the discovered mode ids when
building the create config (both the workspace draft composer and the
workspace-setup dialog), so the submitted mode matches what the picker
shows — falling back to the first available mode when the stored one is
absent. Mirrors the existing resolveEffectiveModel reconciliation for
models.

* test(server): fix opencode mode tests after fallback change + rebase

- Replace 'available modes include build and plan' (which relied on the
  removed [build, plan] discovery-failure fallback) with two tests:
  one asserting discovered agents map to modes, one asserting empty
  discovery yields no fabricated modes.
- Restore messageId fields in the four provider-subagent timeline
  assertions. These were accidentally dropped during rebase conflict
  resolution; the #2013 subagent code legitimately emits messageId, so
  the expectations must include it.

* test(app): fix e2e seed helper using invalid opencode mode

createIdleAgent seeded an OpenCode agent with modeId
'bypassPermissions' — a Claude mode OpenCode never had. This worked
before only because the session create path silently coerced unknown
modes to 'build'. Now that create-time mode validation rejects modes
the provider doesn't define, use 'build' + auto_accept (OpenCode's
unattended full-access equivalent), matching the rewind-flow helper.

Fixes the e2e failures across archive-tab, command-center-host,
settings-toggle-tab-regression, workspace-agent-tab-rename,
workspace-pane-remount, workspace-navigation-regression, and
worktree-restore specs, which all seed via this helper.
2026-07-14 19:00:53 +08:00

293 lines
8.3 KiB
TypeScript

import { z } from "zod";
import type { AgentMode } from "./agent-types.js";
export type AgentModeColorTier = "safe" | "moderate" | "dangerous" | "planning" | `#${string}`;
// Open string by design: the client looks icons up in a registry and falls back
// to a default for unknown values. Daemon downgrades unknown icons for clients
// that pre-date the open-string contract (see CLIENT_CAPS.customModeIcons).
export type AgentModeIcon = string;
export interface AgentModeVisuals {
icon: AgentModeIcon;
colorTier: AgentModeColorTier;
}
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> &
AgentModeVisuals & {
// Marks the provider's most-permissioned no-prompt mode. Selecting it means tools run without approval; the runtime mechanism is provider-specific.
isUnattended?: boolean;
};
// TODO: `modes` should not be static. Providers (especially ACP) report their
// own modes at runtime via session/new. We should fetch modes from the provider
// as source of truth and enrich with UI metadata (icons, colorTier) on top.
export interface AgentProviderDefinition {
id: string;
label: string;
description: string;
enabledByDefault?: boolean;
defaultModeId: string | null;
modes: AgentProviderModeDefinition[];
voice?: {
enabled: boolean;
defaultModeId: string;
defaultModel?: string;
};
}
const CLAUDE_MODES: AgentProviderModeDefinition[] = [
{
id: "plan",
label: "Plan Mode",
description: "Analyze the codebase without executing tools or edits",
icon: "ShieldEllipsis",
colorTier: "planning",
},
{
id: "default",
label: "Always Ask",
description: "Prompts for permission the first time a tool is used",
icon: "Shield",
colorTier: "safe",
},
{
id: "acceptEdits",
label: "Accept File Edits",
description: "Automatically approves edit-focused tools without prompting",
icon: "ShieldPlus",
colorTier: "moderate",
},
{
id: "auto",
label: "Auto mode",
description: "Uses a model classifier to review permission prompts automatically",
icon: "ShieldCheck",
colorTier: "moderate",
},
{
id: "bypassPermissions",
label: "Bypass",
description: "Skip all permission prompts (use with caution)",
icon: "ShieldOff",
colorTier: "dangerous",
isUnattended: true,
},
];
const CODEX_MODES: AgentProviderModeDefinition[] = [
{
id: "auto",
label: "Default Permissions",
description: "Edit files and run commands with Codex's default approval flow.",
icon: "Shield",
colorTier: "moderate",
},
{
id: "auto-review",
label: "Auto-review",
description:
"Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent.",
icon: "ShieldCheck",
colorTier: "moderate",
},
{
id: "full-access",
label: "Full Access",
description: "Edit files, run commands, and access the network without additional prompts.",
icon: "ShieldOff",
colorTier: "dangerous",
isUnattended: true,
},
];
const COPILOT_MODES: AgentProviderModeDefinition[] = [
{
id: "https://agentclientprotocol.com/protocol/session-modes#agent",
label: "Agent",
description: "Default agent mode for conversational interactions",
icon: "Shield",
colorTier: "moderate",
},
{
id: "https://agentclientprotocol.com/protocol/session-modes#plan",
label: "Plan",
description: "Plan mode for creating and executing multi-step plans",
icon: "ShieldEllipsis",
colorTier: "planning",
},
{
id: "allow-all",
label: "Allow All",
description: "Automatically approves all Copilot tool, path, and URL requests.",
icon: "ShieldOff",
colorTier: "dangerous",
isUnattended: true,
},
];
const OPENCODE_MODES: AgentProviderModeDefinition[] = [
{
id: "build",
label: "Build",
description: "Allows edits and tool execution for implementation work",
icon: "Shield",
colorTier: "moderate",
},
{
id: "plan",
label: "Plan",
description: "Read-only planning mode that avoids file edits",
icon: "ShieldEllipsis",
colorTier: "planning",
},
];
const MOCK_LOAD_TEST_MODES: AgentProviderModeDefinition[] = [
{
id: "load-test",
label: "Load Test",
description: "Streams repeated markdown, reasoning, and tool calls for app stress testing",
icon: "ShieldOff",
colorTier: "dangerous",
},
];
const MOCK_SLOW_MODES: AgentProviderModeDefinition[] = [
{
id: "default",
label: "Default",
description: "Dev-only mode for the mock slow provider",
icon: "ShieldOff",
colorTier: "dangerous",
},
];
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
{
id: "claude",
label: "Claude",
description: "Anthropic's multi-tool assistant with MCP support, streaming, and deep reasoning",
defaultModeId: "default",
modes: CLAUDE_MODES,
voice: {
enabled: true,
defaultModeId: "default",
defaultModel: "haiku",
},
},
{
id: "codex",
label: "Codex",
description: "OpenAI's Codex workspace agent with sandbox controls and optional network access",
defaultModeId: "auto",
modes: CODEX_MODES,
voice: {
enabled: true,
defaultModeId: "auto",
defaultModel: "gpt-5.4-mini",
},
},
{
id: "copilot",
label: "Copilot",
description: "GitHub Copilot via Agent Client Protocol with dynamic modes and session support",
defaultModeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
modes: COPILOT_MODES,
},
{
id: "opencode",
label: "OpenCode",
description: "Open-source coding assistant with multi-provider model support",
// No static default: OpenCode users can rename or delete any agent,
// including "build". Leaving this unset means the daemon and OpenCode
// itself decide (see normalizeOpenCodeModeId in opencode-agent.ts).
defaultModeId: null,
modes: OPENCODE_MODES,
voice: {
enabled: true,
defaultModeId: "build",
},
},
{
id: "pi",
label: "Pi",
description: "Minimal terminal-based coding agent with multi-provider LLM support",
defaultModeId: null,
modes: [],
},
{
id: "omp",
label: "OMP",
description: "Pi-compatible coding agent distributed as Oh My Pi",
enabledByDefault: false,
defaultModeId: null,
modes: [],
},
];
export const DEV_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
{
id: "mock",
label: "Mock Load Test",
description:
"Development-only provider that emits synthetic agent traffic for performance tests",
defaultModeId: "load-test",
modes: MOCK_LOAD_TEST_MODES,
},
{
id: "mock-slow",
label: "Mock Slow Provider",
description: "Dev-only: hangs during model discovery to test loading and timeout UI",
defaultModeId: "default",
modes: MOCK_SLOW_MODES,
},
];
export function getAgentProviderDefinition(
provider: string,
definitions: AgentProviderDefinition[] = [
...AGENT_PROVIDER_DEFINITIONS,
...DEV_AGENT_PROVIDER_DEFINITIONS,
],
): AgentProviderDefinition {
const definition = definitions.find((entry) => entry.id === provider);
if (!definition) {
throw new Error(`Unknown agent provider: ${provider}`);
}
return definition;
}
export const BUILTIN_PROVIDER_IDS = AGENT_PROVIDER_DEFINITIONS.map((d) => d.id);
export const AGENT_PROVIDER_IDS = BUILTIN_PROVIDER_IDS;
export const AgentProviderSchema = z.string();
export function isValidAgentProvider(
value: string,
validIds: Iterable<string> = BUILTIN_PROVIDER_IDS,
): boolean {
return Array.isArray(validIds) ? validIds.includes(value) : new Set(validIds).has(value);
}
export function getUnattendedModeId(
provider: string,
definitions: AgentProviderDefinition[] = [
...AGENT_PROVIDER_DEFINITIONS,
...DEV_AGENT_PROVIDER_DEFINITIONS,
],
): string | undefined {
const definition = definitions.find((entry) => entry.id === provider);
return definition?.modes.find((mode) => mode.isUnattended)?.id;
}
export function getModeVisuals(
provider: string,
modeId: string,
definitions: AgentProviderDefinition[],
): AgentModeVisuals | undefined {
const definition = definitions.find((entry) => entry.id === provider);
const mode = definition?.modes.find((m) => m.id === modeId);
if (!mode) return undefined;
return { icon: mode.icon, colorTier: mode.colorTier };
}