diff --git a/apps/cli/src/__tests__/capability-refresh.test.ts b/apps/cli/src/__tests__/capability-refresh.test.ts index 74b6fa06e..8da0b3c28 100644 --- a/apps/cli/src/__tests__/capability-refresh.test.ts +++ b/apps/cli/src/__tests__/capability-refresh.test.ts @@ -37,6 +37,7 @@ const allOk = (): ClientCapabilities => ({ codex: ok(), cursor: ok(), "kimi-code": ok(), + opencode: ok(), }); const codexMissing = (): ClientCapabilities => ({ @@ -45,6 +46,7 @@ const codexMissing = (): ClientCapabilities => ({ codex: missing(), cursor: ok(), "kimi-code": ok(), + opencode: ok(), }); // Detection is install-only, so a provider mid-login is one whose binary is not @@ -73,6 +75,7 @@ const codexPendingSnapshot = (): ClientCapabilities => ({ codex: codexPending(), cursor: ok(), "kimi-code": ok(), + opencode: ok(), }); /** What a re-probe sees while the login is still in flight: still not installed. */ @@ -82,6 +85,7 @@ const codexUnauthSnapshot = (): ClientCapabilities => ({ codex: codexUnauth(), cursor: ok(), "kimi-code": ok(), + opencode: ok(), }); const BASE = 100; diff --git a/apps/cli/src/commands/agent/config/set-reasoning-effort.ts b/apps/cli/src/commands/agent/config/set-reasoning-effort.ts index c56fe0a34..1a45a8b40 100644 --- a/apps/cli/src/commands/agent/config/set-reasoning-effort.ts +++ b/apps/cli/src/commands/agent/config/set-reasoning-effort.ts @@ -7,7 +7,7 @@ export function registerAgentConfigSetReasoningEffortCommand(config: Command): v config .command("set-reasoning-effort ") .description( - 'Set reasoning effort. claude-code: "" (inherit local) | low | medium | high | max. codex: low | medium | high | xhigh | max | ultra (model-dependent). Cursor and Kimi Code have no separate effort field.', + 'Set reasoning effort. claude-code: "" (inherit local) | low | medium | high | max. codex: low | medium | high | xhigh | max | ultra (model-dependent). Cursor, Kimi Code, and OpenCode have no separate effort field.', ) .action(async (agentName: string, level: string) => { const serverUrl = resolveServerUrl(process.env.FIRST_TREE_SERVER_URL); diff --git a/apps/cli/src/commands/agent/create.ts b/apps/cli/src/commands/agent/create.ts index d76ea3227..627b83cae 100644 --- a/apps/cli/src/commands/agent/create.ts +++ b/apps/cli/src/commands/agent/create.ts @@ -16,7 +16,7 @@ export function registerAgentCreateCommand(agent: Command): void { ) .option( "--runtime ", - "Runtime handler — one of: claude-code, claude-code-tui, codex, cursor, kimi-code (default: claude-code)", + "Runtime handler — one of: claude-code, claude-code-tui, codex, cursor, kimi-code, opencode (default: claude-code)", "claude-code", ) .option("--display-name ", "Display name") diff --git a/apps/cli/src/core/client-switch.ts b/apps/cli/src/core/client-switch.ts index a80e00625..6c84fe62b 100644 --- a/apps/cli/src/core/client-switch.ts +++ b/apps/cli/src/core/client-switch.ts @@ -856,7 +856,10 @@ export function parseSwitchProcessEnvValue(envText: string, key: string): string } function isKnownProviderCommand(command: string): boolean { - if (/(^|[/\s])(claude|codex|cursor-agent)(\s|$)/i.test(command) || /@openai\/codex|claude-code/i.test(command)) { + if ( + /(^|[/\s])(claude|codex|cursor-agent|opencode)(\s|$)/i.test(command) || + /@openai\/codex|claude-code|opencode-ai/i.test(command) + ) { return true; } // Cursor's official main command is the generic name `agent`. Match it ONLY diff --git a/apps/cli/src/core/doctor.ts b/apps/cli/src/core/doctor.ts index 1bfda3cb9..d5ddfe29b 100644 --- a/apps/cli/src/core/doctor.ts +++ b/apps/cli/src/core/doctor.ts @@ -336,7 +336,7 @@ export async function checkWebSocket(): Promise { // `daemon probe`) // --------------------------------------------------------------------------- -const RUNTIME_PROVIDER_ORDER = ["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code"]; +const RUNTIME_PROVIDER_ORDER = ["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code", "opencode"]; function formatCapabilityDetail(entry: CapabilityEntry): string { if (entry.state === "ok") { diff --git a/packages/client/src/__tests__/auth-error-hint.test.ts b/packages/client/src/__tests__/auth-error-hint.test.ts index 6cd23291c..ce285af76 100644 --- a/packages/client/src/__tests__/auth-error-hint.test.ts +++ b/packages/client/src/__tests__/auth-error-hint.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { formatAuthHint, isClaudeAuthError, isCodexAuthError } from "../handlers/auth-error-hint.js"; +import { + formatAuthHint, + isClaudeAuthError, + isCodexAuthError, + isOpenCodeAuthError, +} from "../handlers/auth-error-hint.js"; /** * Locks the behavioural contract of the auth-error hint module that @@ -93,6 +98,15 @@ describe("isClaudeAuthError", () => { }); }); +describe("isOpenCodeAuthError", () => { + it("matches provider-owned credential failures without treating capacity failures as auth", () => { + expect(isOpenCodeAuthError("Provider returned 401 Unauthorized: invalid API key")).toBe(true); + expect(isOpenCodeAuthError("Run opencode auth login before using this provider")).toBe(true); + expect(isOpenCodeAuthError("rate limit exceeded")).toBe(false); + expect(isOpenCodeAuthError("")).toBe(false); + }); +}); + describe("formatAuthHint", () => { it("targets `codex login` for the codex runtime and quotes the original SDK message", () => { const hint = formatAuthHint( @@ -115,6 +129,14 @@ describe("formatAuthHint", () => { expect(hint).toContain("authentication_failed"); }); + it("keeps OpenCode authentication host-local and points at the provider-owned login", () => { + const hint = formatAuthHint("opencode", "Provider returned 401 Unauthorized"); + expect(hint).toContain("opencode"); + expect(hint).toContain("`opencode auth login`"); + expect(hint).toContain("OpenCode's selected provider"); + expect(hint).toContain("not First Tree's"); + }); + it("falls back to a placeholder when the SDK gives no message", () => { const hint = formatAuthHint("codex", ""); expect(hint).toContain("(no message from SDK)"); diff --git a/packages/client/src/__tests__/builtin-handlers.test.ts b/packages/client/src/__tests__/builtin-handlers.test.ts index 46818d332..41351b7e6 100644 --- a/packages/client/src/__tests__/builtin-handlers.test.ts +++ b/packages/client/src/__tests__/builtin-handlers.test.ts @@ -97,6 +97,19 @@ describe("Built-in Handlers", () => { expect(typeof handler.shutdown).toBe("function"); }); + it("registers opencode handler with a valid session-oriented shape", () => { + registerBuiltinHandlers(); + + const factory = getHandlerFactory("opencode"); + expect(typeof factory).toBe("function"); + const handler = factory({ workspaceRoot: "/tmp/test", runtimeProvider: "opencode" }); + expect(typeof handler.start).toBe("function"); + expect(typeof handler.resume).toBe("function"); + expect(typeof handler.inject).toBe("function"); + expect(typeof handler.suspend).toBe("function"); + expect(typeof handler.shutdown).toBe("function"); + }); + it("codex factory returns a valid session-oriented handler", () => { registerBuiltinHandlers(); diff --git a/packages/client/src/__tests__/capability-probes.test.ts b/packages/client/src/__tests__/capability-probes.test.ts index b4be4672a..b7a0fe025 100644 --- a/packages/client/src/__tests__/capability-probes.test.ts +++ b/packages/client/src/__tests__/capability-probes.test.ts @@ -739,18 +739,22 @@ describe("probeCapabilities (aggregator)", () => { vi.doMock("../runtime/capabilities/kimi-code.js", () => ({ probeKimiCodeCapability: vi.fn().mockResolvedValue(fakeEntry("ok")), })); + vi.doMock("../runtime/capabilities/opencode.js", () => ({ + probeOpenCodeCapability: vi.fn().mockResolvedValue(fakeEntry("ok")), + })); const mod = await import("../runtime/capabilities/index.js"); const caps = await mod.probeCapabilities(); // claude-code-tui is in DISABLED_RUNTIME_PROVIDERS — it is skipped, so it // gets no capability entry AND its probe is never called (no binary spawn). - expect(Object.keys(caps).sort()).toEqual(["claude-code", "codex", "cursor", "kimi-code"]); + expect(Object.keys(caps).sort()).toEqual(["claude-code", "codex", "cursor", "kimi-code", "opencode"]); expect(caps["claude-code"]?.state).toBe("ok"); expect(caps["claude-code-tui"]).toBeUndefined(); expect(caps.codex?.state).toBe("ok"); expect(caps.cursor?.state).toBe("ok"); expect(caps["kimi-code"]?.state).toBe("ok"); + expect(caps.opencode?.state).toBe("ok"); expect(tuiProbe).not.toHaveBeenCalled(); vi.doUnmock("../runtime/capabilities/claude-code.js"); @@ -758,6 +762,7 @@ describe("probeCapabilities (aggregator)", () => { vi.doUnmock("../runtime/capabilities/codex.js"); vi.doUnmock("../runtime/capabilities/cursor.js"); vi.doUnmock("../runtime/capabilities/kimi-code.js"); + vi.doUnmock("../runtime/capabilities/opencode.js"); vi.resetModules(); }); @@ -778,6 +783,9 @@ describe("probeCapabilities (aggregator)", () => { vi.doMock("../runtime/capabilities/kimi-code.js", () => ({ probeKimiCodeCapability: vi.fn().mockRejectedValue("kimi probe failed"), })); + vi.doMock("../runtime/capabilities/opencode.js", () => ({ + probeOpenCodeCapability: vi.fn().mockRejectedValue("opencode probe failed"), + })); const mod = await import("../runtime/capabilities/index.js"); const caps = await mod.probeCapabilities(); @@ -790,6 +798,7 @@ describe("probeCapabilities (aggregator)", () => { expect(caps.codex).toMatchObject({ state: "error", error: "codex probe failed" }); expect(caps.cursor).toMatchObject({ state: "error", error: "cursor probe failed" }); expect(caps["kimi-code"]).toMatchObject({ state: "error", error: "kimi probe failed" }); + expect(caps.opencode).toMatchObject({ state: "error", error: "opencode probe failed" }); // Disabled provider is never probed, so no entry (not even an error one). expect(caps["claude-code-tui"]).toBeUndefined(); @@ -798,6 +807,7 @@ describe("probeCapabilities (aggregator)", () => { vi.doUnmock("../runtime/capabilities/claude-code-tui.js"); vi.doUnmock("../runtime/capabilities/cursor.js"); vi.doUnmock("../runtime/capabilities/kimi-code.js"); + vi.doUnmock("../runtime/capabilities/opencode.js"); vi.resetModules(); }); }); diff --git a/packages/client/src/__tests__/capability-reprobe.test.ts b/packages/client/src/__tests__/capability-reprobe.test.ts index 875cfffb5..7ec83d1bb 100644 --- a/packages/client/src/__tests__/capability-reprobe.test.ts +++ b/packages/client/src/__tests__/capability-reprobe.test.ts @@ -80,6 +80,7 @@ describe("hasNonOkProvider", () => { codex: okEntry(), cursor: okEntry(), "kimi-code": okEntry(), + opencode: okEntry(), }), ).toBe(false); }); diff --git a/packages/client/src/__tests__/managed-skills.test.ts b/packages/client/src/__tests__/managed-skills.test.ts index 4f0a9d0e6..259e3ee0d 100644 --- a/packages/client/src/__tests__/managed-skills.test.ts +++ b/packages/client/src/__tests__/managed-skills.test.ts @@ -39,7 +39,14 @@ import { } from "../runtime/managed-state.js"; import { spawnWorkspaceLockWorker } from "./workspace-file-lock-worker.js"; -const PROVIDERS: readonly RuntimeProvider[] = ["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code"]; +const PROVIDERS: readonly RuntimeProvider[] = [ + "claude-code", + "claude-code-tui", + "codex", + "cursor", + "kimi-code", + "opencode", +]; function teamSkill(overrides: Partial = {}): RuntimeResourceSkill { return { @@ -173,6 +180,7 @@ describe("managed Skill reconciler", () => { ["codex", ".agents/skills"], ["cursor", ".cursor/skills"], ["kimi-code", ".kimi-code/skills"], + ["opencode", ".opencode/skills"], ]); }); @@ -190,19 +198,23 @@ describe("managed Skill reconciler", () => { "src/handlers/codex/app-server/index.ts", "src/handlers/cursor/index.ts", "src/handlers/kimi-code.ts", + "src/handlers/opencode/index.ts", ].map((path) => readFileSync(join(process.cwd(), path), "utf-8")); expect( handlerSources.reduce( (count, source) => count + (source.match(/reconcileManagedSkillsForConfig\(/g)?.length ?? 0), 0, ), - ).toBe(14); + ).toBe(15); expect(handlerSources.every((source) => !source.includes("reconcileManagedSkills({"))).toBe(true); for (const source of [handlerSources[2] ?? "", handlerSources[3] ?? "", handlerSources[4] ?? ""]) { expect(source).toContain("if (isManagedSkillsUnsafeDiscoveryError(err)) throw err;"); } expect(handlerSources[0]).toContain('failFatalSessionForRecovery(sessionCtx, "claude_config_restart_failed")'); expect(handlerSources[3]).toContain('retryBatch(batch, "codex_managed_skills_unsafe")'); + expect(handlerSources[6]).toContain("teamSkillBundleResolverFromSdk(sessionCtx.sdk)"); + expect(handlerSources[6]).toContain('token.retry(messages, "opencode_managed_skills_unsafe")'); + expect(handlerSources[6]).toContain("if (isManagedSkillsUnsafeDiscoveryError(error))"); }); it.each(PROVIDERS)("projects Core Skills only into the active %s discovery root", async (provider) => { diff --git a/packages/client/src/__tests__/opencode-binary.test.ts b/packages/client/src/__tests__/opencode-binary.test.ts new file mode 100644 index 000000000..a4cac638c --- /dev/null +++ b/packages/client/src/__tests__/opencode-binary.test.ts @@ -0,0 +1,142 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + findOpenCodeExecutableOnPath, + formatOpenCodeBinaryMissingMessage, + isSupportedOpenCodeVersion, + parseOpenCodeVersionOutput, + resolveOpenCodeRuntimeBinary, +} from "../runtime/opencode-binary.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("OpenCode binary resolution", () => { + it("finds the operator-installed binary without launching it", () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-bin-")); + roots.push(root); + const binary = join(root, "opencode"); + writeFileSync(binary, "#!/bin/sh\nexit 0\n"); + chmodSync(binary, 0o755); + + expect( + findOpenCodeExecutableOnPath( + { PATH: root }, + { platform: "linux", wellKnownDirs: () => [], loginShellPathDirs: () => [] }, + ), + ).toBe(binary); + }); + + it("finds the official OpenCode home even when the daemon PATH is empty", () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-home-")); + roots.push(root); + const binary = join(root, ".opencode", "bin", "opencode"); + mkdirSync(join(root, ".opencode", "bin"), { recursive: true }); + writeFileSync(binary, "#!/bin/sh\nexit 0\n"); + chmodSync(binary, 0o755); + + expect( + findOpenCodeExecutableOnPath( + { HOME: root, PATH: "" }, + { platform: "linux", wellKnownDirs: () => [], loginShellPathDirs: () => [] }, + ), + ).toBe(binary); + }); + + it("resolves without launching; the handler performs its gate through the process supervisor", () => { + const result = resolveOpenCodeRuntimeBinary({}, { findOnPath: () => "/opt/bin/opencode" }); + expect(result).toEqual({ ok: true, binary: "/opt/bin/opencode" }); + }); + + it("resolves npm's native Windows executable instead of the opencode.cmd shim", () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-win-bin-")); + roots.push(root); + const native = join(root, "node_modules", "opencode-ai", "bin", "opencode.exe"); + mkdirSync(join(root, "node_modules", "opencode-ai", "bin"), { recursive: true }); + writeFileSync(join(root, "opencode.cmd"), "@echo off\r\n"); + writeFileSync(native, "native"); + + expect( + findOpenCodeExecutableOnPath( + { PATH: root }, + { + platform: "win32", + pathDelimiter: ";", + wellKnownDirs: () => [], + loginShellPathDirs: () => [], + }, + ), + ).toBe(native); + }); + + it("resolves a global Windows npm prefix when the daemon PATH is empty", () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-win-prefix-")); + roots.push(root); + const native = join(root, "npm", "node_modules", "opencode-ai", "bin", "opencode.exe"); + mkdirSync(join(root, "npm", "node_modules", "opencode-ai", "bin"), { recursive: true }); + writeFileSync(native, "native"); + + expect( + findOpenCodeExecutableOnPath( + { USERPROFILE: join(root, "profile"), APPDATA: root, PATH: "" }, + { + platform: "win32", + pathDelimiter: ";", + loginShellPathDirs: () => [], + }, + ), + ).toBe(native); + }); + + it("resolves the official Windows home without a daemon PATH or cmd shim", () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-win-home-")); + roots.push(root); + const native = join(root, ".opencode", "bin", "opencode.exe"); + mkdirSync(join(root, ".opencode", "bin"), { recursive: true }); + writeFileSync(native, "native"); + + expect( + findOpenCodeExecutableOnPath( + { USERPROFILE: root, PATH: "" }, + { + platform: "win32", + pathDelimiter: ";", + wellKnownDirs: () => [], + loginShellPathDirs: () => [], + }, + ), + ).toBe(native); + }); + + it("surfaces external install and provider-owned auth instructions", () => { + expect(formatOpenCodeBinaryMissingMessage("not found")).toContain("npm install -g opencode-ai@^1.18.7"); + expect(formatOpenCodeBinaryMissingMessage("not found")).toContain("opencode auth login"); + }); + + it("parses a stable semver without accepting prerelease or partial versions", () => { + expect(parseOpenCodeVersionOutput("opencode 1.18.7")).toBe("1.18.7"); + expect(parseOpenCodeVersionOutput("opencode 1.18.9-beta.1")).toBeNull(); + expect(parseOpenCodeVersionOutput("opencode 01.18.7")).toBeNull(); + expect(parseOpenCodeVersionOutput("opencode 1.18.7_suffix")).toBeNull(); + expect(parseOpenCodeVersionOutput("opencode 1.18")).toBeNull(); + expect(parseOpenCodeVersionOutput("not-a-version")).toBeNull(); + expect(parseOpenCodeVersionOutput(`${"0".repeat(100_000)}.x opencode 1.18.7`)).toBe("1.18.7"); + }); + + it("accepts the supported major-one range and fails closed outside it", () => { + expect(isSupportedOpenCodeVersion("1.18.7")).toBe(true); + expect(isSupportedOpenCodeVersion("1.18.9")).toBe(true); + expect(isSupportedOpenCodeVersion("1.19.0")).toBe(true); + expect(isSupportedOpenCodeVersion("1.18.6")).toBe(false); + expect(isSupportedOpenCodeVersion("2.0.0")).toBe(false); + expect(isSupportedOpenCodeVersion("1.18.9-beta.1")).toBe(false); + expect(isSupportedOpenCodeVersion("01.18.7")).toBe(false); + expect(isSupportedOpenCodeVersion("1.18.7_suffix")).toBe(false); + expect(isSupportedOpenCodeVersion(null)).toBe(false); + }); +}); diff --git a/packages/client/src/__tests__/opencode-capability.test.ts b/packages/client/src/__tests__/opencode-capability.test.ts new file mode 100644 index 000000000..d180e8665 --- /dev/null +++ b/packages/client/src/__tests__/opencode-capability.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vitest"; +import { probeOpenCodeCapability } from "../runtime/capabilities/opencode.js"; + +describe("OpenCode install-only capability", () => { + it("reports the exact path without launching or inspecting auth", async () => { + const findOnPath = vi.fn(() => "/usr/local/bin/opencode"); + await expect(probeOpenCodeCapability({ findOnPath, env: { PATH: "/usr/local/bin" } })).resolves.toMatchObject({ + state: "ok", + available: true, + runtimeSource: "path", + runtimePath: "/usr/local/bin/opencode", + }); + expect(findOnPath).toHaveBeenCalledTimes(1); + }); + + it("reports a missing external runtime with actionable setup copy", async () => { + const result = await probeOpenCodeCapability({ findOnPath: () => null, env: {} }); + expect(result).toMatchObject({ state: "missing", available: false }); + expect(result.error).toContain("npm install -g opencode-ai@^1.18.7"); + expect(result.error).toContain("opencode auth login"); + }); + + it("does not advertise a resolved Windows binary that the default supervisor rejects", async () => { + const findOnPath = vi.fn(() => "C:\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe"); + const result = await probeOpenCodeCapability({ + findOnPath, + env: { PATH: "C:\\npm" }, + platform: "win32", + }); + + expect(result).toMatchObject({ + state: "error", + available: false, + runtimeSource: "path", + runtimePath: "C:\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe", + }); + expect(result.error).toContain("cannot run it on Windows"); + expect(findOnPath).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/client/src/__tests__/opencode-handler.test.ts b/packages/client/src/__tests__/opencode-handler.test.ts new file mode 100644 index 000000000..2f0beb553 --- /dev/null +++ b/packages/client/src/__tests__/opencode-handler.test.ts @@ -0,0 +1,1613 @@ +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AgentRuntimeConfig } from "@first-tree/shared"; +import { parseProviderRetryEventMessage } from "@first-tree/shared"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildOpenCodeConfigContent, + buildOpenCodeTurnArgs, + clearOpenCodeDbGateCacheForTests, + createOpenCodeHandler, + mapOpenCodeMcpServers, + openCodeProviderAttemptWindowSizeForTests, + projectOpenCodeConfig, + stableOpenCodeScope, +} from "../handlers/opencode/index.js"; +import type { AgentConfigCache } from "../runtime/agent-config-cache.js"; +import type { DeliveryToken, SessionContext, SessionMessage } from "../runtime/handler.js"; +import { ManagedSkillsUnsafeDiscoveryError, reconcileManagedSkillsForConfig } from "../runtime/managed-skills.js"; +import { acquireOpenCodePrivateConfigLease } from "../runtime/opencode-private-config.js"; +import type { ProviderProcessSpec, ProviderProcessSupervisor } from "../runtime/provider-process-supervisor.js"; +import { readSessionBriefingFingerprint } from "../runtime/session-briefing-fingerprint.js"; +import { SessionManager } from "../runtime/session-manager.js"; +import type { FirstTreeHubSDK } from "../sdk.js"; +import { silentLogger } from "./_logger-helpers.js"; +import { mockEntry } from "./test-helpers.js"; + +const roots: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + resetManagedSkillsReconcileMock(); + clearOpenCodeDbGateCacheForTests(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function runtimeConfig(): AgentRuntimeConfig { + return { + agentId: "agent-1", + version: 1, + payload: { + kind: "opencode", + prompt: { append: "managed prompt" }, + model: "openai/gpt-test", + mcpServers: [{ name: "repo", transport: "stdio", command: "mcp-bin", args: ["--stdio"] }], + env: [{ key: "PROVIDER_ENV", value: "local", sensitive: true }], + gitRepos: [], + resourceSkills: [], + }, + updatedAt: new Date(0).toISOString(), + updatedBy: "test", + }; +} + +function cache(config: AgentRuntimeConfig): AgentConfigCache { + return { + get: () => config, + refresh: async () => config, + refreshIfNewer: async () => config, + updateSdk: () => {}, + updateUrls: () => {}, + allReferencedUrls: () => new Set(), + forget: () => {}, + }; +} + +function message(id: string, content: string): SessionMessage { + return { + inboxEntryId: 1, + id, + chatId: "chat-1", + senderId: "human-1", + format: "text", + content, + metadata: null, + }; +} + +function successfulTurn(sessionId = "ses_new", text = "ok"): string { + return [ + JSON.stringify({ type: "step_start", sessionID: sessionId, part: { sessionID: sessionId } }), + JSON.stringify({ type: "text", sessionID: sessionId, part: { text } }), + JSON.stringify({ type: "step_finish", sessionID: sessionId, part: { reason: "stop" } }), + ].join("\n"); +} + +function deliveryToken() { + return { + processingStarted: vi.fn(), + complete: vi.fn(async () => {}), + retry: vi.fn(), + terminalRejected: vi.fn(async () => {}), + } satisfies DeliveryToken; +} + +function reconciledSkillsResult(resourceConfigVersion = 1) { + return { + ok: true, + resourceConfigVersion, + installed: [], + skipped: [], + removed: [], + teamSkills: [], + failures: [], + staleTeamSnapshot: false, + }; +} + +function resetManagedSkillsReconcileMock() { + const reconcile = vi.mocked(reconcileManagedSkillsForConfig); + reconcile.mockReset(); + reconcile.mockImplementation(async (_workspace, _provider, config) => reconciledSkillsResult(config?.version ?? 0)); + return reconcile; +} + +function controlledOpenCodeSleep() { + const waits: Array<{ + delayMs: number; + signal: AbortSignal; + complete: () => void; + }> = []; + const sleep = vi.fn( + (delayMs: number, signal: AbortSignal) => + new Promise((resolveDelay) => { + let settled = false; + const finish = (completed: boolean) => { + if (settled) return; + settled = true; + signal.removeEventListener("abort", onAbort); + resolveDelay(completed); + }; + const onAbort = () => finish(false); + signal.addEventListener("abort", onAbort, { once: true }); + waits.push({ delayMs, signal, complete: () => finish(true) }); + }), + ); + return { sleep, waits }; +} + +const SYNTHETIC_PROVIDER_SCRIPT = ` +const kind = process.env.FIRST_TREE_TEST_PROVIDER_KIND; +if (kind === "version") { + process.stdout.write(process.env.FIRST_TREE_TEST_PROVIDER_VERSION ?? ""); +} else if (kind === "db") { + process.stdout.write('[{"ready":1}]\\n'); +} else { + let input = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + input += chunk; + }); + process.stdin.on("end", () => { + setTimeout(() => { + const sid = process.env.FIRST_TREE_TEST_PROVIDER_SESSION_ID ?? "ses_new"; + process.stdout.write(JSON.stringify({type:"step_start",sessionID:sid,part:{sessionID:sid}}) + "\\n"); + process.stdout.write(JSON.stringify({type:"text",sessionID:sid,part:{text:input.trim()}}) + "\\n"); + process.stdout.write(JSON.stringify({type:"step_finish",sessionID:sid,part:{reason:"stop",tokens:{input:3,output:2}}}) + "\\n"); + }, Number(process.env.FIRST_TREE_TEST_PROVIDER_DELAY_MS ?? "0")); + }); +} +`; + +const PROTOCOL_PROVIDER_SCRIPT = ` +process.stdin.resume(); +process.stdin.on("end", () => { + const encoded = process.env.FIRST_TREE_TEST_PROVIDER_OUTPUT_BASE64 ?? ""; + process.stdout.write(Buffer.from(encoded, "base64").toString("utf8")); + if (process.env.FIRST_TREE_TEST_PROVIDER_HOLD_OPEN === "1") { + setInterval(() => {}, 1000); + } +}); +`; + +function createSyntheticSupervisor( + specs: ProviderProcessSpec[], + options: { version?: string; turnDelayMs?: number; capturedInputs?: string[] } = {}, +): ProviderProcessSupervisor { + return { + spawn(spec) { + specs.push(spec); + const isDb = spec.args[0] === "db"; + const isVersion = spec.args[0] === "--version"; + const resumed = spec.args.includes("--session") ? spec.args[spec.args.indexOf("--session") + 1] : "ses_new"; + const child = spawn(process.execPath, ["-e", SYNTHETIC_PROVIDER_SCRIPT], { + ...spec.options, + env: { + ...spec.options.env, + FIRST_TREE_TEST_PROVIDER_KIND: isVersion ? "version" : isDb ? "db" : "turn", + FIRST_TREE_TEST_PROVIDER_VERSION: `${options.version ?? "1.18.7"}\n`, + FIRST_TREE_TEST_PROVIDER_SESSION_ID: resumed, + FIRST_TREE_TEST_PROVIDER_DELAY_MS: String(options.turnDelayMs ?? 0), + }, + detached: false, + }); + if (!isDb && !isVersion && child.stdin && options.capturedInputs) { + const stdin = child.stdin; + const write = stdin.write.bind(stdin); + stdin.write = ((chunk: string | Uint8Array, ...args: unknown[]) => { + options.capturedInputs?.push(String(chunk)); + return Reflect.apply(write, stdin, [chunk, ...args]); + }) as typeof stdin.write; + } + const exited = new Promise((resolve) => child.once("exit", () => resolve())); + return { child, exited }; + }, + }; +} + +function createProtocolSupervisor( + specs: ProviderProcessSpec[], + turnOutputs: string[], + capturedInputs: string[] = [], + holdOpen = false, +): ProviderProcessSupervisor { + let turn = 0; + return { + spawn(spec) { + specs.push(spec); + const output = + spec.args[0] === "--version" + ? "1.18.9\n" + : spec.args[0] === "db" + ? '[{"ready":1}]\n' + : (turnOutputs[turn++] ?? ""); + const child = spawn(process.execPath, ["-e", PROTOCOL_PROVIDER_SCRIPT], { + ...spec.options, + env: { + ...spec.options.env, + FIRST_TREE_TEST_PROVIDER_OUTPUT_BASE64: Buffer.from(output, "utf8").toString("base64"), + FIRST_TREE_TEST_PROVIDER_HOLD_OPEN: holdOpen && spec.args[0] === "run" ? "1" : "0", + }, + detached: holdOpen, + }); + if (spec.args[0] === "run" && child.stdin) { + const write = child.stdin.write.bind(child.stdin); + child.stdin.write = ((chunk: string | Uint8Array, ...args: unknown[]) => { + capturedInputs.push(String(chunk)); + return Reflect.apply(write, child.stdin, [chunk, ...args]); + }) as typeof child.stdin.write; + } + return { child, exited: new Promise((resolve) => child.once("exit", () => resolve())) }; + }, + }; +} + +function context( + events: unknown[], + forwarded: string[], + identity: { agentId?: string; chatId?: string } = {}, +): SessionContext { + const agentId = identity.agentId ?? "agent-1"; + const chatId = identity.chatId ?? "chat-1"; + return { + agent: { + agentId, + inboxId: "inbox-1", + displayName: "Agent", + type: "agent", + visibility: "organization", + delegateMention: null, + metadata: {}, + }, + sdk: { + serverUrl: "https://example.test", + getChatDetail: async () => ({ + id: "chat-1", + title: "OpenCode test", + topic: "OpenCode", + description: null, + }), + listChatParticipants: async () => [ + { + agentId: "human-1", + name: "human", + displayName: "Human", + type: "human", + role: "member", + mode: "default", + accessMode: "speaker", + }, + ], + } as unknown as SessionContext["sdk"], + log: vi.fn(), + chatId, + recordProviderActivity: vi.fn(), + emitEvent: (event) => events.push(event), + forwardResult: async (text) => { + forwarded.push(text); + }, + markMessagesConsumed: vi.fn(), + finishTurn: vi.fn(async () => {}), + retryTurn: vi.fn(), + failSessionForRecovery: vi.fn(), + buildAgentEnv: (env) => ({ + ...env, + FIRST_TREE_AGENT_ID: agentId, + FIRST_TREE_CHAT_ID: chatId, + FIRST_TREE_PROVIDER: "opencode", + FIRST_TREE_RUNTIME_SESSION_TOKEN_FILE: "/private/token", + }), + formatInboundContent: async (entry) => `[From: human]\n${String(entry.content)}`, + resolveSenderLabel: async () => "human", + formatFromHeader: async () => "[From: human]", + }; +} + +describe("OpenCode V1 handler", () => { + it("passes an SDK-backed Team Skill bundle resolver to every projection refresh", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-team-skill-resolver-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const bytes = Buffer.from("bundle bytes"); + const fetchAttachment = vi.fn(async () => ({ + bytes, + mimeType: "application/zip", + filename: "review.zip", + size: bytes.byteLength, + })); + const sessionCtx = context([], []); + sessionCtx.sdk.fetchAttachment = fetchAttachment; + const reconcile = vi.mocked(reconcileManagedSkillsForConfig); + reconcile.mockClear(); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs), + opencodeTurnTimeoutMs: 5_000, + }); + + await handler.start(message("m-team-skill", "use the complete skill"), sessionCtx, deliveryToken()); + + expect(reconcile).toHaveBeenCalledTimes(2); + const resolver = reconcile.mock.calls[0]?.[4]; + expect(resolver).toBeTypeOf("function"); + await expect( + resolver?.({ + attachmentId: "11111111-1111-4111-8111-111111111111", + format: "zip", + sizeBytes: bytes.byteLength, + }), + ).resolves.toEqual(bytes); + expect(fetchAttachment).toHaveBeenCalledWith({ id: "11111111-1111-4111-8111-111111111111" }); + await handler.shutdown(); + }); + + it("retries unsafe managed-skill discovery before starting the provider turn", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-managed-skills-unsafe-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const sessionCtx = context([], []); + const token = deliveryToken(); + const reconcile = vi.mocked(reconcileManagedSkillsForConfig); + reconcile.mockClear(); + reconcile + .mockResolvedValueOnce(reconciledSkillsResult()) + .mockRejectedValueOnce(new ManagedSkillsUnsafeDiscoveryError("unsafe discovery")); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs), + opencodeTurnTimeoutMs: 5_000, + }); + + await handler.start(message("m-unsafe-skill", "must not reach OpenCode"), sessionCtx, token); + + expect(reconcile).toHaveBeenCalledTimes(2); + expect(specs).toEqual([]); + expect(token.processingStarted).not.toHaveBeenCalled(); + expect(token.complete).not.toHaveBeenCalled(); + expect(token.retry).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m-unsafe-skill" })], + "opencode_managed_skills_unsafe", + ); + expect(vi.mocked(sessionCtx.log).mock.calls.flat().join("\n")).toContain("blocked provider turn: unsafe discovery"); + await handler.shutdown(); + }); + + it.each([ + ["queued preflight", 2], + ["turn preflight", 4], + ] as const)("parks persistent unsafe discovery at the %s and resumes the ordered batch once", async (failurePoint, expectedUnsafeRefreshes) => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-managed-skills-parked-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const inputs: string[] = []; + const events: Array<{ kind?: string; payload?: { message?: string } }> = []; + const sessionCtx = context(events, []); + const reconcile = resetManagedSkillsReconcileMock(); + const { sleep, waits } = controlledOpenCodeSleep(); + let queuedPhase = false; + let unsafe = true; + let queuedRefreshes = 0; + reconcile.mockImplementation(async () => { + if (!queuedPhase) return reconciledSkillsResult(); + queuedRefreshes += 1; + const shouldFail = unsafe && (failurePoint === "queued preflight" || queuedRefreshes % 2 === 0); + if (shouldFail) throw new ManagedSkillsUnsafeDiscoveryError(`${failurePoint} unsafe`); + return reconciledSkillsResult(); + }); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs, { capturedInputs: inputs }), + opencodeUnsafeDiscoverySleep: sleep, + opencodeTurnTimeoutMs: 5_000, + }); + await handler.start(message("m-seed", "seed"), sessionCtx, deliveryToken()); + queuedPhase = true; + const heldToken = deliveryToken(); + const tailToken = deliveryToken(); + + expect(handler.inject(message("m-held", "held delivery"), heldToken)).toEqual({ + kind: "owned", + mode: "queued", + }); + await vi.waitFor(() => expect(sleep).toHaveBeenCalledTimes(1)); + expect(handler.inject(message("m-tail", "later delivery"), tailToken)).toEqual({ + kind: "owned", + mode: "queued", + }); + waits[0]?.complete(); + await vi.waitFor(() => expect(sleep).toHaveBeenCalledTimes(2)); + + expect(sleep.mock.calls.map(([delayMs]) => delayMs)).toEqual([1_000, 2_000]); + expect(queuedRefreshes).toBe(expectedUnsafeRefreshes); + const blockedEvents = events + .map((event) => (event.payload?.message ? parseProviderRetryEventMessage(event.payload.message) : null)) + .filter((payload) => payload?.reasonCode === "managed_skills_unsafe_discovery"); + expect(blockedEvents).toEqual([ + expect.objectContaining({ + event: "provider_retry_scheduled", + provider: "opencode", + scope: "provider_turn", + attempt: 1, + retryMode: "background", + delayMs: 1_000, + userSeverity: "warning", + messagePreview: expect.stringContaining("remains unacknowledged"), + }), + expect.objectContaining({ + event: "provider_retry_scheduled", + provider: "opencode", + scope: "provider_turn", + attempt: 2, + retryMode: "background", + delayMs: 2_000, + userSeverity: "warning", + messagePreview: expect.stringContaining("remains unacknowledged"), + }), + ]); + expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(1); + expect(heldToken.processingStarted).not.toHaveBeenCalled(); + expect(heldToken.complete).not.toHaveBeenCalled(); + expect(heldToken.retry).not.toHaveBeenCalled(); + expect(tailToken.processingStarted).not.toHaveBeenCalled(); + expect(tailToken.complete).not.toHaveBeenCalled(); + expect(tailToken.retry).not.toHaveBeenCalled(); + const blockedLogs = vi + .mocked(sessionCtx.log) + .mock.calls.flat() + .filter((line) => String(line).includes("queued turn blocked by unsafe managed-skill discovery")); + expect(blockedLogs).toHaveLength(2); + expect(vi.mocked(sessionCtx.log).mock.calls.flat().join("\n")).not.toContain("OpenCode queued turn failed"); + + unsafe = false; + waits[1]?.complete(); + await vi.waitFor(() => expect(heldToken.complete).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(tailToken.complete).toHaveBeenCalledTimes(1)); + + expect(heldToken.processingStarted).toHaveBeenCalledTimes(1); + expect(heldToken.retry).not.toHaveBeenCalled(); + expect(tailToken.processingStarted).toHaveBeenCalledTimes(1); + expect(tailToken.retry).not.toHaveBeenCalled(); + expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(3); + expect(inputs).toHaveLength(3); + expect(inputs[1]).toContain("held delivery"); + expect(inputs[1]).not.toContain("later delivery"); + expect(inputs[2]).toContain("later delivery"); + for (const spec of specs.filter((entry) => entry.args[0] === "run").slice(1)) { + expect(spec.args).toEqual(expect.arrayContaining(["--session", "ses_new"])); + } + await handler.shutdown(); + }); + + it.each(["suspend", "shutdown"] as const)("releases a parked unsafe batch exactly once on %s", async (lifecycle) => { + const root = mkdtempSync(join(tmpdir(), `ft-opencode-managed-skills-${lifecycle}-`)); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const sessionCtx = context([], []); + const reconcile = resetManagedSkillsReconcileMock(); + const { sleep, waits } = controlledOpenCodeSleep(); + let queuedPhase = false; + reconcile.mockImplementation(async () => { + if (!queuedPhase) return reconciledSkillsResult(); + throw new ManagedSkillsUnsafeDiscoveryError("persistent unsafe discovery"); + }); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs), + opencodeUnsafeDiscoverySleep: sleep, + opencodeTurnTimeoutMs: 5_000, + }); + await handler.start(message("m-seed", "seed"), sessionCtx, deliveryToken()); + queuedPhase = true; + const heldToken = deliveryToken(); + const tailToken = deliveryToken(); + expect(handler.inject(message("m-held", "held"), heldToken)).toEqual({ + kind: "owned", + mode: "queued", + }); + await vi.waitFor(() => expect(sleep).toHaveBeenCalledTimes(1)); + expect(handler.inject(message("m-tail", "tail"), tailToken)).toEqual({ + kind: "owned", + mode: "queued", + }); + const reason = `test parked ${lifecycle}`; + + if (lifecycle === "suspend") await handler.suspend(reason); + else await handler.shutdown(reason); + + expect(waits[0]?.signal.aborted).toBe(true); + expect(sleep).toHaveBeenCalledTimes(1); + expect(heldToken.retry).toHaveBeenCalledTimes(1); + expect(heldToken.retry).toHaveBeenCalledWith(expect.objectContaining({ id: "m-held" }), reason); + expect(tailToken.retry).toHaveBeenCalledTimes(1); + expect(tailToken.retry).toHaveBeenCalledWith(expect.objectContaining({ id: "m-tail" }), reason); + expect(heldToken.processingStarted).not.toHaveBeenCalled(); + expect(heldToken.complete).not.toHaveBeenCalled(); + expect(tailToken.processingStarted).not.toHaveBeenCalled(); + expect(tailToken.complete).not.toHaveBeenCalled(); + expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(1); + expect(vi.mocked(sessionCtx.log).mock.calls.flat().join("\n")).not.toContain("OpenCode queued turn failed"); + if (lifecycle === "suspend") await handler.shutdown(); + }); + + it("keeps ordinary queued projection failures on the generic recovery path", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-queued-generic-failure-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const sessionCtx = context([], []); + const reconcile = resetManagedSkillsReconcileMock(); + let queuedPhase = false; + reconcile.mockImplementation(async () => { + if (!queuedPhase) return reconciledSkillsResult(); + throw new Error("ordinary queued projection failure"); + }); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs), + opencodeTurnTimeoutMs: 5_000, + }); + await handler.start(message("m-seed", "seed"), sessionCtx, deliveryToken()); + queuedPhase = true; + const token = deliveryToken(); + + expect(handler.inject(message("m-generic", "generic failure"), token)).toEqual({ + kind: "owned", + mode: "queued", + }); + await vi.waitFor(() => expect(token.retry).toHaveBeenCalledTimes(1)); + + expect(token.retry).toHaveBeenCalledWith( + expect.objectContaining({ id: "m-generic" }), + "opencode_queued_turn_failed", + ); + expect(token.processingStarted).not.toHaveBeenCalled(); + expect(token.complete).not.toHaveBeenCalled(); + expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(1); + expect(vi.mocked(sessionCtx.log).mock.calls.flat().join("\n")).toContain( + "OpenCode queued turn failed: ordinary queued projection failure", + ); + await handler.shutdown(); + }); + + it("builds private MCP/agent config and provider-native argv", () => { + const config = runtimeConfig().payload; + expect(mapOpenCodeMcpServers(config, "scope-a")).toEqual({ + servers: { + "first-tree-scope-a-mcp-1": { type: "local", command: ["mcp-bin", "--stdio"], enabled: true }, + }, + aliases: [{ configuredName: "repo", managedName: "first-tree-scope-a-mcp-1" }], + }); + const projected = JSON.parse( + buildOpenCodeConfigContent({ + payload: config, + managedAgentName: "first-tree-scope-a", + scope: "scope-a", + }), + ); + expect(projected.agent["first-tree-scope-a"]).toMatchObject({ + mode: "primary", + model: "openai/gpt-test", + }); + expect(projected.agent["first-tree-scope-a"].prompt).not.toContain("Current Chat Context"); + expect(projected).not.toHaveProperty("permission"); + expect(projected.mcp).toHaveProperty("first-tree-scope-a-mcp-1"); + expect( + buildOpenCodeTurnArgs({ + cwd: "/work", + model: "openai/gpt-test", + resumeSessionId: "ses_1", + managedAgentName: "first-tree-scope-a", + }), + ).toEqual( + expect.arrayContaining([ + "run", + "--format", + "json", + "--auto", + "--agent", + "first-tree-scope-a", + "--model", + "openai/gpt-test", + "--session", + "ses_1", + ]), + ); + }); + + it("moves oversized private config out of the Windows-sensitive environment block and cleans it", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-config-")); + roots.push(root); + const lease = await acquireOpenCodePrivateConfigLease({ + workspace: root, + callerScope: "a".repeat(64), + handlerId: "1".repeat(32), + }); + const projection = projectOpenCodeConfig({ BASE: "1", OPENCODE_CONFIG_CONTENT: "stale" }, '{"secret":"value"}', { + maxEnvBytes: 1, + fileStore: lease, + }); + expect(projection.transport).toBe("file"); + expect(JSON.parse(String(projection.env.OPENCODE_CONFIG_CONTENT))).toEqual({ + autoupdate: false, + share: "disabled", + snapshot: false, + }); + expect(String(projection.env.OPENCODE_CONFIG_CONTENT)).not.toContain("secret"); + const configPath = String(projection.env.OPENCODE_CONFIG); + expect(configPath).toContain(join(".first-tree-workspace", "opencode-config")); + expect(readFileSync(configPath, "utf8")).toBe('{"secret":"value"}'); + projection.cleanup(); + expect(existsSync(configPath)).toBe(false); + await lease.close(); + }); + + it("preserves host custom config for inline projection and fails closed rather than replacing it on overflow", () => { + const hostConfig = "/operator/custom-opencode.json"; + const inline = projectOpenCodeConfig( + { OPENCODE_CONFIG: hostConfig, OPENCODE_CONFIG_CONTENT: "stale" }, + '{"agent":{}}', + ); + expect(inline.transport).toBe("env"); + expect(inline.env.OPENCODE_CONFIG).toBe(hostConfig); + expect(inline.env.OPENCODE_CONFIG_CONTENT).toBe('{"agent":{}}'); + expect(() => + projectOpenCodeConfig({ OPENCODE_CONFIG: hostConfig }, '{"agent":{}}', { + maxEnvBytes: 1, + }), + ).toThrow(/cannot replace the host OPENCODE_CONFIG/i); + }); + + it("derives a stable high-entropy caller scope without cross-caller collisions", () => { + const first = stableOpenCodeScope("agent-1"); + expect(first).toHaveLength(64); + expect(stableOpenCodeScope("agent-1")).toBe(first); + expect(stableOpenCodeScope("agent-2")).not.toBe(first); + }); + + it("keeps the managed agent identity stable across fresh handlers and distinct across agents", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-stable-agent-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const supervisor = createSyntheticSupervisor(specs); + const run = async (agentId: string) => { + const handler = createOpenCodeHandler({ + workspaceRoot: join(root, agentId), + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: supervisor, + }); + await handler.start( + message(`m-${agentId}-${specs.length}`, "turn"), + context([], [], { agentId }), + deliveryToken(), + ); + await handler.shutdown(); + }; + await run("agent-1"); + await run("agent-1"); + await run("agent-2"); + const managedNames = specs + .filter((spec) => spec.args[0] === "run") + .map((spec) => spec.args[spec.args.indexOf("--agent") + 1]); + expect(managedNames[0]).toBe(managedNames[1]); + expect(managedNames[2]).not.toBe(managedNames[0]); + }); + + it("uses a handler-owned private config generation and removes that generation on shutdown", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-private-config-recovery-")); + roots.push(root); + const scopeRoot = join(root, ".first-tree-workspace", "opencode-config", stableOpenCodeScope("agent-1\0chat-1")); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor([]), + }); + + await handler.start(message("m-private", "turn"), context([], []), deliveryToken()); + expect(existsSync(scopeRoot)).toBe(true); + expect(readFileSync(join(scopeRoot, "handler-generations.json"), "utf8")).toContain('"handlerId"'); + await handler.shutdown(); + expect(existsSync(scopeRoot)).toBe(true); + expect(readFileSync(join(scopeRoot, "handler-generations.json"), "utf8")).toContain('"entries": []'); + }); + + it("keeps a replacement handler usable after the older generation shuts down late", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-private-config-replacement-")); + roots.push(root); + const scopeRoot = join(root, ".first-tree-workspace", "opencode-config", stableOpenCodeScope("agent-1\0chat-1")); + const supervisor = createSyntheticSupervisor([]); + const makeHandler = () => + createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: supervisor, + }); + const older = makeHandler(); + const replacement = makeHandler(); + + await older.start(message("m-private-old", "old"), context([], []), deliveryToken()); + await replacement.start(message("m-private-new", "new"), context([], []), deliveryToken()); + const generationNames = () => readdirSync(scopeRoot).filter((name) => /^handler-[a-f0-9]{32}$/.test(name)); + expect(generationNames()).toHaveLength(2); + + await older.shutdown(); + expect(generationNames()).toHaveLength(1); + const replacementToken = deliveryToken(); + replacement.inject(message("m-private-replacement", "still alive"), replacementToken); + await vi.waitFor(() => expect(replacementToken.complete).toHaveBeenCalled()); + + await replacement.shutdown(); + expect(generationNames()).toEqual([]); + }); + + it("fails closed before a file-backed write when a live generation leaf becomes a symlink", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-private-config-swap-")); + const external = mkdtempSync(join(tmpdir(), "ft-opencode-private-config-outside-")); + roots.push(root, external); + const cfg = runtimeConfig(); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(cfg), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor([]), + opencodeRetrySleep: async () => {}, + }); + await handler.start(message("m-private-swap-first", "small config"), context([], []), deliveryToken()); + const scopeRoot = join(root, ".first-tree-workspace", "opencode-config", stableOpenCodeScope("agent-1\0chat-1")); + const generationName = readdirSync(scopeRoot).find((name) => /^handler-[a-f0-9]{32}$/.test(name)); + expect(generationName).toBeTruthy(); + const generationPath = join(scopeRoot, String(generationName)); + rmSync(generationPath, { recursive: true, force: true }); + writeFileSync(join(external, "sentinel.txt"), "outside"); + symlinkSync(external, generationPath, "dir"); + cfg.payload.mcpServers[0] = { + name: "large", + transport: "stdio", + command: "mcp-bin", + args: ["x".repeat(20_000)], + }; + + const token = deliveryToken(); + handler.inject(message("m-private-swap-second", "force file projection"), token); + await vi.waitFor(() => expect(token.retry).toHaveBeenCalled()); + + expect(readdirSync(external)).toEqual(["sentinel.txt"]); + expect(readFileSync(join(external, "sentinel.txt"), "utf8")).toBe("outside"); + await expect(handler.shutdown()).rejects.toThrow(/symlink|identity/i); + }); + + it("fails closed when even the file-backed projection cannot fit a Windows environment block", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-config-overflow-")); + roots.push(root); + const lease = await acquireOpenCodePrivateConfigLease({ + workspace: root, + callerScope: "b".repeat(64), + handlerId: "2".repeat(32), + }); + expect(() => + projectOpenCodeConfig({ HUGE: "x".repeat(1_000) }, '{"agent":{}}', { + platform: "win32", + maxWindowsEnvChars: 100, + fileStore: lease, + }), + ).toThrow(/exceeds the safe Windows block limit/i); + await lease.close(); + }); + + it("serializes DB readiness, sends prompt only on stdin, and resumes the confirmed session", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-handler-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const events: unknown[] = []; + const forwarded: string[] = []; + const sessionCtx = context(events, forwarded); + const cfg = runtimeConfig(); + cfg.payload.env.push({ key: "OPENCODE_CONFIG", value: "/operator/custom-opencode.json", sensitive: false }); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(cfg), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs), + opencodeTurnTimeoutMs: 5_000, + }); + + const firstToken = deliveryToken(); + const started = await handler.start(message("m1", "first prompt"), sessionCtx, firstToken); + expect(started).toMatchObject({ sessionId: "ses_new", route: { kind: "owned", mode: "processing" } }); + expect(specs.map((spec) => spec.args[0])).toEqual(["--version", "db", "run"]); + const firstRun = specs[2]; + expect(firstRun?.args).not.toContain("first prompt"); + expect(firstRun?.options.env).toMatchObject({ + FIRST_TREE_RUNTIME_SESSION_TOKEN_FILE: "/private/token", + PROVIDER_ENV: "local", + OPENCODE_CONFIG: "/operator/custom-opencode.json", + }); + expect(String(firstRun?.options.env?.OPENCODE_CONFIG_CONTENT)).toContain('"first-tree-'); + expect(String(firstRun?.options.env?.OPENCODE_CONFIG_CONTENT)).not.toContain("Current Chat Context"); + expect(forwarded.some((text) => text.includes("[From: human]\nfirst prompt"))).toBe(true); + expect(forwarded[0]).toContain(" spec.args[0] === "db")).toHaveLength(1); + expect(events).toContainEqual(expect.objectContaining({ kind: "token_usage" })); + expect(events).toContainEqual({ kind: "turn_end", payload: { status: "success" } }); + await handler.shutdown(); + }); + + it("queues active injects instead of steering the current process", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-queue-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const inputs: string[] = []; + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs, { turnDelayMs: 100, capturedInputs: inputs }), + }); + const sessionCtx = context([], []); + const startPromise = handler.start(message("m1", "first"), sessionCtx, deliveryToken()); + await vi.waitFor(() => { + expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(1); + }); + const receipt = handler.inject(message("m2", "queued"), deliveryToken()); + expect(receipt).toEqual({ kind: "owned", mode: "queued" }); + expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(1); + await startPromise; + await vi.waitFor(() => { + expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(2); + }); + await vi.waitFor(() => expect(inputs).toHaveLength(2)); + expect(inputs[0]).toContain(" { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-protocol-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const inputs: string[] = []; + const events: Array<{ kind?: string; payload?: { message?: string } }> = []; + const sessionCtx = context(events, []); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor( + specs, + [`${JSON.stringify({ type: "future", sessionID: "ses_new" })}\n`, `${successfulTurn()}\n`], + inputs, + ), + opencodeRetrySleep: async () => {}, + }); + const firstToken = deliveryToken(); + await handler.start(message("m1", "first"), sessionCtx, firstToken); + expect(firstToken.retry).toHaveBeenCalled(); + expect(firstToken.complete).not.toHaveBeenCalled(); + expect( + events.some((event) => event.payload?.message && parseProviderRetryEventMessage(event.payload.message)), + ).toBe(true); + expect(vi.mocked(sessionCtx.log).mock.calls.flat().join("\n")).not.toContain('"type":"future"'); + + const secondToken = deliveryToken(); + expect(handler.inject(message("m2", "second"), secondToken)).toEqual({ kind: "owned", mode: "queued" }); + await vi.waitFor(() => expect(secondToken.complete).toHaveBeenCalled()); + expect(inputs).toHaveLength(2); + expect(inputs[0]).toContain(" { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-retry-window-")); + roots.push(root); + const sleep = vi.fn<(delayMs: number) => Promise>(async () => {}); + const sessionCtx = context([], []); + const tokens = [deliveryToken(), deliveryToken(), deliveryToken()]; + for (const token of tokens) { + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], ["not-json\n"]), + opencodeRetrySleep: sleep, + }); + await handler.start(message("m-retry", "same delivery"), sessionCtx, token); + await handler.shutdown(); + } + + expect(sleep.mock.calls.map(([delay]) => delay)).toEqual([5_000, 15_000]); + expect(tokens[0]?.retry).toHaveBeenCalled(); + expect(tokens[1]?.retry).toHaveBeenCalled(); + expect(tokens[2]?.retry).not.toHaveBeenCalled(); + expect(tokens[2]?.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m-retry" })], + expect.objectContaining({ status: "error", completion: "consumed" }), + ); + expect(sessionCtx.failSessionForRecovery).toHaveBeenCalledTimes(2); + }); + + it("keeps bounded retry custody across real SessionManager handler replacement", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-session-manager-retry-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const sleep = vi.fn<(delayMs: number) => Promise>(async () => {}); + const inputs: string[] = []; + const supervisor = createProtocolSupervisor( + specs, + [`${successfulTurn()}\n`, "not-json\n", "not-json\n", "not-json\n"], + inputs, + ); + const ackEntry = vi.fn<(entryId: number) => Promise>(async () => {}); + const recoverChat = vi.fn<(chatId: string) => Promise>(async () => {}); + const sendMessage = vi.fn(async () => ({ id: "runtime-notice" })); + const sdk = { + sendMessage, + getChatDetail: vi.fn(async () => ({ + id: "chat-sm-retry", + title: "Retry chat", + topic: null, + description: null, + })), + listChatParticipants: vi.fn(async () => []), + } as unknown as FirstTreeHubSDK; + const manager = new SessionManager({ + session: { + idle_timeout: 300, + max_sessions: 10, + working_grace_seconds: 3600, + reconcile_interval_seconds: 300, + }, + concurrency: 1, + handlerFactory: (handlerConfig) => + createOpenCodeHandler({ + ...handlerConfig, + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: supervisor, + opencodeRetrySleep: sleep, + }), + handlerConfig: { workspaceRoot: root, runtimeProvider: "opencode" }, + agentIdentity: { + agentId: "agent-1", + inboxId: "inbox-1", + displayName: "Agent", + type: "agent", + visibility: "organization", + delegateMention: null, + metadata: {}, + }, + sdk, + log: silentLogger(), + registryPath: join(root, "sessions.json"), + ackEntry, + recoverChat, + agentConfigCache: cache(runtimeConfig()), + }); + const seed = mockEntry({ + id: 801, + chatId: "chat-sm-retry", + messageId: "msg-sm-seed", + content: "seed the provider session", + }); + const deliveryHead = mockEntry({ + id: 802, + chatId: "chat-sm-retry", + messageId: "msg-sm-head", + content: "retry delivery head", + }); + const fusedTail = mockEntry({ + id: 803, + chatId: "chat-sm-retry", + messageId: "msg-sm-tail", + content: "fused delivery tail", + }); + + await manager.dispatch(seed); + await vi.waitFor(() => expect(ackEntry).toHaveBeenCalledWith(801)); + await Promise.all([manager.dispatch(deliveryHead), manager.dispatch(fusedTail)]); + await vi.waitFor(() => expect(recoverChat).toHaveBeenCalledTimes(1)); + expect(inputs[1]).toContain("retry delivery head"); + expect(inputs[1]).toContain("fused delivery tail"); + for (const expectedRuns of [3, 4]) { + await manager.dispatch(deliveryHead); + await vi.waitFor(() => expect(specs.filter((spec) => spec.args[0] === "run")).toHaveLength(expectedRuns)); + } + + expect(sleep.mock.calls.map(([delay]) => delay)).toEqual([5_000, 15_000]); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(ackEntry).toHaveBeenCalledWith(802); + await manager.shutdown(); + }); + + it("abandons no custody mutation when suspend interrupts a provider retry delay", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-retry-suspend-")); + roots.push(root); + let sleepStarted!: () => void; + const started = new Promise((resolveStarted) => { + sleepStarted = resolveStarted; + }); + const sleep = vi.fn( + async (_delayMs: number, signal: AbortSignal) => + new Promise((resolveDelay) => { + sleepStarted(); + signal.addEventListener("abort", () => resolveDelay(false), { once: true }); + }), + ); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], ["not-json\n"]), + opencodeRetrySleep: sleep, + }); + const token = deliveryToken(); + const startPromise = handler.start(message("m-delay", "first"), context([], []), token); + await started; + + await handler.suspend("test suspend during provider delay"); + await startPromise; + + expect(token.retry).not.toHaveBeenCalled(); + expect(token.complete).not.toHaveBeenCalled(); + expect(openCodeProviderAttemptWindowSizeForTests()).toBe(1); + await handler.shutdown(); + }); + + it("continues the same retry window after SessionManager preempts an unacked delivery delay", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-retry-preempt-")); + roots.push(root); + let firstDelayStarted!: () => void; + const delayStarted = new Promise((resolveStarted) => { + firstDelayStarted = resolveStarted; + }); + let delayCall = 0; + const sleep = vi.fn(async (_delayMs: number, signal: AbortSignal) => { + delayCall += 1; + if (delayCall > 1) return true; + firstDelayStarted(); + return new Promise((resolveDelay) => { + signal.addEventListener("abort", () => resolveDelay(false), { once: true }); + }); + }); + const specs: ProviderProcessSpec[] = []; + const supervisor = createProtocolSupervisor(specs, [ + "not-json\n", + `${successfulTurn("ses_other")}\n`, + "not-json\n", + "not-json\n", + ]); + const ackEntry = vi.fn<(entryId: number) => Promise>(async () => {}); + const recoverChat = vi.fn<(chatId: string) => Promise>(async () => {}); + const sendMessage = vi.fn(async () => ({ id: "runtime-notice" })); + const sdk = { + sendMessage, + getChatDetail: vi.fn(async (chatId: string) => ({ + id: chatId, + title: "Retry preemption chat", + topic: null, + description: null, + })), + listChatParticipants: vi.fn(async () => []), + } as unknown as FirstTreeHubSDK; + const manager = new SessionManager({ + session: { + idle_timeout: 300, + max_sessions: 10, + working_grace_seconds: 3600, + reconcile_interval_seconds: 300, + }, + concurrency: 1, + handlerFactory: (handlerConfig) => + createOpenCodeHandler({ + ...handlerConfig, + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: supervisor, + opencodeRetrySleep: sleep, + }), + handlerConfig: { workspaceRoot: root, runtimeProvider: "opencode" }, + agentIdentity: { + agentId: "agent-1", + inboxId: "inbox-1", + displayName: "Agent", + type: "agent", + visibility: "organization", + delegateMention: null, + metadata: {}, + }, + sdk, + log: silentLogger(), + registryPath: join(root, "sessions.json"), + ackEntry, + recoverChat, + agentConfigCache: cache(runtimeConfig()), + }); + const delivery = mockEntry({ + id: 811, + chatId: "chat-retry-preempt", + messageId: "msg-retry-preempt", + content: "same delivery after preemption", + }); + const competing = mockEntry({ + id: 812, + chatId: "chat-competing", + messageId: "msg-competing", + content: "take the only runtime slot", + }); + + const firstDispatch = manager.dispatch(delivery); + await delayStarted; + await manager.dispatch(competing); + await firstDispatch; + expect(ackEntry).not.toHaveBeenCalledWith(811); + expect(ackEntry).toHaveBeenCalledWith(812); + + await manager.dispatch(delivery); + await vi.waitFor(() => expect(recoverChat).toHaveBeenCalledWith("chat-retry-preempt")); + await manager.dispatch(delivery); + await vi.waitFor(() => expect(ackEntry).toHaveBeenCalledWith(811)); + + expect(sleep.mock.calls.map(([delay]) => delay)).toEqual([5_000, 15_000]); + expect(sendMessage).toHaveBeenCalledTimes(1); + await manager.shutdown(); + }); + + it("expires abandoned provider-attempt windows before admitting a new delivery head", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-retry-expiry-")); + roots.push(root); + const now = Date.now(); + const clock = vi.spyOn(Date, "now").mockReturnValue(now); + let oldDeliveryPending = true; + const runFailure = async (id: string, hasPendingDelivery: () => boolean) => { + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], ["not-json\n"]), + opencodeRetrySleep: async () => {}, + }); + const sessionCtx = context([], []); + sessionCtx.hasPendingDelivery = hasPendingDelivery; + await handler.start(message(id, "delivery"), sessionCtx, deliveryToken()); + await handler.shutdown(); + }; + + await runFailure("m-old-window", () => oldDeliveryPending); + expect(openCodeProviderAttemptWindowSizeForTests()).toBe(1); + clock.mockReturnValue(now + 31 * 60_000); + await runFailure("m-old-window", () => oldDeliveryPending); + expect(openCodeProviderAttemptWindowSizeForTests()).toBe(1); + oldDeliveryPending = false; + clock.mockReturnValue(now + 62 * 60_000); + await runFailure("m-new-window", () => true); + expect(openCodeProviderAttemptWindowSizeForTests()).toBe(1); + }); + + it("redelivers one-shot context through SessionManager when the durable failure notice post fails", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-session-manager-notice-")); + roots.push(root); + const inputs: string[] = []; + const credentialOutput = [ + JSON.stringify({ type: "step_start", sessionID: "ses_new", part: { sessionID: "ses_new" } }), + JSON.stringify({ + type: "error", + sessionID: "ses_new", + error: { message: "401 Unauthorized: invalid API key" }, + }), + ].join("\n"); + const supervisor = createProtocolSupervisor([], [`${credentialOutput}\n`, `${successfulTurn()}\n`], inputs); + const ackEntry = vi.fn<(entryId: number) => Promise>(async () => {}); + const recoverChat = vi.fn<(chatId: string) => Promise>(async () => {}); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(new Error("runtime notice write failed")) + .mockResolvedValue({ id: "runtime-notice" }); + const sdk = { + sendMessage, + getChatDetail: vi.fn(async () => ({ + id: "chat-sm-notice", + title: "Notice chat", + topic: null, + description: null, + })), + listChatParticipants: vi.fn(async () => []), + } as unknown as FirstTreeHubSDK; + const manager = new SessionManager({ + session: { + idle_timeout: 300, + max_sessions: 10, + working_grace_seconds: 3600, + reconcile_interval_seconds: 300, + }, + concurrency: 1, + handlerFactory: (handlerConfig) => + createOpenCodeHandler({ + ...handlerConfig, + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: supervisor, + }), + handlerConfig: { workspaceRoot: root, runtimeProvider: "opencode" }, + agentIdentity: { + agentId: "agent-1", + inboxId: "inbox-1", + displayName: "Agent", + type: "agent", + visibility: "organization", + delegateMention: null, + metadata: {}, + }, + sdk, + log: silentLogger(), + registryPath: join(root, "sessions.json"), + ackEntry, + recoverChat, + agentConfigCache: cache(runtimeConfig()), + }); + const entry = mockEntry({ + id: 802, + chatId: "chat-sm-notice", + messageId: "msg-sm-notice", + content: "same terminal delivery", + }); + + await manager.dispatch(entry); + expect(ackEntry).not.toHaveBeenCalled(); + expect(existsSync(join(root, ".first-tree-workspace", "session-briefings"))).toBe(false); + await vi.waitFor(() => expect(recoverChat).toHaveBeenCalledWith("chat-sm-notice")); + await manager.dispatch(entry); + await vi.waitFor(() => expect(ackEntry).toHaveBeenCalledWith(802)); + + expect(inputs).toHaveLength(2); + expect(inputs[0]).toContain(" { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-auth-")); + roots.push(root); + const events: Array<{ kind?: string; payload?: { message?: string } }> = []; + const output = [ + JSON.stringify({ type: "step_start", sessionID: "ses_new", part: { sessionID: "ses_new" } }), + JSON.stringify({ + type: "error", + sessionID: "ses_new", + error: { message: "401 Unauthorized: invalid API key" }, + }), + ].join("\n"); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], [`${output}\n`]), + }); + const token = deliveryToken(); + await handler.start(message("m1", "first"), context(events, []), token); + const retryPayloads = events + .map((event) => (event.payload?.message ? parseProviderRetryEventMessage(event.payload.message) : null)) + .filter((value) => value !== null); + expect(retryPayloads).toContainEqual( + expect.objectContaining({ event: "provider_failure_terminal", provider: "opencode", category: "credential" }), + ); + expect(token.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m1" })], + expect.objectContaining({ status: "error", completion: "consumed" }), + ); + await handler.shutdown(); + }); + + it("retains one-shot context and briefing custody when terminal completion is returned for redelivery", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-notice-redelivery-")); + roots.push(root); + const inputs: string[] = []; + const credentialOutput = [ + JSON.stringify({ type: "step_start", sessionID: "ses_new", part: { sessionID: "ses_new" } }), + JSON.stringify({ + type: "error", + sessionID: "ses_new", + error: { message: "401 Unauthorized: invalid API key" }, + }), + ].join("\n"); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor( + [], + [`${credentialOutput}\n`, `${successfulTurn()}\n`], + inputs, + ), + }); + const firstToken = { + ...deliveryToken(), + complete: vi.fn(async () => "retry" as const), + } satisfies DeliveryToken; + const started = await handler.start(message("m-notice", "first"), context([], []), firstToken); + const startedSessionId = typeof started === "string" ? started : started.sessionId; + expect(readSessionBriefingFingerprint(root, startedSessionId)).toBeNull(); + + const secondToken = deliveryToken(); + expect(handler.inject(message("m-redelivered", "first"), secondToken)).toEqual({ kind: "owned", mode: "queued" }); + await vi.waitFor(() => expect(secondToken.complete).toHaveBeenCalled()); + expect(inputs).toHaveLength(2); + expect(inputs[0]).toContain(" { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-agent-fallback-")); + roots.push(root); + const forwarded: string[] = []; + const output = `agent "first-tree-missing" not found. Falling back to default agent\n${successfulTurn()}\n`; + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], [output]), + }); + const token = deliveryToken(); + await handler.start(message("m1", "first"), context([], forwarded), token); + expect(forwarded).toEqual([]); + expect(token.retry).not.toHaveBeenCalled(); + expect(token.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m1" })], + expect.objectContaining({ status: "error", completion: "consumed" }), + ); + await handler.shutdown(); + }); + + it("treats a completed non-read-only tool event as an unsafe replay fence", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-effect-")); + roots.push(root); + const output = [ + JSON.stringify({ type: "step_start", sessionID: "ses_new", part: { sessionID: "ses_new" } }), + JSON.stringify({ + type: "tool_use", + sessionID: "ses_new", + part: { + id: "tool-1", + tool: "bash", + state: { status: "completed", input: { command: "touch effect" }, output: "done" }, + }, + }), + "not-json", + ].join("\n"); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], [`${output}\n`]), + }); + const token = deliveryToken(); + await handler.start(message("m1", "first"), context([], []), token); + expect(token.retry).not.toHaveBeenCalled(); + expect(token.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m1" })], + expect.objectContaining({ status: "error", completion: "consumed", reason: "unsafe_replay" }), + ); + await handler.shutdown(); + }); + + it.each([ + "completed", + "failed", + ] as const)("settles a %s write-tool timeout as unsafe instead of replaying it", async (status) => { + const root = mkdtempSync(join(tmpdir(), `ft-opencode-timeout-${status}-`)); + roots.push(root); + const output = [ + JSON.stringify({ type: "step_start", sessionID: "ses_new", part: { sessionID: "ses_new" } }), + JSON.stringify({ + type: "tool_use", + sessionID: "ses_new", + part: { + id: "tool-1", + tool: "bash", + state: { status, input: { command: "touch effect" }, output: status === "failed" ? "failed" : "done" }, + }, + }), + ].join("\n"); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], [`${output}\n`], [], true), + opencodeTurnTimeoutMs: 250, + }); + const token = deliveryToken(); + await handler.start(message("m-timeout", "first"), context([], []), token); + expect(token.retry).not.toHaveBeenCalled(); + expect(token.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m-timeout" })], + expect.objectContaining({ status: "error", completion: "consumed", reason: "unsafe_replay" }), + ); + await handler.shutdown(); + }); + + it("settles an explicit abort after a write tool as unsafe instead of replaying it", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-abort-effect-")); + roots.push(root); + const events: Array<{ kind?: string }> = []; + const output = [ + JSON.stringify({ type: "step_start", sessionID: "ses_new", part: { sessionID: "ses_new" } }), + JSON.stringify({ + type: "tool_use", + sessionID: "ses_new", + part: { + id: "tool-1", + tool: "bash", + state: { status: "completed", input: { command: "touch effect" }, output: "done" }, + }, + }), + ].join("\n"); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], [`${output}\n`], [], true), + opencodeTurnTimeoutMs: 5_000, + }); + const token = deliveryToken(); + const started = handler.start(message("m-abort", "first"), context(events, []), token); + await vi.waitFor(() => expect(events.some((event) => event.kind === "tool_call")).toBe(true)); + await handler.suspend("test explicit abort"); + await started; + expect(token.retry).not.toHaveBeenCalled(); + expect(token.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m-abort" })], + expect.objectContaining({ status: "error", completion: "consumed", reason: "unsafe_replay" }), + ); + await handler.shutdown(); + }); + + it("preserves the unsafe-effect fence when private-config cleanup fails after provider exit", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-cleanup-effect-")); + roots.push(root); + const output = [ + JSON.stringify({ type: "step_start", sessionID: "ses_new", part: { sessionID: "ses_new" } }), + JSON.stringify({ + type: "tool_use", + sessionID: "ses_new", + part: { + id: "tool-1", + tool: "bash", + state: { status: "completed", input: { command: "touch effect" }, output: "done" }, + }, + }), + JSON.stringify({ type: "step_finish", sessionID: "ses_new", part: { reason: "stop" } }), + ].join("\n"); + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createProtocolSupervisor([], [`${output}\n`]), + opencodeConfigProjector: (env: Record) => ({ + env, + transport: "env" as const, + cleanup: () => { + throw new Error("private config cleanup failed"); + }, + }), + }); + const token = deliveryToken(); + await handler.start(message("m-cleanup", "first"), context([], []), token); + expect(token.retry).not.toHaveBeenCalled(); + expect(token.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m-cleanup" })], + expect.objectContaining({ status: "error", completion: "consumed", reason: "unsafe_replay" }), + ); + await handler.shutdown(); + }); + + it("serializes one shared data-home DB gate across handler instances", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-db-shared-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const supervisor = createSyntheticSupervisor(specs, { turnDelayMs: 25 }); + const create = () => + createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: supervisor, + }); + const left = create(); + const right = create(); + await Promise.all([ + left.start(message("m1", "left"), context([], []), deliveryToken()), + right.start(message("m2", "right"), context([], []), deliveryToken()), + ]); + expect(specs.filter((spec) => spec.args[0] === "db")).toHaveLength(1); + const agentNames = specs + .filter((spec) => spec.args[0] === "run") + .map((spec) => spec.args[spec.args.indexOf("--agent") + 1]); + expect(new Set(agentNames).size).toBe(1); + await left.shutdown(); + await right.shutdown(); + }); + + it("accepts later compatible 1.x releases", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-version-ok-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs, { version: "1.18.9" }), + }); + + await expect(handler.start(message("m1", "submitted"), context([], []), deliveryToken())).resolves.toMatchObject({ + sessionId: "ses_new", + }); + expect(specs.map((spec) => spec.args[0])).toEqual(["--version", "db", "run"]); + await handler.shutdown(); + }); + + it("fails closed through the supervisor before DB or turn launch outside the supported range", async () => { + const root = mkdtempSync(join(tmpdir(), "ft-opencode-version-")); + roots.push(root); + const specs: ProviderProcessSpec[] = []; + const handler = createOpenCodeHandler({ + workspaceRoot: root, + runtimeProvider: "opencode", + agentConfigCache: cache(runtimeConfig()), + opencodeBinaryResolver: () => ({ ok: true, binary: "/host/opencode" }), + providerProcessSupervisor: createSyntheticSupervisor(specs, { version: "2.0.0" }), + }); + + const token = deliveryToken(); + await handler.start(message("m1", "never submitted"), context([], []), token); + expect(token.complete).toHaveBeenCalledWith( + [expect.objectContaining({ id: "m1" })], + expect.objectContaining({ status: "error", completion: "consumed" }), + ); + expect(specs.map((spec) => spec.args)).toEqual([["--version"]]); + await handler.shutdown(); + }); +}); diff --git a/packages/client/src/__tests__/opencode-parser.test.ts b/packages/client/src/__tests__/opencode-parser.test.ts new file mode 100644 index 000000000..2227ca30b --- /dev/null +++ b/packages/client/src/__tests__/opencode-parser.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { OpenCodeStreamParser, parseOpenCodeStreamLine } from "../handlers/opencode/parser.js"; + +describe("OpenCode JSONL parser", () => { + it("normalizes session, text, tool, usage, and terminal events", () => { + const lines = [ + JSON.stringify({ type: "step_start", sessionID: "ses_1", part: { sessionID: "ses_1" } }), + JSON.stringify({ type: "reasoning", sessionID: "ses_1", part: { text: "private chain" } }), + JSON.stringify({ type: "text", sessionID: "ses_1", part: { text: "hello" } }), + JSON.stringify({ + type: "tool_use", + sessionID: "ses_1", + part: { id: "tool_1", tool: "bash", state: { status: "completed", input: { command: "pwd" }, output: "/w" } }, + }), + JSON.stringify({ + type: "step_finish", + sessionID: "ses_1", + part: { reason: "stop", tokens: { input: 10, cache: { read: 3 }, output: 4 } }, + }), + ]; + const parser = new OpenCodeStreamParser(); + const events = parser.push(`${lines.join("\n")}\n`); + expect(events).toContainEqual({ kind: "text", text: "hello" }); + expect(events).toContainEqual({ + kind: "tool", + toolUseId: "tool_1", + name: "bash", + status: "ok", + args: { command: "pwd" }, + resultPreview: "/w", + }); + expect(events).toContainEqual({ + kind: "usage", + usage: { inputTokens: 10, cachedInputTokens: 3, outputTokens: 4 }, + }); + expect(events).toContainEqual({ kind: "terminal", reason: "stop" }); + expect(events).toContainEqual({ kind: "reasoning" }); + }); + + it("does not treat tool-calls as terminal", () => { + expect( + parseOpenCodeStreamLine(JSON.stringify({ type: "step_finish", part: { reason: "tool-calls" } })), + ).not.toContainEqual(expect.objectContaining({ kind: "terminal" })); + }); + + it("classifies malformed, unknown, and malformed-known lines as protocol violations", () => { + expect(parseOpenCodeStreamLine("not-json")[0]).toMatchObject({ kind: "unknown" }); + expect(parseOpenCodeStreamLine(JSON.stringify({ type: "future", sessionID: "ses_1" }))).toContainEqual( + expect.objectContaining({ kind: "unknown" }), + ); + expect(parseOpenCodeStreamLine(JSON.stringify({ type: "text", sessionID: "ses_1", part: {} }))).toContainEqual( + expect.objectContaining({ kind: "unknown" }), + ); + }); +}); diff --git a/packages/client/src/__tests__/opencode-private-config.test.ts b/packages/client/src/__tests__/opencode-private-config.test.ts new file mode 100644 index 000000000..8598ad1b8 --- /dev/null +++ b/packages/client/src/__tests__/opencode-private-config.test.ts @@ -0,0 +1,104 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { acquireOpenCodePrivateConfigLease } from "../runtime/opencode-private-config.js"; + +const roots: string[] = []; +const callerScope = "a".repeat(64); + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("OpenCode private config lease", () => { + it("rejects a symlinked managed ancestor without touching its external target", async () => { + const workspace = mkdtempSync(join(tmpdir(), "ft-opencode-private-workspace-")); + const external = mkdtempSync(join(tmpdir(), "ft-opencode-private-external-")); + roots.push(workspace, external); + const managedRoot = join(workspace, ".first-tree-workspace"); + mkdirSync(managedRoot); + writeFileSync(join(external, "sentinel.txt"), "outside"); + symlinkSync(external, join(managedRoot, "opencode-config"), "dir"); + + await expect( + acquireOpenCodePrivateConfigLease({ + workspace, + callerScope, + handlerId: "1".repeat(32), + }), + ).rejects.toThrow(/symlink/i); + expect(readFileSync(join(external, "sentinel.txt"), "utf8")).toBe("outside"); + }); + + it("keeps a newer handler generation alive when an older handler shuts down late", async () => { + const workspace = mkdtempSync(join(tmpdir(), "ft-opencode-private-generations-")); + roots.push(workspace); + const older = await acquireOpenCodePrivateConfigLease({ + workspace, + callerScope, + handlerId: "2".repeat(32), + }); + const newer = await acquireOpenCodePrivateConfigLease({ + workspace, + callerScope, + handlerId: "3".repeat(32), + }); + const materialization = newer.materialize("newer"); + const callerParent = join(workspace, ".first-tree-workspace", "opencode-config", callerScope); + const generationNames = () => readdirSync(callerParent).filter((name) => /^handler-[a-f0-9]{32}$/.test(name)); + expect(generationNames()).toHaveLength(2); + + await older.close(); + + expect(generationNames()).toHaveLength(1); + expect(readFileSync(materialization.configPath, "utf8")).toBe("newer"); + materialization.cleanup(); + await newer.close(); + expect(generationNames()).toEqual([]); + }); + + it("sweeps a journaled crashed generation and an unjournaled orphan under the caller lock", async () => { + const workspace = mkdtempSync(join(tmpdir(), "ft-opencode-private-stale-")); + roots.push(workspace); + const callerParent = join(workspace, ".first-tree-workspace", "opencode-config", callerScope); + const staleId = "4".repeat(32); + const orphanId = "5".repeat(32); + mkdirSync(join(callerParent, `handler-${staleId}`), { recursive: true }); + mkdirSync(join(callerParent, `handler-${orphanId}`), { recursive: true }); + writeFileSync( + join(callerParent, "handler-generations.json"), + `${JSON.stringify({ + schemaVersion: 1, + entries: [ + { + handlerId: staleId, + pid: process.pid, + processInstanceId: "stale-process-instance", + createdAt: new Date(0).toISOString(), + }, + ], + })}\n`, + ); + + const current = await acquireOpenCodePrivateConfigLease({ + workspace, + callerScope, + handlerId: "6".repeat(32), + }); + + expect(existsSync(join(callerParent, `handler-${staleId}`))).toBe(false); + expect(existsSync(join(callerParent, `handler-${orphanId}`))).toBe(false); + expect(readdirSync(callerParent).some((name) => /^handler-[a-f0-9]{32}$/.test(name))).toBe(true); + await current.close(); + }); +}); diff --git a/packages/client/src/__tests__/provider-process-supervisor.test.ts b/packages/client/src/__tests__/provider-process-supervisor.test.ts new file mode 100644 index 000000000..eabab93ff --- /dev/null +++ b/packages/client/src/__tests__/provider-process-supervisor.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { + createDefaultProviderProcessSupervisor, + ProviderProcessSupervisionUnsupportedError, + supportsDefaultProviderProcessSupervision, +} from "../runtime/provider-process-supervisor.js"; + +describe("provider process supervisor", () => { + it("fails closed on Windows until a pre-admission Job Object supervisor is supplied", () => { + expect(supportsDefaultProviderProcessSupervision("win32")).toBe(false); + expect(supportsDefaultProviderProcessSupervision("darwin")).toBe(true); + expect(supportsDefaultProviderProcessSupervision("linux")).toBe(true); + const supervisor = createDefaultProviderProcessSupervisor("win32"); + + expect(() => + supervisor.spawn({ + command: "opencode.exe", + args: ["run"], + label: "test", + options: { stdio: "ignore" }, + }), + ).toThrow(ProviderProcessSupervisionUnsupportedError); + }); +}); diff --git a/packages/client/src/__tests__/provider-retry-policy.test.ts b/packages/client/src/__tests__/provider-retry-policy.test.ts index 25d9ba835..8e263b31b 100644 --- a/packages/client/src/__tests__/provider-retry-policy.test.ts +++ b/packages/client/src/__tests__/provider-retry-policy.test.ts @@ -1,9 +1,11 @@ import type { ProviderRetryScope, ReplaySafety, RuntimeProvider } from "@first-tree/shared"; import { describe, expect, it } from "vitest"; +import { ManagedSkillsUnsafeDiscoveryError } from "../runtime/managed-skills.js"; import { buildProviderRetryEvent, classifyProviderFailure, decideProviderRetry, + MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE, type ProviderFailureClassification, } from "../runtime/provider-retry-policy.js"; @@ -201,6 +203,56 @@ describe("classifyProviderFailure", () => { ).toMatchObject({ action: "retry" }); }); + it.each([ + "session_start", + "session_resume", + ] as const)("classifies managed-Skill unsafe discovery by stable identity and retries %s indefinitely with bounded backoff", (scope) => { + const classified = classifyProviderFailure(new ManagedSkillsUnsafeDiscoveryError("opaque safety detail"), { + provider: "opencode", + scope, + source: "session", + }); + expect(classified).toMatchObject({ + category: "transient_transport", + reasonCode: MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE, + }); + + for (const [attempt, delayMs, retryMode] of [ + [1, 1_000, "foreground"], + [2, 2_000, "foreground"], + [3, 4_000, "foreground"], + [20, 60_000, "background"], + ] as const) { + expect( + decideProviderRetry({ + classification: classified, + scope, + attempt, + replaySafety: "pre_provider", + }), + ).toMatchObject({ + action: "retry", + attempt, + delayMs, + retryMode, + reasonCode: MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE, + }); + } + + expect( + decideProviderRetry({ + classification: classified, + scope: "provider_turn", + attempt: 1, + replaySafety: "user_visible", + }), + ).toMatchObject({ + action: "stop", + reasonCode: "unsafe_replay", + terminalKind: "unsafe_replay", + }); + }); + it("a genuinely missing codex binary stays terminal needs_operator (no false retry)", () => { const err = new Error( "Codex runtime binary is missing on this machine. First Tree does not bundle the native Codex engine by default.", diff --git a/packages/client/src/__tests__/session-manager-retry.test.ts b/packages/client/src/__tests__/session-manager-retry.test.ts index e97080441..aeda8ea2f 100644 --- a/packages/client/src/__tests__/session-manager-retry.test.ts +++ b/packages/client/src/__tests__/session-manager-retry.test.ts @@ -1,6 +1,7 @@ import { parseProviderRetryEventMessage, type SessionEvent, type SessionState } from "@first-tree/shared"; import { describe, expect, it, vi } from "vitest"; import type { AgentHandler, HandlerFactory, SessionContext, SessionMessage } from "../runtime/handler.js"; +import { ManagedSkillsUnsafeDiscoveryError } from "../runtime/managed-skills.js"; import { SessionManager } from "../runtime/session-manager.js"; import type { FirstTreeHubSDK } from "../sdk.js"; import { silentLogger } from "./_logger-helpers.js"; @@ -248,6 +249,193 @@ describe("SessionManager: transient session retry", () => { await sm.shutdown(); }); + it("parks managed-Skill unsafe resume retries past the old exhaustion boundary and drains head then tail once", async () => { + vi.useFakeTimers(); + try { + const ackEntry = vi.fn<(entryId: number) => Promise>().mockResolvedValue(undefined); + const recoverChat = vi.fn<(chatId: string) => Promise>().mockResolvedValue(undefined); + const events: SessionEvent[] = []; + let initialCtx: SessionContext | undefined; + let initialMessage: SessionMessage | undefined; + let recoveredCtx: SessionContext | undefined; + let recoveredHead: SessionMessage | undefined; + const recoveredTail: SessionMessage[] = []; + const established: AgentHandler = { + start: vi.fn(async (message, ctx) => { + initialMessage = message; + initialCtx = ctx; + return "existing-opencode-session"; + }), + resume: vi.fn().mockRejectedValue(new ManagedSkillsUnsafeDiscoveryError("unsafe discovery on resume")), + inject: vi.fn(), + suspend: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const unsafeRetries = Array.from( + { length: 5 }, + (_, index): AgentHandler => ({ + start: vi.fn(), + resume: vi.fn().mockRejectedValue(new ManagedSkillsUnsafeDiscoveryError(`opaque unsafe retry ${index + 1}`)), + inject: vi.fn(), + suspend: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }), + ); + const recovered: AgentHandler = { + start: vi.fn(), + resume: vi.fn(async (message, sessionId, ctx) => { + recoveredHead = message; + recoveredCtx = ctx; + return sessionId; + }), + inject: vi.fn((message) => { + recoveredTail.push(message); + return { kind: "owned", mode: "queued" } as const; + }), + suspend: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const sm = makeManager({ + handlers: [established, ...unsafeRetries, recovered], + ackEntry, + recoverChat, + onSessionEvent: (_chatId, event) => events.push(event), + }); + + await sm.dispatch(mockEntry({ id: 1, chatId: "chat-unsafe-resume", messageId: "msg-seed" })); + if (!initialCtx || !initialMessage) throw new Error("initial OpenCode session was not captured"); + await initialCtx.finishTurn(initialMessage, { status: "success", terminal: true }); + ackEntry.mockClear(); + await sm.handleCommand("chat-unsafe-resume", "session:suspend"); + + await sm.dispatch(mockEntry({ id: 2, chatId: "chat-unsafe-resume", messageId: "msg-head" })); + await sm.dispatch(mockEntry({ id: 3, chatId: "chat-unsafe-resume", messageId: "msg-tail" })); + + // New input must remain behind the retry head instead of forcing an + // immediate safety retry. + expect(unsafeRetries[0]?.resume).not.toHaveBeenCalled(); + expect(ackEntry).not.toHaveBeenCalled(); + + for (const delayMs of [1_000, 2_000, 4_000, 8_000, 16_000]) { + await vi.advanceTimersByTimeAsync(delayMs); + } + + // 31 virtual seconds crosses the former 5s + 15s unknown-exhaustion + // boundary. The same head and tail remain locally owned and unacked. + expect(unsafeRetries.every((handler) => vi.mocked(handler.resume).mock.calls.length === 1)).toBe(true); + expect(recovered.resume).not.toHaveBeenCalled(); + expect(ackEntry).not.toHaveBeenCalled(); + expect(recoverChat).not.toHaveBeenCalled(); + const resilienceEvents = events + .filter((event): event is Extract => event.kind === "error") + .map((event) => parseProviderRetryEventMessage(event.payload.message)) + .filter((event) => event !== null); + expect(resilienceEvents.some((event) => event.event === "provider_retry_exhausted")).toBe(false); + expect(resilienceEvents.some((event) => event.event === "provider_failure_terminal")).toBe(false); + expect( + resilienceEvents.filter((event) => event.event === "provider_retry_scheduled").map((event) => event.delayMs), + ).toEqual([1_000, 2_000, 4_000, 8_000, 16_000, 32_000]); + + await vi.advanceTimersByTimeAsync(32_000); + expect(recovered.resume).toHaveBeenCalledTimes(1); + expect(recovered.resume).toHaveBeenCalledWith( + expect.objectContaining({ id: "msg-head" }), + "existing-opencode-session", + expect.anything(), + expect.anything(), + ); + expect(recovered.inject).toHaveBeenCalledTimes(1); + expect(recoveredTail.map((message) => message.id)).toEqual(["msg-tail"]); + expect(ackEntry).not.toHaveBeenCalled(); + if (!recoveredCtx || !recoveredHead || !recoveredTail[0]) { + throw new Error("recovered FIFO delivery was not captured"); + } + + await recoveredCtx.finishTurn(recoveredHead, { status: "success", terminal: true }); + await recoveredCtx.finishTurn(recoveredTail[0], { status: "success", terminal: true }); + + expect(ackEntry).toHaveBeenNthCalledWith(1, 2); + expect(ackEntry).toHaveBeenNthCalledWith(2, 3); + expect(recoverChat).not.toHaveBeenCalled(); + await sm.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps fresh-start unsafe discovery unacked and cancels its bounded retry on suspend", async () => { + vi.useFakeTimers(); + try { + const ackEntry = vi.fn<(entryId: number) => Promise>().mockResolvedValue(undefined); + const recoverChat = vi.fn<(chatId: string) => Promise>().mockResolvedValue(undefined); + const retryStart = vi.fn().mockRejectedValue(new ManagedSkillsUnsafeDiscoveryError("unsafe fresh start")); + const first: AgentHandler = { + start: retryStart, + resume: vi.fn(), + inject: vi.fn(), + suspend: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const shouldStayUnused: AgentHandler = { + start: vi.fn().mockRejectedValue(new ManagedSkillsUnsafeDiscoveryError("late unsafe retry")), + resume: vi.fn(), + inject: vi.fn(), + suspend: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const sm = makeManager({ handlers: [first, shouldStayUnused], ackEntry, recoverChat }); + + await sm.dispatch(mockEntry({ id: 10, chatId: "chat-unsafe-start-suspend", messageId: "msg-head" })); + await sm.dispatch(mockEntry({ id: 11, chatId: "chat-unsafe-start-suspend", messageId: "msg-tail" })); + expect(retryStart).toHaveBeenCalledTimes(1); + expect(shouldStayUnused.start).not.toHaveBeenCalled(); + + await sm.handleCommand("chat-unsafe-start-suspend", "session:suspend"); + await sm.handleCommand("chat-unsafe-start-suspend", "session:resume"); + await vi.advanceTimersByTimeAsync(60_000); + + expect(shouldStayUnused.start).not.toHaveBeenCalled(); + expect(ackEntry).not.toHaveBeenCalled(); + expect(recoverChat).toHaveBeenCalledTimes(1); + expect(recoverChat).toHaveBeenCalledWith("chat-unsafe-start-suspend"); + await sm.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it("cancels a fresh-start unsafe retry on shutdown without terminally settling its FIFO work", async () => { + vi.useFakeTimers(); + try { + const ackEntry = vi.fn<(entryId: number) => Promise>().mockResolvedValue(undefined); + const first: AgentHandler = { + start: vi.fn().mockRejectedValue(new ManagedSkillsUnsafeDiscoveryError("unsafe fresh start")), + resume: vi.fn(), + inject: vi.fn(), + suspend: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const shouldStayUnused: AgentHandler = { + start: vi.fn().mockRejectedValue(new ManagedSkillsUnsafeDiscoveryError("late unsafe retry")), + resume: vi.fn(), + inject: vi.fn(), + suspend: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const sm = makeManager({ handlers: [first, shouldStayUnused], ackEntry }); + + await sm.dispatch(mockEntry({ id: 20, chatId: "chat-unsafe-start-shutdown", messageId: "msg-head" })); + await sm.dispatch(mockEntry({ id: 21, chatId: "chat-unsafe-start-shutdown", messageId: "msg-tail" })); + await sm.shutdown(); + await vi.advanceTimersByTimeAsync(60_000); + + expect(shouldStayUnused.start).not.toHaveBeenCalled(); + expect(ackEntry).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("manual suspend during retry backoff cancels the retry and leaves work for recovery", async () => { vi.useFakeTimers(); try { diff --git a/packages/client/src/__tests__/session-manager.test.ts b/packages/client/src/__tests__/session-manager.test.ts index 2363cdabf..54ddd9123 100644 --- a/packages/client/src/__tests__/session-manager.test.ts +++ b/packages/client/src/__tests__/session-manager.test.ts @@ -1962,13 +1962,14 @@ describe("SessionManager ackEntry callback (deferred ack)", () => { capturedCtx, "Your access token could not be refreshed because your refresh token was revoked.", ); - await capturedToken.complete(capturedMessage, { + const completionDisposition = await capturedToken.complete(capturedMessage, { status: "error", terminal: true, completion: "consumed", reason: "provider_credential_required", }); + expect(completionDisposition).toBe("settled"); expect(sendMessage).toHaveBeenCalledTimes(1); expect(sendMessage).toHaveBeenCalledWith( "chat-provider-terminal", @@ -2076,13 +2077,14 @@ describe("SessionManager ackEntry callback (deferred ack)", () => { reasonCode: "provider_credential_required", messagePreview: "Failed to authenticate. API Error: 403 Request not allowed", }); - await capturedToken.complete(capturedMessage, { + const completionDisposition = await capturedToken.complete(capturedMessage, { status: "error", terminal: true, completion: "consumed", reason: "provider_credential_required", }); + expect(completionDisposition).toBe("settled"); expect(sendMessage).toHaveBeenCalledTimes(1); const notice = String(sendMessage.mock.calls[0]?.[1].content); expect(notice).toContain("Claude Code could not run this turn"); @@ -2244,13 +2246,14 @@ describe("SessionManager ackEntry callback (deferred ack)", () => { if (!capturedCtx || !capturedToken || !capturedMessage) throw new Error("delivery was not captured"); emitCodexTerminalProviderFailure(capturedCtx, "revoked refresh token"); - await capturedToken.complete(capturedMessage, { + const failedNoticeCompletionDisposition = await capturedToken.complete(capturedMessage, { status: "error", terminal: true, completion: "consumed", reason: "provider_credential_required", }); + expect(failedNoticeCompletionDisposition).toBe("retry"); expect(sendMessage).toHaveBeenCalledTimes(1); expect(ackEntry).not.toHaveBeenCalled(); expect(recoverChat).toHaveBeenCalledWith("chat-provider-notice-fail"); diff --git a/packages/client/src/handlers/auth-error-hint.ts b/packages/client/src/handlers/auth-error-hint.ts index 82bb0387b..6bd92bc55 100644 --- a/packages/client/src/handlers/auth-error-hint.ts +++ b/packages/client/src/handlers/auth-error-hint.ts @@ -13,7 +13,7 @@ * CLI. The hint reframes the message so the next step is obvious. */ -type Runtime = "codex" | "claude-code" | "cursor" | "kimi-code"; +type Runtime = "codex" | "claude-code" | "cursor" | "kimi-code" | "opencode"; /** * Substring keywords used to detect codex's auth-refresh failures. Codex's @@ -80,6 +80,21 @@ export function isKimiCodeAuthError(codeOrMessage: string): boolean { ); } +const OPENCODE_AUTH_KEYWORDS: readonly string[] = [ + "authentication required", + "not authenticated", + "unauthorized", + "invalid api key", + "missing api key", + "auth login", + "provider.auth", +]; + +export function isOpenCodeAuthError(message: string): boolean { + const lower = message.toLowerCase(); + return OPENCODE_AUTH_KEYWORDS.some((keyword) => lower.includes(keyword)); +} + /** * The single auth-failure code claude-code's SDK reports (out of the * `SDKAssistantMessageError` union). Centralised here so both the assistant- @@ -107,11 +122,21 @@ export function formatAuthHint(runtime: Runtime, originalMessage: string): strin ? "`codex login`" : runtime === "cursor" ? "`cursor-agent login`" - : runtime === "kimi-code" - ? "`kimi` and then `/login`" - : "`claude auth login`"; + : runtime === "opencode" + ? "`opencode auth login`" + : runtime === "kimi-code" + ? "`kimi` and then `/login`" + : "`claude auth login`"; const provider = - runtime === "codex" ? "OpenAI" : runtime === "cursor" ? "Cursor" : runtime === "kimi-code" ? "Kimi" : "Anthropic"; + runtime === "codex" + ? "OpenAI" + : runtime === "cursor" + ? "Cursor" + : runtime === "opencode" + ? "OpenCode's selected provider" + : runtime === "kimi-code" + ? "Kimi" + : "Anthropic"; // Cap the appended raw message so an upstream stack-trace envelope (codex // wraps its `event.error.message` in surprising ways) doesn't bloat the // hint into a wall of text on the chat timeline. diff --git a/packages/client/src/handlers/index.ts b/packages/client/src/handlers/index.ts index e621483d3..e85815927 100644 --- a/packages/client/src/handlers/index.ts +++ b/packages/client/src/handlers/index.ts @@ -6,6 +6,7 @@ import { type ClaudeExecutableResolution, resolveClaudeCodeExecutable } from "./ import { createCodexHandler } from "./codex/index.js"; import { createCursorHandler } from "./cursor/index.js"; import { createKimiCodeHandler } from "./kimi-code.js"; +import { createOpenCodeHandler } from "./opencode/index.js"; /** Injectable seam so tests can force a Claude-executable resolution (no real PATH / shell spawn). */ export type RegisterBuiltinHandlersDeps = { @@ -53,4 +54,9 @@ export function registerBuiltinHandlers(deps: RegisterBuiltinHandlersDeps = {}): // Kimi Code is driven through the bundled Node SDK. It reuses the host's // ~/.kimi-code credential/config and does not add a First Tree login flow. registerHandler("kimi-code", (config) => createKimiCodeHandler(config)); + // OpenCode is an external, host-authenticated, per-turn CLI runtime. The + // handler owns JSONL/session semantics; Windows stays fail-closed until a + // pre-admission Job Object supervisor is supplied and accepted as drain + // authority. + registerHandler("opencode", (config) => createOpenCodeHandler(config)); } diff --git a/packages/client/src/handlers/opencode/index.ts b/packages/client/src/handlers/opencode/index.ts new file mode 100644 index 000000000..3eccee642 --- /dev/null +++ b/packages/client/src/handlers/opencode/index.ts @@ -0,0 +1,1456 @@ +import { createHash, randomUUID } from "node:crypto"; +import { isAbsolute, join, resolve } from "node:path"; +import { + type AgentRuntimeConfig, + type AgentRuntimeConfigPayload, + encodeProviderRetryEventMessage, + isLandingCampaignTrialAgentMetadata, + runtimeProviderSchema, + type ToolFileRef, +} from "@first-tree/shared"; +import { ensureAgentBootstrap } from "../../runtime/agent-bootstrap.js"; +import { buildAgentBriefing } from "../../runtime/agent-briefing.js"; +import type { AgentConfigCache } from "../../runtime/agent-config-cache.js"; +import { type ChatContext, fetchChatContext } from "../../runtime/chat-context.js"; +import { renderChatContextPrompt, renderRuntimeOutputContract } from "../../runtime/chat-context-section.js"; +import { resolveContextTreeRelativePath, toolFileRefsFromShellCommand } from "../../runtime/context-tree-file-refs.js"; +import type { + AgentHandler, + DeliveryToken, + HandlerFactory, + SessionContext, + SessionMessage, + TurnConsumedErrorReason, +} from "../../runtime/handler.js"; +import { deliveryTokenFromSessionContext } from "../../runtime/handler.js"; +import { + isManagedSkillsUnsafeDiscoveryError, + type ReconciledTeamSkill, + reconcileManagedSkillsForConfig, +} from "../../runtime/managed-skills.js"; +import { + isSupportedOpenCodeVersion, + OPENCODE_SUPPORTED_VERSION_RANGE, + parseOpenCodeVersionOutput, + resolveOpenCodeRuntimeBinary, +} from "../../runtime/opencode-binary.js"; +import { + acquireOpenCodePrivateConfigLease, + type OpenCodePrivateConfigLease, +} from "../../runtime/opencode-private-config.js"; +import { ProviderAttempt, type ProviderAttemptSettlement } from "../../runtime/provider-attempt.js"; +import { + createDefaultProviderProcessSupervisor, + type ProviderProcessSupervisor, +} from "../../runtime/provider-process-supervisor.js"; +import { buildProviderRetryEvent, classifyProviderFailure } from "../../runtime/provider-retry-policy.js"; +import { redactErrorPreview } from "../../runtime/redact-error-preview.js"; +import { + buildBriefingUpdateNotice, + computeBriefingFingerprint, + readSessionBriefingFingerprint, + writeSessionBriefingFingerprint, +} from "../../runtime/session-briefing-fingerprint.js"; +import { currentSourceRepoNamesFromPayload, declaredSourceRepos } from "../../runtime/source-repos.js"; +import { teamSkillBundleResolverFromSdk } from "../../runtime/team-skill-bundle-resolver.js"; +import { acquireAgentHome, markWorkspaceInitComplete } from "../../runtime/workspace.js"; +import { chunkAssistantText } from "../assistant-text.js"; +import { formatAuthHint, isOpenCodeAuthError } from "../auth-error-hint.js"; +import { consumedErrorOutcome } from "../turn-settlement.js"; +import { type OpenCodeStreamEvent, OpenCodeStreamParser, type OpenCodeUsage } from "./parser.js"; + +export const OPENCODE_PENDING_SESSION_PREFIX = "opencode-pending-"; + +const STDERR_TAIL_LIMIT = 8_000; +const DEFAULT_TURN_TIMEOUT_MS = 20 * 60_000; +const KILL_GRACE_MS = 5_000; +const FINAL_CLOSE_WAIT_MS = 2_000; +const DB_GATE_TIMEOUT_MS = 30_000; +const CONFIG_CONTENT_ENV_MAX_BYTES = 16 * 1024; +const WINDOWS_ENV_BLOCK_MAX_CHARS = 30_000; +const PROVIDER_ATTEMPT_WINDOW_TTL_MS = 30 * 60_000; +const MAX_PROVIDER_ATTEMPT_WINDOWS = 512; +const QUEUED_UNSAFE_DISCOVERY_RETRY_BASE_MS = 1_000; +const QUEUED_UNSAFE_DISCOVERY_RETRY_MAX_MS = 30_000; + +export function isOpenCodePendingSessionId(sessionId: string): boolean { + return sessionId.startsWith(OPENCODE_PENDING_SESSION_PREFIX); +} + +type OpenCodeMcpConfig = + | { type: "local"; command: string[]; enabled: true } + | { type: "remote"; url: string; headers?: Record; enabled: true }; + +export type OpenCodeMcpProjection = { + servers: Record; + aliases: Array<{ configuredName: string; managedName: string }>; +}; + +export function mapOpenCodeMcpServers(payload: AgentRuntimeConfigPayload, scope: string): OpenCodeMcpProjection { + const out: Record = {}; + const aliases: OpenCodeMcpProjection["aliases"] = []; + for (const [index, server] of payload.mcpServers.entries()) { + const managedName = `first-tree-${scope}-mcp-${index + 1}`; + aliases.push({ configuredName: server.name, managedName }); + if (server.transport === "stdio") { + out[managedName] = { + type: "local", + command: [server.command, ...(server.args ?? [])], + enabled: true, + }; + } else { + out[managedName] = { + type: "remote", + url: server.url, + ...(server.headers ? { headers: server.headers } : {}), + enabled: true, + }; + } + } + return { servers: out, aliases }; +} + +export function buildOpenCodeConfigContent(input: { + payload: AgentRuntimeConfigPayload; + managedAgentName: string; + scope: string; +}): string { + const mcp = mapOpenCodeMcpServers(input.payload, input.scope); + const aliasNotice = + mcp.aliases.length === 0 + ? "" + : `\nManaged MCP aliases:\n${mcp.aliases + .map(({ configuredName, managedName }) => `- ${configuredName}: ${managedName}`) + .join("\n")}`; + return JSON.stringify({ + $schema: "https://opencode.ai/config.json", + autoupdate: false, + share: "disabled", + snapshot: false, + agent: { + [input.managedAgentName]: { + description: "First Tree managed agent", + mode: "primary", + prompt: + "You are running as a First Tree managed OpenCode agent. Read and follow the workspace AGENTS.md. " + + "Use the First Tree runtime CLI for teammate communication when instructed." + + aliasNotice, + ...(input.payload.model ? { model: input.payload.model } : {}), + permission: { + edit: "allow", + bash: "allow", + webfetch: "allow", + websearch: "allow", + task: "allow", + }, + }, + }, + mcp: mcp.servers, + }); +} + +export function buildOpenCodeTurnArgs(input: { + cwd: string; + model: string; + resumeSessionId: string | null; + managedAgentName: string; +}): string[] { + const args = [ + "run", + "--format", + "json", + "--auto", + "--agent", + input.managedAgentName, + "--title", + "First Tree managed turn", + "--dir", + input.cwd, + "--print-logs", + "--log-level", + "ERROR", + ]; + if (input.model) args.push("--model", input.model); + if (input.resumeSessionId) args.push("--session", input.resumeSessionId); + return args; +} + +export type OpenCodeConfigProjection = { + env: Record; + cleanup: () => void; + transport: "env" | "file"; +}; + +export function projectOpenCodeConfig( + env: Record, + configContent: string, + deps: { + fileStore?: Pick; + maxEnvBytes?: number; + maxWindowsEnvChars?: number; + platform?: NodeJS.Platform; + } = {}, +): OpenCodeConfigProjection { + const maxEnvBytes = deps.maxEnvBytes ?? CONFIG_CONTENT_ENV_MAX_BYTES; + const platform = deps.platform ?? process.platform; + const maxWindowsEnvChars = deps.maxWindowsEnvChars ?? WINDOWS_ENV_BLOCK_MAX_CHARS; + const privateEnv = { ...env }; + delete privateEnv.OPENCODE_CONFIG_CONTENT; + const contentEnv = { ...privateEnv, OPENCODE_CONFIG_CONTENT: configContent }; + if ( + Buffer.byteLength(configContent, "utf8") <= maxEnvBytes && + (platform !== "win32" || windowsEnvBlockChars(contentEnv) <= maxWindowsEnvChars) + ) { + return { + env: contentEnv, + cleanup: () => {}, + transport: "env", + }; + } + + if (privateEnv.OPENCODE_CONFIG) { + throw new Error( + "OpenCode private projection is too large for the child environment and cannot replace the host OPENCODE_CONFIG", + ); + } + if (!deps.fileStore) { + throw new Error("OpenCode file-backed projection requires a runtime-owned workspace lease"); + } + const materialization = deps.fileStore.materialize(configContent); + const fileEnv = { + ...privateEnv, + OPENCODE_CONFIG: materialization.configPath, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ autoupdate: false, share: "disabled", snapshot: false }), + }; + if (platform === "win32" && windowsEnvBlockChars(fileEnv) > maxWindowsEnvChars) { + materialization.cleanup(); + throw new Error("OpenCode runtime provider mismatch: child environment exceeds the safe Windows block limit"); + } + return { + env: fileEnv, + cleanup: materialization.cleanup, + transport: "file", + }; +} + +function windowsEnvBlockChars(env: Readonly>): number { + let total = 1; + for (const [key, value] of Object.entries(env)) total += key.length + 1 + value.length + 1; + return total; +} + +type ProcessOutcome = { + exitCode: number | null; + signal: NodeJS.Signals | null; + stdoutTail: string; + stderrTail: string; + spawnError?: Error; +}; + +type TurnState = { + parser: OpenCodeStreamParser; + sessionIds: Set; + terminalReasons: string[]; + errors: string[]; + text: string[]; + usage: OpenCodeUsage | null; + sawProviderActivity: boolean; + sawUnsafeTool: boolean; + protocolDiagnostics: string[]; +}; + +const dbGatePromises = new Map>(); +type ProviderTurnFailureWindow = { + attempt: number; + touchedAt: number; + hasPendingDelivery: () => boolean; +}; + +const providerTurnFailureAttempts = new Map(); + +export function clearOpenCodeDbGateCacheForTests(): void { + dbGatePromises.clear(); + providerTurnFailureAttempts.clear(); +} + +export function openCodeProviderAttemptWindowSizeForTests(): number { + return providerTurnFailureAttempts.size; +} + +type OpenCodeRetrySleep = (delayMs: number, signal: AbortSignal) => Promise; +type QueuedDelivery = { message: SessionMessage; token: DeliveryToken }; + +async function defaultOpenCodeRetrySleep(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return false; + return new Promise((resolveDelay) => { + let settled = false; + const finish = (completed: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + resolveDelay(completed); + }; + const onAbort = () => finish(false); + const timer = setTimeout(() => finish(true), delayMs); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +function queuedUnsafeDiscoveryRetryDelayMs(attempt: number): number { + const exponent = Math.min(Math.max(attempt - 1, 0), 30); + return Math.min(QUEUED_UNSAFE_DISCOVERY_RETRY_BASE_MS * 2 ** exponent, QUEUED_UNSAFE_DISCOVERY_RETRY_MAX_MS); +} + +export const createOpenCodeHandler: HandlerFactory = (config) => { + const workspaceRoot = config.workspaceRoot as string; + const runtimeProvider = runtimeProviderSchema.parse(config.runtimeProvider ?? "opencode"); + const agentConfigCache = (config.agentConfigCache as AgentConfigCache | undefined) ?? null; + const contextTreePath = (config.contextTreePath as string | undefined) ?? null; + const contextTreeRepoUrl = (config.contextTreeRepoUrl as string | undefined) ?? null; + const contextTreeBranch = (config.contextTreeBranch as string | undefined) ?? null; + const resolveBinary = + (config.opencodeBinaryResolver as typeof resolveOpenCodeRuntimeBinary | undefined) ?? resolveOpenCodeRuntimeBinary; + const processSupervisor = + (config.providerProcessSupervisor as ProviderProcessSupervisor | undefined) ?? + createDefaultProviderProcessSupervisor(); + const turnTimeoutMs = + typeof config.opencodeTurnTimeoutMs === "number" && config.opencodeTurnTimeoutMs > 0 + ? config.opencodeTurnTimeoutMs + : DEFAULT_TURN_TIMEOUT_MS; + const retrySleep = (config.opencodeRetrySleep as OpenCodeRetrySleep | undefined) ?? defaultOpenCodeRetrySleep; + const unsafeDiscoverySleep = + (config.opencodeUnsafeDiscoverySleep as OpenCodeRetrySleep | undefined) ?? defaultOpenCodeRetrySleep; + const configProjector = + (config.opencodeConfigProjector as typeof projectOpenCodeConfig | undefined) ?? projectOpenCodeConfig; + let cwd: string | null = null; + let ctx: SessionContext | null = null; + let activeConfig: AgentRuntimeConfig | null = null; + let teamSkills: readonly ReconciledTeamSkill[] = []; + let binary: string | null = null; + let providerSessionId: string | null = null; + let pendingSyntheticId: string | null = null; + let sessionActive = false; + let initialTurnPreparing = false; + let currentAbort: AbortController | null = null; + let currentTurnPromise: Promise | null = null; + let versionReady = false; + let generation = 0; + let drainScheduled = false; + let drainInProgress = false; + let currentDrainPromise: Promise | null = null; + let drainingBatch: QueuedDelivery[] | null = null; + let unsafeDiscoveryParkedBatch: QueuedDelivery[] | null = null; + let unsafeDiscoveryWaitAbort: AbortController | null = null; + let drainCancellationReason: string | null = null; + let pendingChatContextPrompt: string | null = null; + let projectionScope: string | null = null; + let managedAgentName: string | null = null; + const handlerGenerationId = randomUUID().replaceAll("-", ""); + let privateConfigLease: OpenCodePrivateConfigLease | null = null; + const queue: QueuedDelivery[] = []; + + function deliveryAttemptKey(sessionCtx: SessionContext, messages: readonly SessionMessage[]): string { + const deliveryHead = messages[0]; + if (!deliveryHead) { + throw new Error("OpenCode provider attempt requires a delivery head"); + } + return `${sessionCtx.agent.agentId}\0${sessionCtx.chatId}\0${deliveryHead.inboxEntryId}\0${deliveryHead.id}`; + } + + function nextProviderAttempt( + attemptKey: string, + hasPendingDelivery: ProviderTurnFailureWindow["hasPendingDelivery"], + ): number { + const now = Date.now(); + for (const [key, entry] of providerTurnFailureAttempts) { + let pending = true; + try { + pending = entry.hasPendingDelivery(); + } catch { + // Observer failure is not authority to forget an unacked delivery. + } + if (!pending && now - entry.touchedAt >= PROVIDER_ATTEMPT_WINDOW_TTL_MS) { + providerTurnFailureAttempts.delete(key); + } + } + const existing = providerTurnFailureAttempts.get(attemptKey); + const attempt = (existing?.attempt ?? 0) + 1; + while (!existing && providerTurnFailureAttempts.size >= MAX_PROVIDER_ATTEMPT_WINDOWS) { + const abandoned = [...providerTurnFailureAttempts] + .sort((left, right) => left[1].touchedAt - right[1].touchedAt) + .find(([, entry]) => { + try { + return !entry.hasPendingDelivery(); + } catch { + return false; + } + }); + if (!abandoned) { + throw new Error("OpenCode provider attempt ledger is full of pending deliveries"); + } + providerTurnFailureAttempts.delete(abandoned[0]); + } + providerTurnFailureAttempts.delete(attemptKey); + providerTurnFailureAttempts.set(attemptKey, { attempt, touchedAt: now, hasPendingDelivery }); + return attempt; + } + + function buildEnv(sessionCtx: SessionContext, payload: AgentRuntimeConfigPayload): Record { + const base: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") base[key] = value; + } + for (const entry of payload.env) base[entry.key] = entry.value; + const merged = sessionCtx.buildAgentEnv(base); + const env: Record = {}; + for (const [key, value] of Object.entries(merged)) { + if (typeof value === "string") env[key] = value; + } + delete env.OPENCODE_CONFIG_CONTENT; + return env; + } + + async function fetchChatContextOrLog(sessionCtx: SessionContext): Promise { + try { + return await fetchChatContext(sessionCtx.sdk, sessionCtx.chatId, sessionCtx.agent); + } catch (error) { + sessionCtx.log(`OpenCode chat-context fetch failed: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + + function buildBriefing(sessionCtx: SessionContext, payload: AgentRuntimeConfigPayload, workspaceCwd: string): string { + return buildAgentBriefing({ + identity: sessionCtx.agent, + payload, + workspacePath: workspaceCwd, + sourceRepos: declaredSourceRepos(workspaceCwd, payload), + contextTreePath, + contextTreeRepoUrl, + contextTreeBranch, + teamSkills, + }); + } + + async function refreshProjection(sessionCtx: SessionContext): Promise<{ + payload: AgentRuntimeConfigPayload; + briefing: string; + }> { + if (!cwd) throw new Error("OpenCode workspace is not prepared"); + let runtimeConfig = activeConfig; + if (agentConfigCache) { + runtimeConfig = await agentConfigCache.refresh(sessionCtx.agent.agentId); + } + const payload: AgentRuntimeConfigPayload = + runtimeConfig?.payload ?? + ({ + kind: "opencode", + prompt: { append: "" }, + model: "", + mcpServers: [], + env: [], + gitRepos: [], + resourceSkills: [], + } satisfies AgentRuntimeConfigPayload); + if (payload.kind !== "opencode") { + throw new Error(`OpenCode handler received ${payload.kind} runtime config`); + } + teamSkills = ( + await reconcileManagedSkillsForConfig( + cwd, + runtimeProvider, + runtimeConfig, + sessionCtx.log, + teamSkillBundleResolverFromSdk(sessionCtx.sdk), + ) + ).teamSkills; + const briefing = buildBriefing(sessionCtx, payload, cwd); + ensureAgentBootstrap({ + workspace: cwd, + sessionCtx, + contextTreePath, + briefing, + currentSourceRepoNames: currentSourceRepoNamesFromPayload(payload, runtimeConfig !== null), + }); + markWorkspaceInitComplete(cwd); + activeConfig = runtimeConfig; + return { payload, briefing }; + } + + function runProcess(input: { + command: string; + args: string[]; + prompt?: string; + env: Record; + workspaceCwd: string; + state?: TurnState; + sessionCtx: SessionContext; + abortSignal: AbortSignal; + timeoutMs: number; + turnGeneration: number; + label: string; + }): Promise { + return new Promise((resolveOutcome) => { + let supervised: ReturnType; + try { + supervised = processSupervisor.spawn({ + command: input.command, + args: input.args, + label: input.label, + timeoutMs: input.timeoutMs, + options: { + cwd: input.workspaceCwd, + env: input.env, + shell: false, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + ...(process.platform === "win32" ? {} : { detached: true }), + }, + }); + } catch (error) { + resolveOutcome({ + exitCode: null, + signal: null, + stdoutTail: "", + stderrTail: "", + spawnError: error instanceof Error ? error : new Error(String(error)), + }); + return; + } + const child = supervised.child; + let stdoutTail = ""; + let stderrTail = ""; + let closed: { exitCode: number | null; signal: NodeJS.Signals | null } | null = null; + let stdoutEnded = false; + let settled = false; + let spawnError: Error | undefined; + + const finish = (): void => { + if (settled || !closed || !stdoutEnded) return; + settled = true; + resolveOutcome({ ...closed, stdoutTail, stderrTail, spawnError }); + }; + const handleEvents = (events: OpenCodeStreamEvent[]): void => { + if (!input.state) return; + for (const event of events) { + try { + handleEvent(event, input.state, input.sessionCtx); + } catch (error) { + input.sessionCtx.log( + `OpenCode event handling failed (${event.kind}): ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + }; + const terminate = (): void => { + try { + if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGTERM"); + else child.kill("SIGTERM"); + } catch { + // The process may already be gone. + } + const hardKill = setTimeout(() => { + try { + if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGKILL"); + else child.kill("SIGKILL"); + } catch { + // Ignore a completed process. + } + }, KILL_GRACE_MS); + hardKill.unref?.(); + const finalWait = setTimeout(() => { + stdoutEnded = true; + closed ??= { exitCode: null, signal: "SIGKILL" }; + finish(); + }, KILL_GRACE_MS + FINAL_CLOSE_WAIT_MS); + finalWait.unref?.(); + }; + input.abortSignal.addEventListener("abort", terminate, { once: true }); + + child.on("error", (error) => { + spawnError = error; + closed ??= { exitCode: null, signal: null }; + stdoutEnded = true; + finish(); + }); + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + stdoutTail = (stdoutTail + chunk).slice(-STDERR_TAIL_LIMIT); + if (input.state && !input.abortSignal.aborted && generation === input.turnGeneration) { + handleEvents(input.state.parser.push(chunk)); + } + }); + child.stdout?.on("end", () => { + if (input.state && !input.abortSignal.aborted && generation === input.turnGeneration) { + handleEvents(input.state.parser.flush()); + } + stdoutEnded = true; + finish(); + }); + child.stdout?.on("error", () => { + stdoutEnded = true; + finish(); + }); + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + stderrTail = (stderrTail + chunk).slice(-STDERR_TAIL_LIMIT); + }); + child.on("close", (exitCode, signal) => { + input.abortSignal.removeEventListener("abort", terminate); + closed = { exitCode, signal }; + finish(); + }); + child.stdin?.on("error", () => { + // EPIPE is classified from close + stderr. + }); + if (input.prompt !== undefined) child.stdin?.write(input.prompt); + child.stdin?.end(); + }); + } + + function handleEvent(event: OpenCodeStreamEvent, state: TurnState, sessionCtx: SessionContext): void { + sessionCtx.recordProviderActivity(); + state.sawProviderActivity = true; + switch (event.kind) { + case "session": + state.sessionIds.add(event.sessionId); + break; + case "text": + state.text.push(event.text); + break; + case "tool": + if (!isReadOnlyTool(event.name)) state.sawUnsafeTool = true; + { + const toolFileRefs = event.status === "pending" ? undefined : fileRefsForTool(event.name, event.args); + sessionCtx.emitEvent({ + kind: "tool_call", + payload: { + toolUseId: event.toolUseId, + name: event.name, + args: event.args, + status: event.status, + ...(event.resultPreview ? { resultPreview: event.resultPreview } : {}), + ...(toolFileRefs && toolFileRefs.length > 0 ? { toolFileRefs } : {}), + }, + }); + } + break; + case "usage": + state.usage = event.usage; + break; + case "terminal": + state.terminalReasons.push(event.reason); + break; + case "error": + state.errors.push(event.message); + break; + case "reasoning": + break; + case "unknown": + if (state.protocolDiagnostics.length < 5) { + sessionCtx.log(`OpenCode protocol diagnostic: ${event.note}`); + } + state.protocolDiagnostics.push(event.note); + break; + } + } + + function fileRefsForTool(name: string, args: unknown): ToolFileRef[] | undefined { + if (!cwd) return undefined; + const values = asRecord(args); + if (name.toLowerCase() === "bash") { + const command = typeof values?.command === "string" ? values.command : null; + if (!command) return undefined; + return toolFileRefsFromShellCommand({ + command, + cwd, + contextTreePath, + contextTreeRepoUrl, + contextTreeBranch, + }); + } + const rawPath = + typeof values?.path === "string" + ? values.path + : typeof values?.filePath === "string" + ? values.filePath + : typeof values?.file_path === "string" + ? values.file_path + : null; + if (!rawPath) return undefined; + const absolutePath = isAbsolute(rawPath) ? resolve(rawPath) : resolve(cwd, rawPath); + const repoRelativePath = resolveContextTreeRelativePath(absolutePath, { + contextTreePath, + contextTreeRepoUrl, + }); + const write = /^(edit|write|patch)$/i.test(name); + return [ + { + origin: write ? "file_change" : "tool_arg", + localPath: rawPath, + pathKind: "file", + ...(contextTreeRepoUrl && repoRelativePath && repoRelativePath !== "/" + ? { + repoUrl: contextTreeRepoUrl, + ...(contextTreeBranch ? { repoBranch: contextTreeBranch } : {}), + repoRelativePath, + } + : {}), + }, + ]; + } + + async function ensureDbReady(input: { + activeBinary: string; + env: Record; + workspaceCwd: string; + sessionCtx: SessionContext; + abortSignal: AbortSignal; + turnGeneration: number; + }): Promise { + const dataHome = input.env.XDG_DATA_HOME ?? input.env.APPDATA ?? input.env.HOME ?? ""; + const key = `${input.activeBinary}\0${dataHome}`; + let gate = dbGatePromises.get(key); + if (!gate) { + gate = (async () => { + const outcome = await runProcess({ + command: input.activeBinary, + args: ["db", "SELECT 1 AS ready", "--format", "json"], + env: input.env, + workspaceCwd: input.workspaceCwd, + sessionCtx: input.sessionCtx, + abortSignal: input.abortSignal, + timeoutMs: DB_GATE_TIMEOUT_MS, + turnGeneration: input.turnGeneration, + label: "opencode database readiness", + }); + if (outcome.spawnError || outcome.exitCode !== 0) { + throw ( + outcome.spawnError ?? + new Error(`OpenCode database readiness failed (${outcome.exitCode}): ${outcome.stderrTail}`) + ); + } + })().catch((error) => { + dbGatePromises.delete(key); + throw error; + }); + dbGatePromises.set(key, gate); + } + await gate; + } + + async function ensureSupportedVersion(input: { + activeBinary: string; + env: Record; + workspaceCwd: string; + sessionCtx: SessionContext; + abortSignal: AbortSignal; + turnGeneration: number; + }): Promise { + if (versionReady) return; + const outcome = await runProcess({ + command: input.activeBinary, + args: ["--version"], + env: input.env, + workspaceCwd: input.workspaceCwd, + sessionCtx: input.sessionCtx, + abortSignal: input.abortSignal, + timeoutMs: DB_GATE_TIMEOUT_MS, + turnGeneration: input.turnGeneration, + label: "opencode compatible-version gate", + }); + const version = parseOpenCodeVersionOutput(`${outcome.stdoutTail}\n${outcome.stderrTail}`); + if (outcome.spawnError || outcome.exitCode !== 0 || !isSupportedOpenCodeVersion(version)) { + const detail = redactErrorPreview( + outcome.spawnError?.message || outcome.stderrTail || outcome.stdoutTail || `exit ${outcome.exitCode}`, + 800, + ); + throw new Error( + `OpenCode runtime provider mismatch: unsupported version. First Tree requires ${OPENCODE_SUPPORTED_VERSION_RANGE}; ` + + `observed ${version ?? "no parseable version"}. ${detail}`, + ); + } + versionReady = true; + } + + function adoptSessionId(sessionCtx: SessionContext, id: string): void { + if (providerSessionId === id) return; + const synthetic = pendingSyntheticId; + providerSessionId = id; + if (synthetic) { + pendingSyntheticId = null; + sessionCtx.replaceSessionId?.(id, "opencode_session_id_confirmed"); + if (cwd) { + const baseline = readSessionBriefingFingerprint(cwd, synthetic); + if (baseline) writeSessionBriefingFingerprint(cwd, id, baseline); + } + } + } + + function emitProviderTurnSettlementEvent(sessionCtx: SessionContext, settlement: ProviderAttemptSettlement): void { + sessionCtx.emitEvent({ + kind: "error", + payload: { + source: "runtime", + message: encodeProviderRetryEventMessage(settlement.eventPayload), + }, + }); + } + + function emitQueuedUnsafeDiscoveryBlocked( + sessionCtx: SessionContext, + error: Error, + attempt: number, + delayMs: number, + ): void { + const classification = classifyProviderFailure(error, { + provider: runtimeProvider, + scope: "provider_turn", + source: "bind", + }); + const payload = buildProviderRetryEvent({ + event: "provider_retry_scheduled", + provider: runtimeProvider, + scope: "provider_turn", + classification, + decision: { + action: "retry", + delayMs, + reasonCode: classification.reasonCode, + attempt, + retryMode: "background", + replaySafety: "pre_provider", + userSeverity: "warning", + }, + messagePreview: + "Queued delivery remains unacknowledged because First Tree cannot safely reconcile managed OpenCode Skills. " + + `${error.message}`, + }); + sessionCtx.emitEvent({ + kind: "error", + payload: { + source: "runtime", + message: encodeProviderRetryEventMessage(payload), + }, + }); + } + + function consumedReasonForProviderSettlement(settlement: ProviderAttemptSettlement): TurnConsumedErrorReason { + return settlement.decision.action === "stop" && settlement.decision.terminalKind === "capacity_wait_required" + ? "capacity_wait_required" + : settlement.decision.action === "stop" && settlement.decision.terminalKind === "exhausted" + ? "provider_retry_exhausted" + : settlement.decision.reasonCode; + } + + async function settleFailure(input: { + failure: string; + spawnError?: Error; + state: Pick; + sessionCtx: SessionContext; + messages: readonly SessionMessage[]; + token: DeliveryToken; + turnGeneration: number; + }): Promise { + const attemptKey = deliveryAttemptKey(input.sessionCtx, input.messages); + const replaySafety = input.state.sawUnsafeTool + ? "unsafe" + : input.state.text.length > 0 + ? "user_visible" + : input.state.sawProviderActivity + ? "pre_visible" + : "pre_provider"; + const displayMessage = isOpenCodeAuthError(input.failure) + ? formatAuthHint("opencode", input.failure) + : input.failure; + const attempt = new ProviderAttempt({ + provider: runtimeProvider, + scope: "provider_turn", + source: input.spawnError ? "sdk" : "stream", + replaySafety, + }); + attempt.recordSignal({ + kind: input.spawnError ? "local_error" : "provider_error", + error: input.spawnError ?? input.failure, + messagePreview: displayMessage, + }); + const attemptNumber = nextProviderAttempt( + attemptKey, + () => input.sessionCtx.hasPendingDelivery?.(input.messages) ?? true, + ); + const settlement = attempt.settle({ attempt: attemptNumber }); + if (!settlement) { + input.token.retry(input.messages, "opencode_unclassified_failure"); + return false; + } + + emitProviderTurnSettlementEvent(input.sessionCtx, settlement); + input.sessionCtx.emitEvent({ + kind: "error", + payload: { source: "sdk", message: displayMessage }, + }); + input.sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "error" } }); + if (settlement.decision.action === "retry") { + const delayAbort = new AbortController(); + if (generation === input.turnGeneration && sessionActive) { + currentAbort = delayAbort; + } + const completedDelay = await retrySleep(settlement.decision.delayMs, delayAbort.signal); + if ( + completedDelay === false || + delayAbort.signal.aborted || + generation !== input.turnGeneration || + !sessionActive + ) { + return false; + } + input.token.retry(input.messages, settlement.decision.reasonCode); + if (input.state.sawProviderActivity) { + input.sessionCtx.failSessionForRecovery?.("opencode_turn_retryable_failure", providerSessionId ?? undefined); + } + return false; + } + const completion = await input.token.complete( + input.messages, + consumedErrorOutcome(consumedReasonForProviderSettlement(settlement)), + ); + if (completion === "retry") return false; + providerTurnFailureAttempts.delete(attemptKey); + pendingChatContextPrompt = null; + return true; + } + + async function runTurn( + prompt: string, + sessionCtx: SessionContext, + messages: readonly SessionMessage[], + token: DeliveryToken, + unsafeDiscoveryAction: "retry" | "throw" = "retry", + ): Promise { + const workspaceCwd = cwd; + const activeBinary = binary; + const activeProjectionScope = projectionScope; + const activeManagedAgentName = managedAgentName; + const activePrivateConfigLease = privateConfigLease; + if ( + !workspaceCwd || + !activeBinary || + !activeProjectionScope || + !activeManagedAgentName || + !activePrivateConfigLease || + !sessionActive + ) { + token.retry(messages, sessionActive ? "opencode_not_prepared" : "opencode_session_inactive"); + return false; + } + const turnGeneration = ++generation; + const abort = new AbortController(); + currentAbort = abort; + let observedState: TurnState | null = null; + const promise = (async () => { + const { payload } = await refreshProjection(sessionCtx); + const env = buildEnv(sessionCtx, payload); + await ensureSupportedVersion({ + activeBinary, + env, + workspaceCwd, + sessionCtx, + abortSignal: abort.signal, + turnGeneration, + }); + await ensureDbReady({ + activeBinary, + env, + workspaceCwd, + sessionCtx, + abortSignal: abort.signal, + turnGeneration, + }); + if (abort.signal.aborted || generation !== turnGeneration || !sessionActive) return false; + + const oneShotPrompt = pendingChatContextPrompt; + const providerPrompt = oneShotPrompt ? `${oneShotPrompt}\n\n${prompt}` : prompt; + const expectedSessionId = providerSessionId; + const state: TurnState = { + parser: new OpenCodeStreamParser(), + sessionIds: new Set(), + terminalReasons: [], + errors: [], + text: [], + usage: null, + sawProviderActivity: false, + sawUnsafeTool: false, + protocolDiagnostics: [], + }; + observedState = state; + token.processingStarted(messages); + const timeout = setTimeout(() => abort.abort(), turnTimeoutMs); + timeout.unref?.(); + let outcome: ProcessOutcome; + try { + const configProjection = configProjector( + env, + buildOpenCodeConfigContent({ + payload, + managedAgentName: activeManagedAgentName, + scope: activeProjectionScope, + }), + { fileStore: activePrivateConfigLease }, + ); + try { + outcome = await runProcess({ + command: activeBinary, + args: buildOpenCodeTurnArgs({ + cwd: workspaceCwd, + model: payload.model, + resumeSessionId: expectedSessionId, + managedAgentName: activeManagedAgentName, + }), + prompt: `${providerPrompt}\n`, + env: configProjection.env, + workspaceCwd, + state, + sessionCtx, + abortSignal: abort.signal, + timeoutMs: turnTimeoutMs, + turnGeneration, + label: `opencode turn ${sessionCtx.chatId}`, + }); + } finally { + configProjection.cleanup(); + } + } finally { + clearTimeout(timeout); + } + + if (abort.signal.aborted || generation !== turnGeneration || !sessionActive) { + return settleFailure({ + failure: "OpenCode turn aborted or timed out before a safe terminal event", + spawnError: new Error("OpenCode turn aborted or timed out"), + state, + sessionCtx, + messages, + token, + turnGeneration, + }); + } + + const ids = [...state.sessionIds]; + const protocolErrors: string[] = []; + if (ids.length !== 1) protocolErrors.push(`expected one session ID, observed ${ids.length}`); + if (expectedSessionId && ids[0] !== expectedSessionId) { + protocolErrors.push(`resume session mismatch: expected ${expectedSessionId}, observed ${ids[0] ?? "none"}`); + } + if (state.terminalReasons.length !== 1) { + protocolErrors.push(`expected one terminal step_finish event, observed ${state.terminalReasons.length}`); + } + if (state.errors.length > 0) protocolErrors.push(...state.errors); + if (state.protocolDiagnostics.length > 0) { + protocolErrors.push( + `unsupported or malformed OpenCode JSONL (${state.protocolDiagnostics.length} line${ + state.protocolDiagnostics.length === 1 ? "" : "s" + })`, + ); + } + if (/agent\s+["'][^"']+["']\s+not found.*falling back to default agent/i.test(outcome.stderrTail)) { + protocolErrors.push("managed agent was not selected"); + } + + const success = !outcome.spawnError && outcome.exitCode === 0 && protocolErrors.length === 0; + if (success) { + const id = ids[0]; + if (!id) throw new Error("OpenCode success without session ID"); + adoptSessionId(sessionCtx, id); + const finalText = state.text.join(""); + for (const chunk of chunkAssistantText(finalText)) { + sessionCtx.emitEvent({ kind: "assistant_text", payload: { text: chunk } }); + } + if (state.usage) { + sessionCtx.emitEvent({ + kind: "token_usage", + payload: { + provider: "opencode", + model: payload.model || "opencode-default", + inputTokens: state.usage.inputTokens, + cachedInputTokens: state.usage.cachedInputTokens, + outputTokens: state.usage.outputTokens, + }, + }); + } + try { + await sessionCtx.forwardResult(finalText); + } catch (error) { + sessionCtx.emitEvent({ + kind: "error", + payload: { + source: "runtime", + message: `forwardResult failed: ${error instanceof Error ? error.message : String(error)}`.slice(0, 2000), + }, + }); + sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "error" } }); + const completion = await token.complete(messages, { + status: "error", + completion: "consumed", + reason: "forward_failed", + }); + if (completion === "retry") return false; + providerTurnFailureAttempts.delete(deliveryAttemptKey(sessionCtx, messages)); + pendingChatContextPrompt = null; + return true; + } + sessionCtx.emitEvent({ kind: "turn_end", payload: { status: "success" } }); + const completion = await token.complete(messages, { status: "success" }); + if (completion === "retry") return false; + providerTurnFailureAttempts.delete(deliveryAttemptKey(sessionCtx, messages)); + if (pendingChatContextPrompt === oneShotPrompt) pendingChatContextPrompt = null; + return true; + } + + const rawFailure = [ + ...protocolErrors, + outcome.spawnError?.message, + outcome.stderrTail, + outcome.exitCode === null ? `signal ${outcome.signal ?? "unknown"}` : `exit ${outcome.exitCode}`, + ] + .filter((value): value is string => Boolean(value?.trim())) + .join("\n") + .slice(0, 2000); + const failure = redactErrorPreview(rawFailure, 2000); + return settleFailure({ + failure, + ...(outcome.spawnError ? { spawnError: outcome.spawnError } : {}), + state, + sessionCtx, + messages, + token, + turnGeneration, + }); + })(); + currentTurnPromise = promise.then( + () => {}, + () => {}, + ); + try { + return await promise; + } catch (error) { + if (isManagedSkillsUnsafeDiscoveryError(error)) { + if (unsafeDiscoveryAction === "throw") throw error; + token.retry(messages, "opencode_managed_skills_unsafe"); + sessionCtx.log(`blocked provider turn: ${error.message}`); + return false; + } + const failure = redactErrorPreview(error instanceof Error ? error.message : String(error), 2000); + return await settleFailure({ + failure, + spawnError: error instanceof Error ? error : new Error(String(error)), + state: observedState ?? { sawProviderActivity: false, sawUnsafeTool: false, text: [] }, + sessionCtx, + messages, + token, + turnGeneration, + }); + } finally { + if (generation === turnGeneration) { + currentAbort = null; + currentTurnPromise = null; + scheduleDrain(); + } + } + } + + async function prepareSession(sessionCtx: SessionContext): Promise<{ + briefing: string; + workspaceCwd: string; + }> { + if (isLandingCampaignTrialAgentMetadata(sessionCtx.agent.metadata)) { + throw new Error("landing campaign trial agents require the codex app-server runtime"); + } + ctx = sessionCtx; + cwd = acquireAgentHome(workspaceRoot); + projectionScope = stableOpenCodeScope(sessionCtx.agent.agentId); + managedAgentName = `first-tree-${projectionScope}`; + const resolution = resolveBinary(process.env); + if (!resolution.ok) { + throw new Error(resolution.error); + } + binary = resolution.binary; + sessionCtx.log(`OpenCode binary: ${resolution.binary}`); + const { briefing } = await refreshProjection(sessionCtx); + const chatContext = await fetchChatContextOrLog(sessionCtx); + pendingChatContextPrompt = [renderRuntimeOutputContract(), renderChatContextPrompt(chatContext)] + .filter(Boolean) + .join("\n\n"); + privateConfigLease ??= await acquireOpenCodePrivateConfigLease({ + workspace: cwd, + callerScope: stableOpenCodeScope(`${sessionCtx.agent.agentId}\0${sessionCtx.chatId}`), + handlerId: handlerGenerationId, + }); + sessionActive = true; + return { briefing, workspaceCwd: cwd }; + } + + function finishDrainingBatch(batch: QueuedDelivery[]): void { + if (unsafeDiscoveryParkedBatch === batch) unsafeDiscoveryParkedBatch = null; + if (drainingBatch === batch) drainingBatch = null; + } + + function retryDrainingBatch(batch: QueuedDelivery[], reason: string): void { + if (drainingBatch !== batch && unsafeDiscoveryParkedBatch !== batch) return; + finishDrainingBatch(batch); + for (const entry of batch) entry.token.retry(entry.message, reason); + } + + async function runQueued(drained: QueuedDelivery[], sessionCtx: SessionContext): Promise { + const token = drained[0]?.token; + if (!token) return; + const messages = drained.map((entry) => entry.message); + const parts: string[] = []; + try { + for (const message of messages) parts.push(await sessionCtx.formatInboundContent(message)); + } catch (error) { + sessionCtx.log(`OpenCode queued formatting failed: ${error instanceof Error ? error.message : String(error)}`); + retryDrainingBatch(drained, "opencode_queued_format_failed"); + return; + } + + let unsafeAttempt = 0; + while (sessionActive && drainingBatch === drained) { + const sessionKey = providerSessionId ?? pendingSyntheticId; + let fingerprint: string | null = null; + try { + const turnParts = [...parts]; + if (cwd && sessionKey) { + const projection = await refreshProjection(sessionCtx); + fingerprint = computeBriefingFingerprint(projection.briefing); + if (readSessionBriefingFingerprint(cwd, sessionKey) !== fingerprint) { + turnParts.unshift(buildBriefingUpdateNotice(join(cwd, "AGENTS.md"))); + } + } + const delivered = await runTurn(turnParts.join("\n\n"), sessionCtx, messages, token, "throw"); + if (delivered && fingerprint && cwd && sessionKey) { + writeSessionBriefingFingerprint(cwd, providerSessionId ?? sessionKey, fingerprint); + } + finishDrainingBatch(drained); + return; + } catch (error) { + if (!isManagedSkillsUnsafeDiscoveryError(error)) throw error; + if (!sessionActive || drainingBatch !== drained) return; + + unsafeDiscoveryParkedBatch = drained; + unsafeAttempt += 1; + const delayMs = queuedUnsafeDiscoveryRetryDelayMs(unsafeAttempt); + emitQueuedUnsafeDiscoveryBlocked(sessionCtx, error, unsafeAttempt, delayMs); + sessionCtx.log( + `OpenCode queued turn blocked by unsafe managed-skill discovery; retrying in ${delayMs}ms: ${error.message}`, + ); + const waitAbort = new AbortController(); + unsafeDiscoveryWaitAbort = waitAbort; + const completedDelay = await unsafeDiscoverySleep(delayMs, waitAbort.signal); + if (unsafeDiscoveryWaitAbort === waitAbort) unsafeDiscoveryWaitAbort = null; + if (!completedDelay) { + if (!drainCancellationReason && sessionActive && drainingBatch === drained) { + throw new Error("OpenCode queued unsafe-discovery wait ended without lifecycle cancellation"); + } + return; + } + } + } + } + + function scheduleDrain(): void { + if ( + drainScheduled || + drainInProgress || + drainingBatch || + queue.length === 0 || + !ctx || + !sessionActive || + currentTurnPromise || + initialTurnPreparing + ) { + return; + } + drainScheduled = true; + setImmediate(() => { + drainScheduled = false; + if ( + drainInProgress || + drainingBatch || + queue.length === 0 || + !ctx || + !sessionActive || + currentTurnPromise || + initialTurnPreparing + ) { + scheduleDrain(); + return; + } + const drained = queue.splice(0); + const sessionCtx = ctx; + drainingBatch = drained; + drainInProgress = true; + const drainPromise = runQueued(drained, sessionCtx) + .catch((error) => { + const cancellationReason = drainCancellationReason; + if (cancellationReason) { + retryDrainingBatch(drained, cancellationReason); + return; + } + sessionCtx.log(`OpenCode queued turn failed: ${error instanceof Error ? error.message : String(error)}`); + retryDrainingBatch(drained, "opencode_queued_turn_failed"); + }) + .finally(() => { + if (!drainCancellationReason && drainingBatch === drained) finishDrainingBatch(drained); + drainInProgress = false; + if (currentDrainPromise === drainPromise) currentDrainPromise = null; + scheduleDrain(); + }); + currentDrainPromise = drainPromise; + void drainPromise; + }); + } + + function retryQueue(reason: string): void { + for (const entry of queue.splice(0)) entry.token.retry(entry.message, reason); + } + + return { + async start(message, sessionCtx, token) { + const explicit = token !== undefined; + const deliveryToken = token ?? deliveryTokenFromSessionContext(sessionCtx); + initialTurnPreparing = true; + let completed = false; + let delivered = false; + let briefing: string; + let workspaceCwd: string; + try { + ({ briefing, workspaceCwd } = await prepareSession(sessionCtx)); + const prompt = await sessionCtx.formatInboundContent(message); + delivered = await runTurn(prompt, sessionCtx, [message], deliveryToken); + completed = delivered; + } finally { + initialTurnPreparing = false; + if (completed) scheduleDrain(); + } + if (!providerSessionId) pendingSyntheticId = `${OPENCODE_PENDING_SESSION_PREFIX}${randomUUID()}`; + const sessionId = providerSessionId ?? pendingSyntheticId; + if (!sessionId) throw new Error("OpenCode session id unresolved"); + if (delivered) { + writeSessionBriefingFingerprint(workspaceCwd, sessionId, computeBriefingFingerprint(briefing)); + } + return explicit ? { sessionId, route: { kind: "owned", mode: "processing" } } : sessionId; + }, + + async resume(message, sessionId, sessionCtx, token) { + const explicit = token !== undefined; + const deliveryToken = token ?? deliveryTokenFromSessionContext(sessionCtx); + initialTurnPreparing = true; + let briefing: string; + let workspaceCwd: string; + try { + ({ briefing, workspaceCwd } = await prepareSession(sessionCtx)); + } catch (error) { + initialTurnPreparing = false; + throw error; + } + if (isOpenCodePendingSessionId(sessionId)) { + pendingSyntheticId = sessionId; + providerSessionId = null; + } else { + providerSessionId = sessionId; + pendingSyntheticId = null; + } + const fingerprint = computeBriefingFingerprint(briefing); + if (message) { + let prompt = await sessionCtx.formatInboundContent(message); + if (readSessionBriefingFingerprint(workspaceCwd, sessionId) !== fingerprint) { + prompt = `${buildBriefingUpdateNotice(join(workspaceCwd, "AGENTS.md"))}\n\n${prompt}`; + } + try { + const delivered = await runTurn(prompt, sessionCtx, [message], deliveryToken); + if (delivered) { + writeSessionBriefingFingerprint(workspaceCwd, providerSessionId ?? sessionId, fingerprint); + } + } finally { + initialTurnPreparing = false; + scheduleDrain(); + } + } else { + initialTurnPreparing = false; + scheduleDrain(); + } + const effectiveId = providerSessionId ?? pendingSyntheticId ?? sessionId; + return explicit + ? { sessionId: effectiveId, route: message ? { kind: "owned", mode: "processing" } : null } + : effectiveId; + }, + + inject(message, token) { + if (!ctx) return { kind: "rejected", reason: "no_active_context", retryable: true }; + queue.push({ message, token: token ?? deliveryTokenFromSessionContext(ctx) }); + scheduleDrain(); + return { kind: "owned", mode: "queued" }; + }, + + async suspend(reason) { + const recoveryReason = reason ?? "opencode_suspend_before_terminal"; + sessionActive = false; + drainCancellationReason = recoveryReason; + generation++; + currentAbort?.abort(); + unsafeDiscoveryWaitAbort?.abort(); + await Promise.all([currentTurnPromise, currentDrainPromise]); + if (drainingBatch) retryDrainingBatch(drainingBatch, recoveryReason); + retryQueue(recoveryReason); + drainCancellationReason = null; + unsafeDiscoveryWaitAbort = null; + currentAbort = null; + currentTurnPromise = null; + initialTurnPreparing = false; + }, + + async shutdown(reason) { + const recoveryReason = reason ?? "opencode_shutdown_before_terminal"; + sessionActive = false; + drainCancellationReason = recoveryReason; + generation++; + currentAbort?.abort(); + unsafeDiscoveryWaitAbort?.abort(); + await Promise.all([currentTurnPromise, currentDrainPromise]); + if (drainingBatch) retryDrainingBatch(drainingBatch, recoveryReason); + retryQueue(recoveryReason); + drainCancellationReason = null; + unsafeDiscoveryWaitAbort = null; + currentAbort = null; + currentTurnPromise = null; + cwd = null; + ctx = null; + activeConfig = null; + teamSkills = []; + binary = null; + providerSessionId = null; + pendingSyntheticId = null; + versionReady = false; + await privateConfigLease?.close(); + projectionScope = null; + managedAgentName = null; + privateConfigLease = null; + initialTurnPreparing = false; + pendingChatContextPrompt = null; + queue.length = 0; + }, + } satisfies AgentHandler; +}; + +function isReadOnlyTool(name: string): boolean { + return /^(read|glob|grep|list|ls|webfetch|websearch)$/i.test(name); +} + +export function stableOpenCodeScope(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; +} diff --git a/packages/client/src/handlers/opencode/parser.ts b/packages/client/src/handlers/opencode/parser.ts new file mode 100644 index 000000000..66217b1b1 --- /dev/null +++ b/packages/client/src/handlers/opencode/parser.ts @@ -0,0 +1,188 @@ +export type OpenCodeUsage = { + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +}; + +export type OpenCodeStreamEvent = + | { kind: "session"; sessionId: string } + | { kind: "text"; text: string } + | { + kind: "tool"; + toolUseId: string; + name: string; + status: "pending" | "ok" | "error"; + args: unknown; + resultPreview?: string; + } + | { kind: "usage"; usage: OpenCodeUsage } + | { kind: "terminal"; reason: string } + | { kind: "error"; message: string } + | { kind: "reasoning" } + | { kind: "unknown"; note: string }; + +const PREVIEW_LIMIT = 400; + +function record(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; +} + +function string(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function number(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function preview(value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) return value.slice(0, PREVIEW_LIMIT); + if (value === undefined || value === null) return undefined; + try { + return JSON.stringify(value).slice(0, PREVIEW_LIMIT); + } catch { + return String(value).slice(0, PREVIEW_LIMIT); + } +} + +function usage(value: unknown): OpenCodeUsage | null { + const row = record(value); + if (!row) return null; + const cache = record(row.cache); + const input = number(row.input) ?? number(row.inputTokens) ?? number(row.input_tokens); + const cached = + number(row.cacheRead) ?? + number(row.cache_read) ?? + number(row.cachedInputTokens) ?? + number(row.cached_input_tokens) ?? + number(cache?.read); + const output = number(row.output) ?? number(row.outputTokens) ?? number(row.output_tokens); + if (input === null && cached === null && output === null) return null; + return { + inputTokens: Math.max(0, input ?? 0), + cachedInputTokens: Math.max(0, cached ?? 0), + outputTokens: Math.max(0, output ?? 0), + }; +} + +function sessionId(row: Record, part: Record | null): string | null { + return string(row.sessionID) ?? string(row.sessionId) ?? string(part?.sessionID) ?? string(part?.sessionId); +} + +/** Parse one OpenCode `run --format json` line without throwing. */ +export function parseOpenCodeStreamLine(line: string): OpenCodeStreamEvent[] { + const raw = line.trim(); + if (!raw) return []; + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + return [{ kind: "unknown", note: "unparsable JSONL line" }]; + } + const row = record(value); + if (!row) return [{ kind: "unknown", note: "non-object JSONL value" }]; + const part = record(row.part); + const events: OpenCodeStreamEvent[] = []; + const id = sessionId(row, part); + if (id) events.push({ kind: "session", sessionId: id }); + + switch (string(row.type)) { + case "text": { + const text = string(part?.text) ?? string(row.text); + if (!text) events.push({ kind: "unknown", note: "text event missing text" }); + else events.push({ kind: "text", text }); + break; + } + case "tool_use": { + const state = record(part?.state); + const toolName = string(part?.tool); + if (!part || !state || !toolName) { + events.push({ kind: "unknown", note: "tool_use event missing part, state, or tool name" }); + break; + } + const metadata = record(state?.metadata); + const statusValue = string(state?.status) ?? "pending"; + const status = + statusValue === "completed" || statusValue === "ok" + ? (number(metadata?.exit) ?? 0) === 0 + ? "ok" + : "error" + : statusValue === "error" || statusValue === "failed" + ? "error" + : "pending"; + events.push({ + kind: "tool", + toolUseId: + string(part?.id) ?? + string(part?.callID) ?? + string(part?.callId) ?? + `${toolName}:${string(part?.messageID) ?? "unknown"}`, + name: toolName, + status, + args: state?.input ?? part?.input ?? {}, + ...(status === "pending" ? {} : { resultPreview: preview(state?.output ?? state?.error) }), + }); + break; + } + case "step_finish": { + if (!part) { + events.push({ kind: "unknown", note: "step_finish event missing part" }); + break; + } + const tokenUsage = usage(part?.tokens ?? row.tokens); + if (tokenUsage) events.push({ kind: "usage", usage: tokenUsage }); + const reason = string(part?.reason); + if (!reason) events.push({ kind: "unknown", note: "step_finish event missing reason" }); + else if (reason !== "tool-calls") events.push({ kind: "terminal", reason }); + break; + } + case "error": { + const error = record(part?.error ?? row.error); + events.push({ + kind: "error", + message: + string(error?.message) ?? + string(part?.message) ?? + string(row.message) ?? + preview(part?.error ?? row.error) ?? + "OpenCode emitted an error event", + }); + break; + } + case "step_start": + break; + case "reasoning": + events.push({ kind: "reasoning" }); + break; + default: + events.push({ + kind: "unknown", + note: `unknown event type ${String(row.type)}`, + }); + } + return events; +} + +export class OpenCodeStreamParser { + private buffer = ""; + + push(chunk: string): OpenCodeStreamEvent[] { + this.buffer += chunk; + const events: OpenCodeStreamEvent[] = []; + let start = 0; + for (;;) { + const newline = this.buffer.indexOf("\n", start); + if (newline < 0) break; + events.push(...parseOpenCodeStreamLine(this.buffer.slice(start, newline))); + start = newline + 1; + } + if (start > 0) this.buffer = this.buffer.slice(start); + return events; + } + + flush(): OpenCodeStreamEvent[] { + const tail = this.buffer; + this.buffer = ""; + return tail.trim() ? parseOpenCodeStreamLine(tail) : []; + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index b2bd81e0b..e967bf356 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -16,6 +16,12 @@ export { export { detectStreamApiError, StreamApiTransientError } from "./handlers/claude-code.js"; export { registerBuiltinHandlers } from "./handlers/index.js"; export { createKimiCodeHandler, formatKimiCodeError, kimiToolIsReadOnly } from "./handlers/kimi-code.js"; +export { + buildOpenCodeConfigContent, + buildOpenCodeTurnArgs, + createOpenCodeHandler, + mapOpenCodeMcpServers, +} from "./handlers/opencode/index.js"; export { applyClientLoggerConfig, captureClientException, @@ -62,6 +68,8 @@ export { revalidateCapabilities, shouldFullReprobe, } from "./runtime/capabilities/index.js"; +export { probeOpenCodeCapability } from "./runtime/capabilities/opencode.js"; + export type { AdoptOptions, ChildCategory, @@ -107,6 +115,24 @@ export { getHandlerFactory, hasHandler, registerHandler } from "./runtime/handle export type { BuildImageAttachmentsOptions, BuildMessageImageSnapshotsResult } from "./runtime/image-snapshots.js"; export { buildMessageImageSnapshots } from "./runtime/image-snapshots.js"; export { InputController } from "./runtime/input-controller.js"; +export { + findOpenCodeExecutableOnPath, + formatOpenCodeBinaryMissingMessage, + isSupportedOpenCodeVersion, + OPENCODE_INSTALL_COMMAND, + OPENCODE_LOGIN_COMMAND, + OPENCODE_MINIMUM_VERSION, + OPENCODE_SUPPORTED_VERSION_RANGE, + parseOpenCodeVersionOutput, + resolveOpenCodeRuntimeBinary, +} from "./runtime/opencode-binary.js"; +export { + createDefaultProviderProcessSupervisor, + type ProviderProcessSpec, + ProviderProcessSupervisionUnsupportedError, + type ProviderProcessSupervisor, + type SupervisedProviderProcess, +} from "./runtime/provider-process-supervisor.js"; export type { AgentRuntimeOptions } from "./runtime/runtime.js"; export { AgentRuntime } from "./runtime/runtime.js"; // Runtime-auth (browser OAuth) diff --git a/packages/client/src/runtime/capabilities/discover-models.ts b/packages/client/src/runtime/capabilities/discover-models.ts index 199b9407a..341425640 100644 --- a/packages/client/src/runtime/capabilities/discover-models.ts +++ b/packages/client/src/runtime/capabilities/discover-models.ts @@ -204,6 +204,12 @@ export async function discoverProviderModels( return discoverCursorModels(deps); case "kimi-code": return discoverKimiModels(deps); + case "opencode": + return unavailable( + provider, + "OpenCode model discovery is not enabled in V1; enter the provider-native provider/model id", + deps, + ); case "claude-code": case "claude-code-tui": case "codex": diff --git a/packages/client/src/runtime/capabilities/index.ts b/packages/client/src/runtime/capabilities/index.ts index f1e84807e..93123e4dc 100644 --- a/packages/client/src/runtime/capabilities/index.ts +++ b/packages/client/src/runtime/capabilities/index.ts @@ -9,6 +9,7 @@ import { probeClaudeCodeTuiCapability } from "./claude-code-tui.js"; import { probeCodexCapability } from "./codex.js"; import { probeCursorCapability } from "./cursor.js"; import { probeKimiCodeCapability } from "./kimi-code.js"; +import { probeOpenCodeCapability } from "./opencode.js"; /** Periodic full re-probe ceiling: re-detect at most this often on reconnect to * catch silent drift (a provider uninstalled while connected). Detection is @@ -19,7 +20,7 @@ export const REPROBE_MAX_AGE_MS = 24 * 60 * 60 * 1000; * temporarily disabled. Drives whether a daemon's advertised snapshot still has * a provider worth re-probing (see {@link hasNonOkProvider}). */ export const PROBED_RUNTIME_PROVIDERS: readonly RuntimeProvider[] = ( - ["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code"] as const + ["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code", "opencode"] as const ).filter((p) => isRuntimeProviderEnabled(p)); /** First delay before the daemon-side degraded-capability re-probe fires. Short @@ -92,6 +93,7 @@ export async function probeCapabilities(): Promise { if (isRuntimeProviderEnabled("codex")) probes.push(["codex", probeCodexCapability()]); if (isRuntimeProviderEnabled("cursor")) probes.push(["cursor", probeCursorCapability()]); if (isRuntimeProviderEnabled("kimi-code")) probes.push(["kimi-code", probeKimiCodeCapability()]); + if (isRuntimeProviderEnabled("opencode")) probes.push(["opencode", probeOpenCodeCapability()]); return aggregate(probes); } diff --git a/packages/client/src/runtime/capabilities/opencode.ts b/packages/client/src/runtime/capabilities/opencode.ts new file mode 100644 index 000000000..9ab2fad7f --- /dev/null +++ b/packages/client/src/runtime/capabilities/opencode.ts @@ -0,0 +1,43 @@ +import type { CapabilityEntry } from "@first-tree/shared"; +import { findOpenCodeExecutableOnPath, formatOpenCodeBinaryMissingMessage } from "../opencode-binary.js"; +import { supportsDefaultProviderProcessSupervision } from "../provider-process-supervisor.js"; +import { type DetectOutcome, runDetect } from "./detect.js"; + +export type OpenCodeProbeDeps = { + findOnPath?: (env?: Record) => string | null; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; +}; + +/** + * Resolve-only probe plus an execution-support gate. It deliberately does not + * launch OpenCode, inspect its config, or infer provider authentication. + * + * A resolved Windows binary stays visible in diagnostics, but is not + * advertised as available while the built-in supervisor would reject it + * before spawn. + */ +export async function probeOpenCodeCapability(deps: OpenCodeProbeDeps = {}): Promise { + const env = deps.env ?? process.env; + const findOnPath = deps.findOnPath ?? findOpenCodeExecutableOnPath; + const detected = await runDetect(async (): Promise => { + const runtimePath = findOnPath(env); + if (runtimePath) return { installed: true, runtimeSource: "path", runtimePath }; + return { + installed: false, + error: formatOpenCodeBinaryMissingMessage("no opencode binary resolved on this host"), + }; + }); + if (detected.state !== "ok" || supportsDefaultProviderProcessSupervision(deps.platform)) { + return detected; + } + return { + ...detected, + state: "error", + available: false, + authenticated: false, + error: + "OpenCode is installed, but First Tree cannot run it on Windows until the client-wide " + + "pre-admission Job Object supervisor is available.", + }; +} diff --git a/packages/client/src/runtime/handler.ts b/packages/client/src/runtime/handler.ts index 4db548868..d89cedee1 100644 --- a/packages/client/src/runtime/handler.ts +++ b/packages/client/src/runtime/handler.ts @@ -106,7 +106,10 @@ export type ResumeResult = ResumeReceipt | string; export type DeliveryToken = { processingStarted(messages: SessionMessage | readonly SessionMessage[]): void; - complete(messages: SessionMessage | readonly SessionMessage[], outcome: TurnOutcome): Promise; + complete( + messages: SessionMessage | readonly SessionMessage[], + outcome: TurnOutcome, + ): Promise; retry(messages: SessionMessage | readonly SessionMessage[], reason: string): void; terminalRejected( messages: SessionMessage | readonly SessionMessage[], @@ -115,6 +118,19 @@ export type DeliveryToken = { ): Promise; }; +/** + * Observable completion custody for handlers whose one-shot provider payload + * may only advance after the inbox delivery really settles. `retry` means the + * completion path deliberately retained server custody (for example because a + * required durable runtime-failure notice could not be posted). + * + * `void` remains accepted on DeliveryToken.complete for legacy/test tokens; + * production SessionManager tokens always return an explicit disposition. + */ +export type DeliveryCompletionDisposition = "settled" | "retry"; +// biome-ignore lint/suspicious/noConfusingVoidType: legacy/test tokens intentionally resolve void. +export type DeliveryCompletionResult = DeliveryCompletionDisposition | void; + export function noopDeliveryToken(): DeliveryToken { return { processingStarted: () => {}, @@ -167,7 +183,10 @@ export type SessionContext = HandlerContext & { * The coordinator sends one ACK-through for the last message's * `inboxEntryId` and settles local ledger only after server confirmation. */ - finishTurn: (messages: SessionMessage | readonly SessionMessage[], outcome: TurnOutcome) => Promise; + finishTurn: ( + messages: SessionMessage | readonly SessionMessage[], + outcome: TurnOutcome, + ) => Promise; /** * Mark a concrete message or batch as abandoned by a retryable path @@ -176,6 +195,14 @@ export type SessionContext = HandlerContext & { */ retryTurn: (messages: SessionMessage | readonly SessionMessage[], reason: string) => void; + /** + * True while the delivery coordinator still owns at least one concrete + * inbox row in this message/fused batch. Retry windows use this as their + * durable cleanup authority; route-generation invalidation alone does not + * mean the unacked server delivery was abandoned. + */ + hasPendingDelivery?: (messages: SessionMessage | readonly SessionMessage[]) => boolean; + /** * Drop the current live handler after it has fenced an unknown-custody * provider failure and marked the affected inbox work for recovery. The diff --git a/packages/client/src/runtime/managed-skills.ts b/packages/client/src/runtime/managed-skills.ts index d3e0a1658..83a964b73 100644 --- a/packages/client/src/runtime/managed-skills.ts +++ b/packages/client/src/runtime/managed-skills.ts @@ -64,6 +64,7 @@ const PROVIDER_SKILL_ROOTS: Readonly> = { codex: ".agents/skills", cursor: ".cursor/skills", "kimi-code": ".kimi-code/skills", + opencode: ".opencode/skills", }; const ALLOWED_TARGET_ROOTS = new Set([...Object.values(PROVIDER_SKILL_ROOTS), LEGACY_RESOURCE_SKILLS_ROOT]); diff --git a/packages/client/src/runtime/managed-state.ts b/packages/client/src/runtime/managed-state.ts index 3355bff8d..5652bd840 100644 --- a/packages/client/src/runtime/managed-state.ts +++ b/packages/client/src/runtime/managed-state.ts @@ -375,6 +375,7 @@ function isSafePersistedTarget(target: string): boolean { root === ".claude/skills" || root === ".cursor/skills" || root === ".kimi-code/skills" || + root === ".opencode/skills" || root === ".first-tree/resources/skills"; return allowedRoot && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(parts.at(-1) ?? ""); } diff --git a/packages/client/src/runtime/opencode-binary.ts b/packages/client/src/runtime/opencode-binary.ts new file mode 100644 index 000000000..b0f120b2c --- /dev/null +++ b/packages/client/src/runtime/opencode-binary.ts @@ -0,0 +1,168 @@ +import { accessSync, constants, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path"; +import { prerelease, satisfies, valid } from "semver"; +import { wellKnownBinDirs } from "./install-locations.js"; +import { getLoginShellPathDirs } from "./login-shell-path.js"; + +/** Lowest compatible CLI validated by the runtime contract. */ +export const OPENCODE_MINIMUM_VERSION = "1.18.7"; +export const OPENCODE_SUPPORTED_VERSION_RANGE = ">=1.18.7 <2.0.0"; +/** Host-local OpenCode installation surfaced in setup and error copy. */ +export const OPENCODE_INSTALL_COMMAND = `npm install -g opencode-ai@^${OPENCODE_MINIMUM_VERSION}`; +export const OPENCODE_LOGIN_COMMAND = "opencode auth login"; + +export function formatOpenCodeBinaryMissingMessage(input: unknown): string { + const original = errorText(input).trim(); + const suffix = original ? ` Original error: ${original}` : ""; + return ( + "OpenCode CLI is missing on this machine. " + + "First Tree does not bundle or install OpenCode and never reads its provider credentials. " + + `Install it with \`${OPENCODE_INSTALL_COMMAND}\`, then complete provider-owned setup with ` + + `\`${OPENCODE_LOGIN_COMMAND}\` and retry.` + + suffix + ); +} + +export function isOpenCodeBinaryMissingError(input: unknown): boolean { + const text = errorText(input); + return /opencode cli is missing|opencode.*not (?:found|installed)/i.test(text); +} + +export type FindOpenCodeExecutableDeps = { + loginShellPathDirs?: () => string[]; + wellKnownDirs?: () => string[]; + platform?: NodeJS.Platform; + pathDelimiter?: string; +}; + +/** Existence-only resolver shared by capability detection and the handler. */ +export function findOpenCodeExecutableOnPath( + env: Record = process.env, + deps: FindOpenCodeExecutableDeps = {}, +): string | null { + const platform = deps.platform ?? process.platform; + const pathDelimiter = deps.pathDelimiter ?? (platform === "win32" ? ";" : delimiter); + const loginShellPathDirs = deps.loginShellPathDirs ?? getLoginShellPathDirs; + const configuredHome = env.HOME || env.USERPROFILE; + const home = configuredHome && configuredHome.length > 0 ? configuredHome : homedir(); + const wellKnownDirs = deps.wellKnownDirs ?? (() => wellKnownBinDirs(home)); + const seen = new Set(); + + const search = (dirs: readonly string[]): string | null => { + for (const dir of dirs) { + if (!dir) continue; + const base = isAbsolute(dir) ? dir : resolve(dir); + if (seen.has(base)) continue; + seen.add(base); + for (const candidate of openCodeExecutableCandidates(base, platform)) { + if (isExecutableFile(candidate, platform)) return candidate; + } + } + return null; + }; + + const pathValue = env.PATH ?? env.Path ?? env.path ?? ""; + const pathDirs = pathValue ? pathValue.split(pathDelimiter) : []; + const providerInstallDirs = [ + join(home, ".opencode", "bin"), + ...(platform === "win32" + ? [...(env.APPDATA ? [join(env.APPDATA, "npm")] : []), join(home, "AppData", "Roaming", "npm")] + : []), + ]; + return search(pathDirs) ?? search(providerInstallDirs) ?? search(wellKnownDirs()) ?? search(loginShellPathDirs()); +} + +export type OpenCodeRuntimeBinaryResolution = + | { ok: true; binary: string } + | { ok: false; error: string; transient: false }; + +export type OpenCodeRuntimeResolveDeps = { + findOnPath?: (env?: Record) => string | null; +}; + +/** + * Resolve only. Every OpenCode invocation, including the compatible-version gate, + * is launched later through the provider process supervisor so Windows never + * executes an unadmitted runtime process. + */ +export function resolveOpenCodeRuntimeBinary( + env: NodeJS.ProcessEnv = process.env, + deps: OpenCodeRuntimeResolveDeps = {}, +): OpenCodeRuntimeBinaryResolution { + const findOnPath = deps.findOnPath ?? findOpenCodeExecutableOnPath; + const binary = findOnPath(env); + if (!binary) { + return { + ok: false, + error: formatOpenCodeBinaryMissingMessage("no opencode binary resolved"), + transient: false, + }; + } + return { ok: true, binary }; +} + +/** + * npm exposes global Windows CLIs through `.cmd` shims, which cannot be + * launched with `shell: false` and cannot be pre-admitted as the OpenCode root + * process. Resolve the package's native executable beside the shim instead. + */ +function openCodeExecutableCandidates(base: string, platform: NodeJS.Platform): string[] { + if (platform !== "win32") return [join(base, "opencode")]; + const candidates = [join(base, "opencode.exe"), join(base, "node_modules", "opencode-ai", "bin", "opencode.exe")]; + if (basename(base).toLowerCase() === ".bin") { + candidates.push(join(dirname(base), "opencode-ai", "bin", "opencode.exe")); + } + return candidates; +} + +export function parseOpenCodeVersionOutput(output: string): string | null { + for (const token of whitespaceTokens(output)) { + if (token.length > 64) continue; + const normalized = valid(token); + if (!normalized || normalized !== token || prerelease(normalized) !== null) continue; + return normalized; + } + return null; +} + +export function isSupportedOpenCodeVersion(version: string | null): boolean { + if (!version || valid(version) !== version || prerelease(version) !== null) return false; + return satisfies(version, OPENCODE_SUPPORTED_VERSION_RANGE, { includePrerelease: false }); +} + +function whitespaceTokens(value: string): string[] { + const tokens: string[] = []; + let start = -1; + for (let index = 0; index <= value.length; index++) { + const code = index < value.length ? value.charCodeAt(index) : 32; + const whitespace = code === 9 || code === 10 || code === 11 || code === 12 || code === 13 || code === 32; + if (!whitespace && start < 0) { + start = index; + } else if (whitespace && start >= 0) { + tokens.push(value.slice(start, index)); + start = -1; + } + } + return tokens; +} + +function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean { + try { + if (!statSync(filePath).isFile()) return false; + accessSync(filePath, platform === "win32" ? constants.F_OK : constants.X_OK); + return true; + } catch { + return false; + } +} + +function errorText(input: unknown): string { + if (input instanceof Error) return `${input.name} ${input.message}`; + if (typeof input === "string") return input; + if (input && typeof input === "object" && "message" in input) { + const message = (input as { message?: unknown }).message; + if (typeof message === "string") return message; + } + return String(input); +} diff --git a/packages/client/src/runtime/opencode-private-config.ts b/packages/client/src/runtime/opencode-private-config.ts new file mode 100644 index 000000000..fa926565a --- /dev/null +++ b/packages/client/src/runtime/opencode-private-config.ts @@ -0,0 +1,510 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + fchmodSync, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + mkdtempSync, + openSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmdirSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; +import { acquireWorkspaceFileLock } from "./workspace-file-lock.js"; + +const PRIVATE_CONFIG_RELATIVE_ROOT = [".first-tree-workspace", "opencode-config"] as const; +const JOURNAL_FILENAME = "handler-generations.json"; +const LOCK_FILENAME = "handler-generations.lock"; +const MAX_JOURNAL_ENTRIES = 256; +const PROCESS_INSTANCE_ID = randomUUID(); + +type HandlerGenerationEntry = Readonly<{ + handlerId: string; + pid: number; + processInstanceId: string; + createdAt: string; +}>; + +type HandlerGenerationJournal = Readonly<{ + schemaVersion: 1; + entries: readonly HandlerGenerationEntry[]; +}>; + +export type OpenCodePrivateConfigLease = Readonly<{ + materialize: (configContent: string) => OpenCodePrivateConfigMaterialization; + close: () => Promise; +}>; + +export type OpenCodePrivateConfigMaterialization = Readonly<{ + configPath: string; + cleanup: () => void; +}>; + +type FileIdentity = Readonly<{ + dev: number; + ino: number; +}>; + +/** + * Allocate one handler-owned private-config directory below a stable caller + * parent. Every filesystem mutation revalidates canonical workspace + * containment and rejects symlinked/non-directory ancestors before it acts. + * + * The persistent lock/journal let a later daemon generation clean directories + * left by a crashed process. Live sibling handlers keep distinct children, so + * an old handler's delayed shutdown can never delete a newer handler's root. + */ +export async function acquireOpenCodePrivateConfigLease(input: { + workspace: string; + callerScope: string; + handlerId: string; +}): Promise { + assertScope(input.callerScope, "caller scope"); + assertScope(input.handlerId, "handler id"); + const workspaceRoot = canonicalWorkspaceRoot(input.workspace); + const callerParent = resolveContained(workspaceRoot, [...PRIVATE_CONFIG_RELATIVE_ROOT, input.callerScope]); + ensureSafeDirectoryChain(workspaceRoot, callerParent); + const lockPath = join(callerParent, LOCK_FILENAME); + const lock = await acquireWorkspaceFileLock(lockPath, { timeoutMs: 10_000 }); + const runtimeRoot = join(callerParent, `handler-${input.handlerId}`); + let handlerIdentity: FileIdentity | null = null; + + try { + assertSafeDirectoryChain(workspaceRoot, callerParent); + const journalPath = join(callerParent, JOURNAL_FILENAME); + const journal = readJournalNoFollow(journalPath); + const entries: HandlerGenerationEntry[] = []; + for (const entry of journal.entries) { + if (isStaleProcessEntry(entry)) { + removeHandlerChild(workspaceRoot, callerParent, entry.handlerId); + } else { + entries.push(entry); + } + } + sweepUnjournaledHandlerChildren(workspaceRoot, callerParent, entries); + if (entries.some((entry) => entry.handlerId === input.handlerId)) { + throw new Error("OpenCode private-config handler generation already exists"); + } + if (entries.length >= MAX_JOURNAL_ENTRIES) { + throw new Error("OpenCode private-config generation journal exceeded its safety bound"); + } + handlerIdentity = createHandlerChild(workspaceRoot, callerParent, input.handlerId); + entries.push({ + handlerId: input.handlerId, + pid: process.pid, + processInstanceId: PROCESS_INSTANCE_ID, + createdAt: new Date().toISOString(), + }); + writeJournalAtomic(journalPath, { schemaVersion: 1, entries }); + } finally { + await lock.release(); + } + + if (!handlerIdentity) { + throw new Error("OpenCode private-config handler generation was not created"); + } + const ownedHandlerIdentity = handlerIdentity; + const activeProjectionDirectories = new Map(); + let closed = false; + return { + materialize: (configContent) => { + if (closed) throw new Error("OpenCode private-config lease is closed"); + assertHandlerChildIdentity(workspaceRoot, callerParent, input.handlerId, ownedHandlerIdentity); + const configDirectory = mkdtempSync(join(runtimeRoot, "turn-")); + const directoryIdentity = directoryIdentityAt(configDirectory, "projection directory"); + let retained = false; + try { + assertHandlerChildIdentity(workspaceRoot, callerParent, input.handlerId, ownedHandlerIdentity); + assertDirectoryIdentity(configDirectory, directoryIdentity, "projection directory"); + chmodSync(configDirectory, 0o700); + const configPath = join(configDirectory, "opencode.json"); + writeConfigFileNoFollow(configPath, configContent, () => { + assertHandlerChildIdentity(workspaceRoot, callerParent, input.handlerId, ownedHandlerIdentity); + assertDirectoryIdentity(configDirectory, directoryIdentity, "projection directory"); + }); + assertHandlerChildIdentity(workspaceRoot, callerParent, input.handlerId, ownedHandlerIdentity); + assertDirectoryIdentity(configDirectory, directoryIdentity, "projection directory"); + activeProjectionDirectories.set(configDirectory, directoryIdentity); + retained = true; + let cleaned = false; + return { + configPath, + cleanup: () => { + if (cleaned) return; + assertHandlerChildIdentity(workspaceRoot, callerParent, input.handlerId, ownedHandlerIdentity); + assertDirectoryIdentity(configDirectory, directoryIdentity, "projection directory"); + removeDirectoryTreeNoFollow(configDirectory); + activeProjectionDirectories.delete(configDirectory); + cleaned = true; + }, + }; + } finally { + if (!retained) { + removeProjectionDirectoryIfOwned( + workspaceRoot, + callerParent, + input.handlerId, + ownedHandlerIdentity, + configDirectory, + directoryIdentity, + ); + } + } + }, + close: async () => { + if (closed) return; + if (activeProjectionDirectories.size > 0) { + throw new Error("OpenCode private-config lease still has active projections"); + } + assertSafeDirectoryChain(workspaceRoot, callerParent); + const closeLock = await acquireWorkspaceFileLock(lockPath, { timeoutMs: 10_000 }); + try { + assertSafeDirectoryChain(workspaceRoot, callerParent); + const journalPath = join(callerParent, JOURNAL_FILENAME); + const journal = readJournalNoFollow(journalPath); + const owned = journal.entries.some( + (entry) => + entry.handlerId === input.handlerId && + entry.pid === process.pid && + entry.processInstanceId === PROCESS_INSTANCE_ID, + ); + if (!owned) { + throw new Error("OpenCode private-config generation ownership changed before shutdown"); + } + assertHandlerChildIdentity(workspaceRoot, callerParent, input.handlerId, ownedHandlerIdentity); + removeHandlerChild(workspaceRoot, callerParent, input.handlerId); + writeJournalAtomic(journalPath, { + schemaVersion: 1, + entries: journal.entries.filter((entry) => entry.handlerId !== input.handlerId), + }); + closed = true; + } finally { + await closeLock.release(); + } + }, + }; +} + +function canonicalWorkspaceRoot(workspace: string): string { + const root = realpathSync(resolve(workspace)); + const stats = lstatSync(root); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error("OpenCode private-config workspace is not a canonical directory"); + } + return root; +} + +function resolveContained(workspaceRoot: string, segments: readonly string[]): string { + const target = resolve(workspaceRoot, ...segments); + const rel = relative(workspaceRoot, target); + if (!rel || rel.startsWith("..") || target === workspaceRoot) { + throw new Error("OpenCode private-config path escapes the workspace"); + } + return target; +} + +function ensureSafeDirectoryChain(workspaceRoot: string, target: string): void { + const rel = relative(workspaceRoot, target); + if (!rel || rel.startsWith("..")) throw new Error("OpenCode private-config directory escapes the workspace"); + let current = workspaceRoot; + for (const [index, segment] of rel.split(sep).filter(Boolean).entries()) { + current = join(current, segment); + try { + const stats = lstatSync(current); + if (stats.isSymbolicLink()) { + throw new Error(`OpenCode private-config ancestor is a symlink: ${current}`); + } + if (!stats.isDirectory()) { + throw new Error(`OpenCode private-config ancestor is not a directory: ${current}`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + try { + mkdirSync(current, { mode: 0o700 }); + } catch (mkdirError) { + if ((mkdirError as NodeJS.ErrnoException).code !== "EEXIST") throw mkdirError; + } + const created = lstatSync(current); + if (created.isSymbolicLink() || !created.isDirectory()) { + throw new Error(`OpenCode private-config directory changed during creation: ${current}`); + } + } + if (index > 0) chmodSync(current, 0o700); + } +} + +function assertSafeDirectoryChain(workspaceRoot: string, target: string): void { + const rel = relative(workspaceRoot, target); + if (!rel || rel.startsWith("..")) throw new Error("OpenCode private-config directory escapes the workspace"); + let current = workspaceRoot; + for (const segment of rel.split(sep).filter(Boolean)) { + current = join(current, segment); + const stats = lstatSync(current); + if (stats.isSymbolicLink()) { + throw new Error(`OpenCode private-config ancestor is a symlink: ${current}`); + } + if (!stats.isDirectory()) { + throw new Error(`OpenCode private-config ancestor is not a directory: ${current}`); + } + } +} + +function createHandlerChild(workspaceRoot: string, callerParent: string, handlerId: string): FileIdentity { + assertSafeDirectoryChain(workspaceRoot, callerParent); + const child = join(callerParent, `handler-${handlerId}`); + try { + mkdirSync(child, { mode: 0o700 }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error("OpenCode private-config handler directory already exists"); + } + throw error; + } + const stats = lstatSync(child); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error("OpenCode private-config handler directory is not a real directory"); + } + return { dev: stats.dev, ino: stats.ino }; +} + +function assertHandlerChildIdentity( + workspaceRoot: string, + callerParent: string, + handlerId: string, + expected: FileIdentity, +): void { + assertSafeDirectoryChain(workspaceRoot, callerParent); + assertDirectoryIdentity(join(callerParent, `handler-${handlerId}`), expected, "handler generation"); +} + +function directoryIdentityAt(path: string, label: string): FileIdentity { + const stats = lstatSync(path); + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error(`OpenCode private-config ${label} is not a real directory`); + } + return { dev: stats.dev, ino: stats.ino }; +} + +function assertDirectoryIdentity(path: string, expected: FileIdentity, label: string): void { + const stats = lstatSync(path); + if (stats.isSymbolicLink() || !stats.isDirectory() || stats.dev !== expected.dev || stats.ino !== expected.ino) { + throw new Error(`OpenCode private-config ${label} identity changed`); + } +} + +function writeConfigFileNoFollow(path: string, configContent: string, validateParent: () => void): void { + validateParent(); + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + const fd = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow, 0o600); + try { + const opened = fstatSync(fd); + if (!opened.isFile() || opened.nlink === 0) { + throw new Error("OpenCode private-config file is not a live regular file"); + } + writeFileSync(fd, configContent, "utf8"); + fsyncSync(fd); + fchmodSync(fd, 0o600); + const current = lstatSync(path); + if (current.isSymbolicLink() || !current.isFile() || current.dev !== opened.dev || current.ino !== opened.ino) { + throw new Error("OpenCode private-config file identity changed while writing"); + } + } finally { + closeSync(fd); + } +} + +function removeProjectionDirectoryIfOwned( + workspaceRoot: string, + callerParent: string, + handlerId: string, + handlerIdentity: FileIdentity, + configDirectory: string, + directoryIdentity: FileIdentity, +): void { + try { + assertHandlerChildIdentity(workspaceRoot, callerParent, handlerId, handlerIdentity); + assertDirectoryIdentity(configDirectory, directoryIdentity, "projection directory"); + removeDirectoryTreeNoFollow(configDirectory); + } catch { + // Fail closed: never follow or remove a path whose containment/identity + // cannot still be proven. + } +} + +function removeHandlerChild(workspaceRoot: string, callerParent: string, handlerId: string): void { + assertScope(handlerId, "journal handler id"); + assertSafeDirectoryChain(workspaceRoot, callerParent); + const child = join(callerParent, `handler-${handlerId}`); + let stats: ReturnType; + try { + stats = lstatSync(child); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error("OpenCode private-config owned handler path is not a real directory"); + } + removeDirectoryTreeNoFollow(child); +} + +function sweepUnjournaledHandlerChildren( + workspaceRoot: string, + callerParent: string, + entries: readonly HandlerGenerationEntry[], +): void { + assertSafeDirectoryChain(workspaceRoot, callerParent); + const registered = new Set(entries.map((entry) => entry.handlerId)); + for (const name of readdirSync(callerParent)) { + if (name === JOURNAL_FILENAME || name === LOCK_FILENAME) continue; + if (!name.startsWith("handler-")) continue; + const handlerId = name.slice("handler-".length); + assertScope(handlerId, "orphan handler id"); + if (!registered.has(handlerId)) { + removeHandlerChild(workspaceRoot, callerParent, handlerId); + } + } +} + +function removeDirectoryTreeNoFollow(directory: string): void { + for (const name of readdirSync(directory)) { + const child = join(directory, name); + const stats = lstatSync(child); + if (stats.isDirectory() && !stats.isSymbolicLink()) { + removeDirectoryTreeNoFollow(child); + } else { + unlinkSync(child); + } + } + rmdirSync(directory); +} + +function readJournalNoFollow(path: string): HandlerGenerationJournal { + let current: ReturnType; + try { + current = lstatSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { schemaVersion: 1, entries: [] }; + throw error; + } + if (current.isSymbolicLink() || !current.isFile()) { + throw new Error("OpenCode private-config generation journal is not a regular file"); + } + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + const fd = openSync(path, constants.O_RDONLY | noFollow); + try { + const opened = fstatSync(fd); + const afterOpen = lstatSync(path); + if ( + !opened.isFile() || + !afterOpen.isFile() || + opened.nlink === 0 || + opened.dev !== afterOpen.dev || + opened.ino !== afterOpen.ino + ) { + throw new Error("OpenCode private-config generation journal changed while opening"); + } + return parseJournal(JSON.parse(readFileSync(fd, "utf8")) as unknown); + } finally { + closeSync(fd); + } +} + +function parseJournal(value: unknown): HandlerGenerationJournal { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("OpenCode private-config generation journal is invalid"); + } + const record = value as Record; + if (record.schemaVersion !== 1 || !Array.isArray(record.entries)) { + throw new Error("OpenCode private-config generation journal has an unsupported schema"); + } + const entries = record.entries.map((value): HandlerGenerationEntry => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("OpenCode private-config generation journal entry is invalid"); + } + const entry = value as Record; + if ( + typeof entry.handlerId !== "string" || + !isScope(entry.handlerId) || + typeof entry.pid !== "number" || + !Number.isSafeInteger(entry.pid) || + entry.pid <= 0 || + typeof entry.processInstanceId !== "string" || + entry.processInstanceId.length < 16 || + typeof entry.createdAt !== "string" || + Number.isNaN(Date.parse(entry.createdAt)) + ) { + throw new Error("OpenCode private-config generation journal entry has invalid fields"); + } + return { + handlerId: entry.handlerId, + pid: entry.pid, + processInstanceId: entry.processInstanceId, + createdAt: entry.createdAt, + }; + }); + if (entries.length > MAX_JOURNAL_ENTRIES) { + throw new Error("OpenCode private-config generation journal exceeded its safety bound"); + } + if (new Set(entries.map((entry) => entry.handlerId)).size !== entries.length) { + throw new Error("OpenCode private-config generation journal has duplicate handlers"); + } + return { schemaVersion: 1, entries }; +} + +function writeJournalAtomic(path: string, value: HandlerGenerationJournal): void { + const tempPath = `${path}.${randomBytes(8).toString("hex")}.tmp`; + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + let fd: number | null = null; + try { + fd = openSync(tempPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | noFollow, 0o600); + writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + fsyncSync(fd); + closeSync(fd); + fd = null; + renameSync(tempPath, path); + } catch (error) { + if (fd !== null) { + try { + closeSync(fd); + } catch { + // Preserve the original error. + } + } + try { + unlinkSync(tempPath); + } catch { + // Preserve the original error. + } + throw error; + } +} + +function isStaleProcessEntry(entry: HandlerGenerationEntry): boolean { + if (entry.processInstanceId === PROCESS_INSTANCE_ID) return false; + if (entry.pid === process.pid) return true; + try { + process.kill(entry.pid, 0); + return false; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return code === "ESRCH"; + } +} + +function assertScope(value: string, label: string): void { + if (!isScope(value)) throw new Error(`invalid OpenCode private-config ${label}`); +} + +function isScope(value: string): boolean { + return /^[a-f0-9]{32,64}$/.test(value); +} diff --git a/packages/client/src/runtime/provider-process-supervisor.ts b/packages/client/src/runtime/provider-process-supervisor.ts new file mode 100644 index 000000000..ac98a0201 --- /dev/null +++ b/packages/client/src/runtime/provider-process-supervisor.ts @@ -0,0 +1,71 @@ +import type { ChildProcess, SpawnOptions } from "node:child_process"; +import { getChildProcessRegistry } from "./child-process-registry.js"; + +export type ProviderProcessSpec = { + command: string; + args: readonly string[]; + options: SpawnOptions; + label: string; + timeoutMs?: number; +}; + +export type SupervisedProviderProcess = { + child: ChildProcess; + /** + * Runtime-local cleanup observation. This is diagnostic only and is never a + * substitute for client-switch drain authority. + */ + exited: Promise; +}; + +export interface ProviderProcessSupervisor { + spawn(spec: ProviderProcessSpec): SupervisedProviderProcess; +} + +/** + * Whether the built-in supervisor can safely execute an external provider on + * this platform. Capability advertising uses the same gate as spawn so a + * Windows client cannot offer a runtime that this supervisor will reject. + */ +export function supportsDefaultProviderProcessSupervision(platform: NodeJS.Platform = process.platform): boolean { + return platform !== "win32"; +} + +/** + * The existing POSIX path: environment-attributed process-tree observation is + * still the client-switch authority; the registry only improves local abort + * and daemon shutdown cleanup. + * + * Windows deliberately fails closed here. OpenCode descendants must be + * pre-admitted to a non-breakaway kill-on-close Job by a platform supervisor + * before this factory can be enabled. Supplying that supervisor is an + * explicit product seam, not a child-registry fallback. + */ +export function createDefaultProviderProcessSupervisor( + platform: NodeJS.Platform = process.platform, +): ProviderProcessSupervisor { + return { + spawn(spec) { + if (!supportsDefaultProviderProcessSupervision(platform)) { + throw new ProviderProcessSupervisionUnsupportedError( + "OpenCode on Windows requires a pre-admission Job Object supervisor; " + + "the current client-switch drain authority remains unsupported", + ); + } + const { child, record } = getChildProcessRegistry().spawn(spec.command, spec.args, { + ...spec.options, + category: "other", + label: spec.label, + ...(spec.timeoutMs ? { timeoutMs: spec.timeoutMs } : {}), + }); + return { child, exited: record.exited }; + }, + }; +} + +export class ProviderProcessSupervisionUnsupportedError extends Error { + constructor(message: string) { + super(message); + this.name = "ProviderProcessSupervisionUnsupportedError"; + } +} diff --git a/packages/client/src/runtime/provider-retry-policy.ts b/packages/client/src/runtime/provider-retry-policy.ts index 5e36980fe..aef1e786f 100644 --- a/packages/client/src/runtime/provider-retry-policy.ts +++ b/packages/client/src/runtime/provider-retry-policy.ts @@ -7,6 +7,7 @@ import type { RuntimeProvider, } from "@first-tree/shared"; import { type Classification, classify, ERROR_KINDS } from "./error-taxonomy.js"; +import { isManagedSkillsUnsafeDiscoveryError } from "./managed-skills.js"; import { redactErrorPreview } from "./redact-error-preview.js"; export type ProviderFailureClassification = { @@ -49,6 +50,8 @@ const SESSION_CAPACITY_CAP_MS = 5 * 60_000; const AUTH_HTTP_CODE_RE = /\b(401|403)\b/; const TRANSIENT_HTTP_CODE_RE = /\b(500|502|503|504)\b/; +export const MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE = "managed_skills_unsafe_discovery"; + export function classifyProviderFailure( err: unknown, context: { @@ -64,6 +67,14 @@ export function classifyProviderFailure( const retryAfterMs = readRetryAfterMs(shape); const status = shape.status ?? shape.statusCode; + if (isManagedSkillsUnsafeDiscoveryError(err)) { + return { + category: "transient_transport", + reasonCode: MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE, + message: base.message, + sourceKind: base.kind, + }; + } if (isBillingLimit(text)) { return { category: "provider_capacity", @@ -153,7 +164,8 @@ export function decideProviderRetry(input: { if ( input.scope === "provider_turn" && isUnsafeReplay(input.replaySafety) && - !isRetryableUserVisibleFailure(input.classification.category, input.replaySafety) + (input.classification.reasonCode === MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE || + !isRetryableUserVisibleFailure(input.classification.category, input.replaySafety)) ) { return stop("unsafe_replay", "unsafe_replay", input.replaySafety, "warning"); } diff --git a/packages/client/src/runtime/runtime-notice.ts b/packages/client/src/runtime/runtime-notice.ts index 282cb5472..663f986e2 100644 --- a/packages/client/src/runtime/runtime-notice.ts +++ b/packages/client/src/runtime/runtime-notice.ts @@ -43,6 +43,8 @@ function providerLabel(provider: RuntimeProvider): string { return "Cursor"; case "kimi-code": return "Kimi Code"; + case "opencode": + return "OpenCode"; default: return provider; } diff --git a/packages/client/src/runtime/session-manager.ts b/packages/client/src/runtime/session-manager.ts index e38a12565..b69435a86 100644 --- a/packages/client/src/runtime/session-manager.ts +++ b/packages/client/src/runtime/session-manager.ts @@ -41,6 +41,7 @@ import { clampRetryAttempt } from "./error-taxonomy.js"; import type { AgentHandler, AgentIdentity, + DeliveryCompletionDisposition, DeliveryToken, HandlerConfig, HandlerFactory, @@ -58,6 +59,7 @@ import { buildProviderRetryEvent, classifyProviderFailure, decideProviderRetry, + MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE, type ProviderFailureClassification, } from "./provider-retry-policy.js"; import { redactErrorPreview } from "./redact-error-preview.js"; @@ -1297,29 +1299,30 @@ export class SessionManager { messages: SessionMessage | readonly SessionMessage[], outcome: TurnOutcome, deliveryLeaseValid: (() => boolean) | null = null, - ): Promise { - if (deliveryLeaseValid && !deliveryLeaseValid()) return; + ): Promise { + if (deliveryLeaseValid && !deliveryLeaseValid()) return "retry"; const retryReason = this.errorCompletionRetryReason(outcome); if (retryReason) { this.warnRejectedErrorCompletion(chatId, outcome, retryReason); this.retryDeliveryTurn(chatId, messages, retryReason); this.projectSessionRuntime(chatId); - return; + return "retry"; } if (outcome.status === "success") { this.clearPendingRuntimeFailureNotice(chatId); } else if (outcome.completion === "consumed") { const noticePosted = await this.postPendingRuntimeFailureNotice(chatId, deliveryLeaseValid); - if (deliveryLeaseValid && !deliveryLeaseValid()) return; + if (deliveryLeaseValid && !deliveryLeaseValid()) return "retry"; if (!noticePosted) { this.retryDeliveryTurn(chatId, messages, "runtime_failure_notice_delivery_failed"); this.projectSessionRuntime(chatId); - return; + return "retry"; } } - if (deliveryLeaseValid && !deliveryLeaseValid()) return; + if (deliveryLeaseValid && !deliveryLeaseValid()) return "retry"; await this.inboxDelivery.finishTurn(chatId, messages, outcome); this.projectSessionRuntime(chatId); + return "settled"; } private createDeliveryToken(chatId: string, routeLeaseValid: (() => boolean) | null = null): DeliveryToken { @@ -1344,8 +1347,8 @@ export class SessionManager { this.projectSessionRuntime(chatId); }, complete: async (messages, outcome) => { - if (!claimTerminal("complete")) return; - await this.completeDeliveryTurn(chatId, messages, outcome, isValid); + if (!claimTerminal("complete")) return "retry"; + return await this.completeDeliveryTurn(chatId, messages, outcome, isValid); }, retry: (messages, reason) => { if (!claimTerminal("retry")) return; @@ -2204,6 +2207,11 @@ export class SessionManager { private triggerImmediateRetry(chatId: string): void { const entry = this.sessions.get(chatId); if (!entry || entry.retryAttempt === 0) return; + // Managed-Skill discovery safety is a local provider-preflight gate, not + // an availability hint that newer input can bypass. Keep the original + // head and FIFO tail behind the bounded timer so repeated deliveries or + // resume commands cannot turn the safety wait into a hot retry loop. + if (entry.lastRetryReason === MANAGED_SKILLS_UNSAFE_DISCOVERY_REASON_CODE) return; if (entry.retryTimer) { clearTimeout(entry.retryTimer); entry.retryTimer = null; @@ -2817,6 +2825,18 @@ export class SessionManager { this.retryDeliveryTurn(chatId, messages, reason); this.projectSessionRuntime(chatId); }, + hasPendingDelivery: (messages) => { + const batch = Array.isArray(messages) ? messages : [messages]; + return batch.some( + (message) => + message.inboxEntryId !== undefined && + this.inboxDelivery.hasEntry({ + chatId, + entryId: message.inboxEntryId, + messageId: message.id, + }), + ); + }, failSessionForRecovery: (reason, sessionId) => { if (routeLeaseValid && !routeLeaseValid()) return; this.failSessionForRecovery(chatId, reason, sessionId); diff --git a/packages/qa/cases/runtime/opencode-provider.md b/packages/qa/cases/runtime/opencode-provider.md new file mode 100644 index 000000000..76020b9fa --- /dev/null +++ b/packages/qa/cases/runtime/opencode-provider.md @@ -0,0 +1,95 @@ +--- +id: opencode-provider +description: Validate the external OpenCode CLI provider end to end — private config projection, per-turn JSONL, session resume, queueing, auth, and process drain. +areas: [runtime] +surfaces: [web, cli, server, client] +--- + +# OpenCode Runtime Provider + +## Goal + +Confirm that an agent bound to `opencode` runs through a supported external CLI, reuses provider-owned +host-local authentication without giving First Tree token custody, and preserves First Tree's delivery, session, +configuration, Context Tree I/O, and process-drain contracts. + +Use this case when the OpenCode handler, binary resolver, capability probe, private config projection, parser, model +surface, or provider supervisor changes. + +## Preconditions + +- Run in the isolated QA cell selected by the plan: Docker plus a temporary source worktree, with an explicit native + bridge only where the OS process authority cannot live inside Docker. Never modify the operator checkout. +- Install an OpenCode version in the product's supported `>=1.18.7 <2.0.0` range on the client host and complete provider-owned setup with + `opencode auth login`. The test may prove the login by completing a real turn, but must not read, copy, print, or + archive provider credential files. +- Use disposable source, MCP, and Context Tree fixtures. Provider tool calls must not modify the product checkout. +- Windows acceptance requires the separately owner-reviewed drain-authority decision and a product Job Object + supervisor. Until both exist, the Windows branch must fail closed before any OpenCode invocation and cannot PASS. + +## Checklist + +- Capability: the connected client reports `opencode` as `missing` or `ok` solely from the same binary resolver used by + the handler. Re-probing must not launch OpenCode, inspect its config, infer auth, or contact a model provider. +- Provider selection: Web and CLI expose OpenCode only on a client advertising the capability. The config defaults to + an empty model, accepts an exact provider-native `provider/model` string, and exposes no separate reasoning-effort + control. +- Runtime gates: the first active use launches `opencode --version` through the provider supervisor and requires a + stable release in `>=1.18.7 <2.0.0`; prerelease, older, major-two, and unparseable output fail closed. It then + serially runs `opencode db "SELECT 1 AS ready" --format json` before concurrent + per-turn processes may use the same client data home. +- Private projection: each handler supplies uniquely namespaced First Tree primary-agent and MCP keys so OpenCode's + deep merge cannot retain colliding operator fields. Small projections use caller-scoped `OPENCODE_CONFIG_CONTENT`; + large projections use a private runtime-owned config file that is removed after the turn. Current Chat Context and + the runtime output contract never enter persistent config; they ride stdin as one-shot context and survive + non-delivery. The projection must not rewrite the operator's global OpenCode config. Projected Skills live only under + `.opencode/skills` and retain the shared ownership, lock, journal, rollback, and fail-closed reconciliation behavior. +- Child boundary: observe the First Tree identity/drain envelope and runtime-session token-file path in the child + environment. The token contents and provider credentials must not enter argv, logs, Server data, or retained + evidence. Prompt text appears only on stdin followed by EOF. +- Real turn: observe + `opencode run --format json --auto --agent --dir ` plus `--model` only when configured and + `--session` only for a confirmed resume. Verify normalized assistant, tool, token-usage, and successful terminal + events, exactly one non-`tool-calls` terminal event, and a deterministic disposable file tool effect. Every non-empty + stdout line must be a supported JSON object; malformed and unknown lines fail closed while official `reasoning` + events are explicitly ignored. +- Session and queue: persist the unique session ID observed in JSONL. Suspend and resume the same chat with an explicit + `--session`; reject a mismatch or missing terminal event. Inject a message during an active turn and prove it is + queued for a subsequent process rather than sent to the current stdin. +- Managed MCP: project disposable stdio and remote servers through the private config, complete a real tool call, and + confirm secret headers are absent from logs/evidence. A config change must apply on the next turn without modifying + global OpenCode state. +- Auth and failure custody: a logged-out real turn produces a durable error notice directing the operator to + `opencode auth login`; First Tree offers no in-product OAuth. Deterministic provider/config failures and failures + after assistant or unsafe tool output are consumed only after the notice. Unknown pre-effect failures remain + retryable and preserve recovery custody. +- Context Tree I/O: OpenCode `read`/`glob`/`grep` records `opencode_read_tool`; `edit`/`write`/`patch` records + `opencode_write_tool`; qualifying `bash` commands produce the provider-neutral shell evidence with repo/path + qualification. +- Process safety: on POSIX, prove the existing environment-attributed OS process-tree drain observes and clears the + OpenCode root and descendants. On Windows, prove pre-admission to a non-breakaway kill-on-close Job, root exit while + a detached child remains a Job member, `TerminateJobObject`, PID/start-time identity, and two empty scans at least + 500 ms apart. Child registry evidence is diagnostic only and must never authorize a client switch. + +## Expected Result + +`PASS` requires a real authenticated two-turn First Tree/OpenCode flow with session continuity, a deterministic tool +effect, private config/MCP/Skills evidence, correct delivery and failure custody, normalized events, Context Tree I/O, +and platform-accepted process-drain proof. + +`FAIL` includes prompt text in argv, credentials or secret headers retained by First Tree, an unadmitted runtime +process, global OpenCode config mutation, silent model fallback, synthetic or mismatched resume, missing terminal-event +validation, active-turn steering, unsafe side-effect replay, terminal failure consumed before its durable notice, or a +client switch authorized by child registry alone. + +`BLOCKED` means a compatible CLI, provider login/entitlement/network, isolated platform bridge, owner-reviewed Windows +drain authority, or product Job supervisor is absent. Unit tests and the one-time protocol harness do not turn a +blocked First Tree product branch into PASS. `INCONCLUSIVE` means a live turn ran but retained evidence cannot +distinguish the claimed behavior. + +## Evidence + +Keep sanitized capability snapshots, exact binary/version and argv/cwd observations, private config shape with secrets +removed, session ID continuity, event-kind sequence and token totals, disposable file hashes, MCP call and Context Tree +rows, delivery/retry/notice transitions, and authoritative drain receipts. Never retain provider request bodies, +credential files, auth headers, runtime-session token contents, private prompts, or raw stderr before redaction. diff --git a/packages/server/src/__tests__/admin-agent-config.test.ts b/packages/server/src/__tests__/admin-agent-config.test.ts index c05bd48ff..572ce3c5f 100644 --- a/packages/server/src/__tests__/admin-agent-config.test.ts +++ b/packages/server/src/__tests__/admin-agent-config.test.ts @@ -226,6 +226,33 @@ describe("Admin agent-config API (Step 2)", () => { expect("reasoningEffort" in model.json().payload).toBe(false); }); + it("persists an exact OpenCode provider/model id and rejects reasoning effort", async () => { + const app = getApp(); + const req = await authedRequest(app); + const agent = await (await seedAgentFactory(app))({ + name: `cfg-opencode-${crypto.randomUUID().slice(0, 8)}`, + type: "agent", + runtimeProvider: "opencode", + }); + + const model = await req("PATCH", `/api/v1/agents/${agent.uuid}/config`, { + expectedVersion: 1, + payload: { model: "anthropic/claude-opus-4-6" }, + }); + expect(model.statusCode).toBe(200); + expect(model.json().payload).toMatchObject({ + kind: "opencode", + model: "anthropic/claude-opus-4-6", + }); + + const effort = await req("PATCH", `/api/v1/agents/${agent.uuid}/config`, { + expectedVersion: 2, + payload: { reasoningEffort: "high" }, + }); + expect(effort.statusCode).toBe(400); + expect(effort.json<{ error: string }>().error).toContain("not supported"); + }); + it("persists model-dependent max and ultra values for codex agents", async () => { const app = getApp(); const req = await authedRequest(app); diff --git a/packages/server/src/__tests__/agent-capability-gate.test.ts b/packages/server/src/__tests__/agent-capability-gate.test.ts index cf1b60d75..b5663fada 100644 --- a/packages/server/src/__tests__/agent-capability-gate.test.ts +++ b/packages/server/src/__tests__/agent-capability-gate.test.ts @@ -127,6 +127,22 @@ describe("Agent capability gate (services/agent.ts)", () => { ).rejects.toThrow(/does not have runtime provider "claude-code" available/i); }); + it("blocks OpenCode selection when the client reports installed-but-unsupported Windows execution", async () => { + const app = getApp(); + const ctx = await createAdminContext(app); + await setCapabilities(app, ctx.clientId, { opencode: entry("error") }); + + await expect( + createAgent(app.db, { + name: `cap-gate-opencode-win-${crypto.randomUUID().slice(0, 6)}`, + type: "agent", + managerId: ctx.memberId, + clientId: ctx.clientId, + runtimeProvider: "opencode", + }), + ).rejects.toThrow(/does not have runtime provider "opencode" available/i); + }); + it("`force: true` bypasses the gate even when the SDK is reported missing", async () => { const app = getApp(); const ctx = await createAdminContext(app); diff --git a/packages/server/src/__tests__/context-tree-io.test.ts b/packages/server/src/__tests__/context-tree-io.test.ts index 0fe41c3ea..eb7c86eee 100644 --- a/packages/server/src/__tests__/context-tree-io.test.ts +++ b/packages/server/src/__tests__/context-tree-io.test.ts @@ -503,6 +503,54 @@ describe("context-tree IO service", () => { ).toEqual({ recordable: false, reason: "unsupported_tool" }); }); + it("derives OpenCode lower-case read/write and bash IO only for opencode", () => { + const read = { + kind: "tool_call", + payload: { + toolUseId: "oc-read", + name: "read", + args: { path: "NODE.md" }, + status: "ok", + toolFileRefs: [ + { + origin: "tool_arg", + repoUrl: TREE_REPO, + repoBranch: "main", + repoRelativePath: "NODE.md", + pathKind: "file", + }, + ], + }, + }; + const write = { + kind: "tool_call", + payload: { + toolUseId: "oc-write", + name: "patch", + args: { path: "system/NODE.md" }, + status: "ok", + toolFileRefs: [ + { + origin: "file_change", + repoUrl: TREE_REPO, + repoBranch: "main", + repoRelativePath: "system/NODE.md", + pathKind: "file", + }, + ], + }, + }; + expect( + explainContextTreeIoDecision({ runtimeProvider: "opencode", sessionEvent: read, bindingRepo: TREE_REPO }), + ).toEqual({ recordable: true }); + expect( + explainContextTreeIoDecision({ runtimeProvider: "opencode", sessionEvent: write, bindingRepo: TREE_REPO }), + ).toEqual({ recordable: true }); + expect( + explainContextTreeIoDecision({ runtimeProvider: "cursor", sessionEvent: write, bindingRepo: TREE_REPO }), + ).toEqual({ recordable: false, reason: "unsupported_tool" }); + }); + it("end-to-end regression: a real-shaped completed cursor shell event lands as a repo-qualified read", async () => { // The exact event shape the cursor handler emits after client enrichment // for a tree read via shell (`cat /NODE.md`) — the path the old diff --git a/packages/server/src/services/context-tree-io.ts b/packages/server/src/services/context-tree-io.ts index 8d0924eab..0499f946e 100644 --- a/packages/server/src/services/context-tree-io.ts +++ b/packages/server/src/services/context-tree-io.ts @@ -42,6 +42,8 @@ const CURSOR_READ_TOOLS = new Set(["read"]); const CURSOR_WRITE_TOOLS = new Set(["edit", "write"]); const KIMI_READ_TOOLS = new Set(["Read", "Grep", "Glob"]); const KIMI_WRITE_TOOLS = new Set(["Write", "Edit"]); +const OPENCODE_READ_TOOLS = new Set(["read", "grep", "glob"]); +const OPENCODE_WRITE_TOOLS = new Set(["edit", "write", "patch"]); const CONTEXT_TREE_IO_TOOL_NAMES = [ "Bash", "Edit", @@ -58,6 +60,8 @@ const CONTEXT_TREE_IO_TOOL_NAMES = [ "read", "shell", "write", + "bash", + "patch", ]; const log = createLogger("ContextTreeIo"); const GIT_STATUS_DELTA_REF_ORIGIN = "git_status_delta"; @@ -198,6 +202,7 @@ function isShellTool(runtimeProvider: string, toolName: string): boolean { (runtimeProvider === "codex" && toolName === "command") || (runtimeProvider === "cursor" && toolName === "shell") || (runtimeProvider === "kimi-code" && toolName === "Bash") || + (runtimeProvider === "opencode" && toolName === "bash") || (isClaudeRuntime(runtimeProvider) && toolName === "Bash") ); } @@ -236,6 +241,9 @@ function skippedDecisionFastPathForNoRefs( if (runtimeProvider === "kimi-code" && (KIMI_READ_TOOLS.has(toolName) || KIMI_WRITE_TOOLS.has(toolName))) { return { handled: true, decision: { recordable: false, reason: "no_tool_file_refs" }, toolName }; } + if (runtimeProvider === "opencode" && (OPENCODE_READ_TOOLS.has(toolName) || OPENCODE_WRITE_TOOLS.has(toolName))) { + return { handled: true, decision: { recordable: false, reason: "no_tool_file_refs" }, toolName }; + } if (isClaudeRuntime(runtimeProvider) && (CLAUDE_READ_TOOLS.has(toolName) || CLAUDE_WRITE_TOOLS.has(toolName))) { return { handled: true, decision: { recordable: false, reason: "no_tool_file_refs" }, toolName }; } @@ -272,6 +280,12 @@ function deriveEventIo(event: SessionEvent, runtimeProvider: string): EventIoDer if (runtimeProvider === "kimi-code" && KIMI_WRITE_TOOLS.has(toolName)) { return { action: "write", source: "kimi_write_tool" }; } + if (runtimeProvider === "opencode" && OPENCODE_READ_TOOLS.has(toolName)) { + return { action: "read", source: "opencode_read_tool" }; + } + if (runtimeProvider === "opencode" && OPENCODE_WRITE_TOOLS.has(toolName)) { + return { action: "write", source: "opencode_write_tool" }; + } if (isClaudeRuntime(runtimeProvider) && CLAUDE_READ_TOOLS.has(toolName)) { return { action: "read", source: "claude_read_tool" }; } diff --git a/packages/shared/src/__tests__/agent-runtime-config.test.ts b/packages/shared/src/__tests__/agent-runtime-config.test.ts index dedb1a7f8..e5e147090 100644 --- a/packages/shared/src/__tests__/agent-runtime-config.test.ts +++ b/packages/shared/src/__tests__/agent-runtime-config.test.ts @@ -9,6 +9,7 @@ import { DEFAULT_CODEX_RUNTIME_CONFIG_PAYLOAD, DEFAULT_CURSOR_RUNTIME_CONFIG_PAYLOAD, DEFAULT_KIMI_CODE_RUNTIME_CONFIG_PAYLOAD, + DEFAULT_OPENCODE_RUNTIME_CONFIG_PAYLOAD, defaultRuntimeConfigPayload, deriveRepoLocalPath, deriveRepoShortLabel, @@ -275,6 +276,24 @@ describe("agent runtime config — kimi-code variant", () => { }); }); +describe("agent runtime config — opencode variant", () => { + it("defaults to host-local model selection with no reasoning-effort field", () => { + expect(DEFAULT_OPENCODE_RUNTIME_CONFIG_PAYLOAD).toMatchObject({ kind: "opencode", model: "" }); + expect("reasoningEffort" in DEFAULT_OPENCODE_RUNTIME_CONFIG_PAYLOAD).toBe(false); + expect(defaultRuntimeConfigPayload("opencode")).toMatchObject({ kind: "opencode", model: "" }); + }); + + it("passes a provider-native provider/model id through unchanged", () => { + const parsed = agentRuntimeConfigPayloadSchema.parse({ + kind: "opencode", + model: "openai/gpt-5.5", + reasoningEffort: "high", + }); + expect(parsed.model).toBe("openai/gpt-5.5"); + expect("reasoningEffort" in parsed).toBe(false); + }); +}); + describe("agent runtime config — reasoning effort", () => { it("claude default is '' (inherit local effortLevel); codex default is 'high'", () => { expect(effortOf(DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD)).toBe(""); diff --git a/packages/shared/src/__tests__/runtime-provider-schemas.test.ts b/packages/shared/src/__tests__/runtime-provider-schemas.test.ts index 4237e1f98..81fca5969 100644 --- a/packages/shared/src/__tests__/runtime-provider-schemas.test.ts +++ b/packages/shared/src/__tests__/runtime-provider-schemas.test.ts @@ -26,6 +26,7 @@ describe("runtimeProviderSchema", () => { expect(runtimeProviderSchema.parse("codex")).toBe("codex"); expect(runtimeProviderSchema.parse("cursor")).toBe("cursor"); expect(runtimeProviderSchema.parse("kimi-code")).toBe("kimi-code"); + expect(runtimeProviderSchema.parse("opencode")).toBe("opencode"); }); it("rejects unknown providers", () => { @@ -39,6 +40,7 @@ describe("runtimeProviderSchema", () => { expect(runtimeProviderSchema.parse(RUNTIME_PROVIDERS.CODEX)).toBe("codex"); expect(runtimeProviderSchema.parse(RUNTIME_PROVIDERS.CURSOR)).toBe("cursor"); expect(runtimeProviderSchema.parse(RUNTIME_PROVIDERS.KIMI_CODE)).toBe("kimi-code"); + expect(runtimeProviderSchema.parse(RUNTIME_PROVIDERS.OPENCODE)).toBe("opencode"); }); it("DEFAULT_RUNTIME_PROVIDER is claude-code (existing rows pre-0026 have no kind)", () => { @@ -50,6 +52,7 @@ describe("runtimeProviderSchema", () => { expect(isRuntimeProviderEnabled("codex")).toBe(true); expect(isRuntimeProviderEnabled("cursor")).toBe(true); expect(isRuntimeProviderEnabled("kimi-code")).toBe(true); + expect(isRuntimeProviderEnabled("opencode")).toBe(true); expect(isRuntimeProviderEnabled("claude-code-tui")).toBe(false); expect(isRuntimeProviderEnabled("future-provider")).toBe(true); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8cae5c3d6..de0d371f9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -137,6 +137,7 @@ export { DEFAULT_CODEX_RUNTIME_CONFIG_PAYLOAD, DEFAULT_CURSOR_RUNTIME_CONFIG_PAYLOAD, DEFAULT_KIMI_CODE_RUNTIME_CONFIG_PAYLOAD, + DEFAULT_OPENCODE_RUNTIME_CONFIG_PAYLOAD, type DryRunAgentRuntimeConfig, defaultRuntimeConfigPayload, deriveRepoLocalPath, diff --git a/packages/shared/src/schemas/agent-runtime-config.ts b/packages/shared/src/schemas/agent-runtime-config.ts index b8012aad0..014aec127 100644 --- a/packages/shared/src/schemas/agent-runtime-config.ts +++ b/packages/shared/src/schemas/agent-runtime-config.ts @@ -310,12 +310,20 @@ const kimiCodeRuntimeConfigPayloadShape = agentRuntimeConfigPayloadShape.extend( // configuration while a non-empty exact id is passed to the SDK. }); +const opencodeRuntimeConfigPayloadShape = agentRuntimeConfigPayloadShape.extend({ + kind: z.literal("opencode"), + // OpenCode model identifiers are provider-native `provider/model` values. + // An empty value delegates selection to the operator's local OpenCode + // configuration; non-empty values are forwarded as one argv entry. +}); + const taggedPayloadUnion = z.discriminatedUnion("kind", [ claudeRuntimeConfigPayloadShape, claudeCodeTuiRuntimeConfigPayloadShape, codexRuntimeConfigPayloadShape, cursorRuntimeConfigPayloadShape, kimiCodeRuntimeConfigPayloadShape, + opencodeRuntimeConfigPayloadShape, ]); type TaggedPayload = z.infer; @@ -463,6 +471,17 @@ export const DEFAULT_KIMI_CODE_RUNTIME_CONFIG_PAYLOAD: AgentRuntimeConfigPayload resourceSkills: [], }; +/** Default payload for OpenCode. Empty model inherits the host-local config. */ +export const DEFAULT_OPENCODE_RUNTIME_CONFIG_PAYLOAD: AgentRuntimeConfigPayload = { + kind: "opencode", + prompt: { append: "" }, + model: "", + mcpServers: [], + env: [], + gitRepos: [], + resourceSkills: [], +}; + /** * Default payload selector by runtime provider. */ @@ -476,6 +495,8 @@ export function defaultRuntimeConfigPayload( return { ...DEFAULT_CURSOR_RUNTIME_CONFIG_PAYLOAD }; case "kimi-code": return { ...DEFAULT_KIMI_CODE_RUNTIME_CONFIG_PAYLOAD }; + case "opencode": + return { ...DEFAULT_OPENCODE_RUNTIME_CONFIG_PAYLOAD }; case "claude-code-tui": return { ...DEFAULT_CLAUDE_CODE_TUI_RUNTIME_CONFIG_PAYLOAD }; case "claude-code": diff --git a/packages/shared/src/schemas/context-tree.ts b/packages/shared/src/schemas/context-tree.ts index c67b67a42..448e3512d 100644 --- a/packages/shared/src/schemas/context-tree.ts +++ b/packages/shared/src/schemas/context-tree.ts @@ -189,6 +189,8 @@ export const contextTreeIoSourceSchema = z.enum([ "cursor_write_tool", "kimi_read_tool", "kimi_write_tool", + "opencode_read_tool", + "opencode_write_tool", "shell_command", "git_status_delta", ]); diff --git a/packages/shared/src/schemas/runtime-provider.ts b/packages/shared/src/schemas/runtime-provider.ts index 8ed2ee3fd..e742cd00f 100644 --- a/packages/shared/src/schemas/runtime-provider.ts +++ b/packages/shared/src/schemas/runtime-provider.ts @@ -11,9 +11,17 @@ export const RUNTIME_PROVIDERS = { CODEX: "codex", CURSOR: "cursor", KIMI_CODE: "kimi-code", + OPENCODE: "opencode", } as const; -export const runtimeProviderSchema = z.enum(["claude-code", "claude-code-tui", "codex", "cursor", "kimi-code"]); +export const runtimeProviderSchema = z.enum([ + "claude-code", + "claude-code-tui", + "codex", + "cursor", + "kimi-code", + "opencode", +]); export type RuntimeProvider = z.infer; export const DEFAULT_RUNTIME_PROVIDER: RuntimeProvider = "claude-code"; diff --git a/packages/web/src/components/new-agent-dialog.tsx b/packages/web/src/components/new-agent-dialog.tsx index 1c99f4a4e..8767bb654 100644 --- a/packages/web/src/components/new-agent-dialog.tsx +++ b/packages/web/src/components/new-agent-dialog.tsx @@ -146,7 +146,8 @@ function asRuntimeProvider(provider: string): RuntimeProvider | null { provider === "claude-code-tui" || provider === "codex" || provider === "cursor" || - provider === "kimi-code" + provider === "kimi-code" || + provider === "opencode" ) { return provider; } @@ -170,6 +171,7 @@ function pickPreferredRuntime(caps: ClientCapabilities): RuntimeProvider | null // Same central-switch guard as the TUI line: a stale `ok` snapshot from a // daemon must not auto-pick a provider that has since been disabled. if (isRuntimeProviderEnabled("cursor") && caps.cursor?.state === "ok") return "cursor"; + if (isRuntimeProviderEnabled("opencode") && caps.opencode?.state === "ok") return "opencode"; // Any other provider (incl. one still disabled in a stale snapshot) is only // auto-picked when enabled. for (const [provider, entry] of Object.entries(caps)) { @@ -187,6 +189,7 @@ function prettyRuntimeLabel(provider: RuntimeProvider): string { if (provider === "codex") return "Codex"; if (provider === "cursor") return "Cursor"; if (provider === "kimi-code") return "Kimi Code"; + if (provider === "opencode") return "OpenCode"; return provider; } diff --git a/packages/web/src/pages/agent-detail.tsx b/packages/web/src/pages/agent-detail.tsx index 371d90a48..053dc6c88 100644 --- a/packages/web/src/pages/agent-detail.tsx +++ b/packages/web/src/pages/agent-detail.tsx @@ -994,7 +994,7 @@ function capabilitiesReported(client: HubClient): boolean { return Object.keys(client.capabilities ?? {}).length > 0; } -function runtimeSwitchAvailableProviders(client: HubClient): RuntimeProvider[] { +export function runtimeSwitchAvailableProviders(client: HubClient): RuntimeProvider[] { if (!capabilitiesReported(client)) return [...PROVIDER_ORDER]; return PROVIDER_ORDER.filter((provider) => client.capabilities[provider]?.available === true); } diff --git a/packages/web/src/pages/agent-detail/__tests__/runtime-switch-capabilities.test.ts b/packages/web/src/pages/agent-detail/__tests__/runtime-switch-capabilities.test.ts new file mode 100644 index 000000000..0208c15a7 --- /dev/null +++ b/packages/web/src/pages/agent-detail/__tests__/runtime-switch-capabilities.test.ts @@ -0,0 +1,50 @@ +import type { CapabilityEntry } from "@first-tree/shared"; +import { describe, expect, it } from "vitest"; +import type { HubClient } from "../../../api/activity.js"; +import { runtimeSwitchAvailableProviders } from "../../agent-detail.js"; + +function capability(state: CapabilityEntry["state"]): CapabilityEntry { + return { + state, + available: state === "ok", + authenticated: state === "ok", + authMethod: "none", + sdkVersion: null, + detectedAt: new Date(0).toISOString(), + }; +} + +function client(capabilities: HubClient["capabilities"]): HubClient { + return { + id: "client-win", + userId: "user-1", + status: "connected", + authState: "ok", + binName: "first-tree", + sdkVersion: "0.5.18", + hostname: "windows-host", + os: "win32", + agentCount: 0, + connectedAt: new Date(0).toISOString(), + lastSeenAt: new Date(0).toISOString(), + capabilities, + }; +} + +describe("runtime switch capability filtering", () => { + it("does not offer OpenCode when its installed Windows runtime is unavailable", () => { + const available = runtimeSwitchAvailableProviders( + client({ + codex: capability("ok"), + opencode: { + ...capability("error"), + runtimePath: "C:\\npm\\node_modules\\opencode-ai\\bin\\opencode.exe", + error: "installed but Windows execution is unsupported", + }, + }), + ); + + expect(available).toContain("codex"); + expect(available).not.toContain("opencode"); + }); +}); diff --git a/packages/web/src/pages/agent-detail/model-section.tsx b/packages/web/src/pages/agent-detail/model-section.tsx index 3ae01ea3c..1a90d83d8 100644 --- a/packages/web/src/pages/agent-detail/model-section.tsx +++ b/packages/web/src/pages/agent-detail/model-section.tsx @@ -82,6 +82,7 @@ const MODEL_OPTIONS_BY_PROVIDER: Record = { codex: CODEX_MODEL_OPTIONS, cursor: [], "kimi-code": [], + opencode: [], }; const MODEL_HELP_BY_PROVIDER: Record = { @@ -92,6 +93,8 @@ const MODEL_HELP_BY_PROVIDER: Record = { "Options come from this computer's Cursor CLI when reachable. The id is passed through verbatim on the next turn — one your account can't use fails visibly, no silent fallback. Unset uses the Cursor default (auto).", "kimi-code": "Options come from this computer's ~/.kimi-code config when reachable. Passed to new sessions. Unset uses the model configured in ~/.kimi-code.", + opencode: + "Enter an exact OpenCode provider/model id. It is passed through verbatim on the next turn; unset inherits the host-local OpenCode configuration.", }; /** Extra note when discovery is unsupported / offline / timed out (`null` from the API helper). */ @@ -454,9 +457,11 @@ function FreeFormModelInput({ placeholder={ provider === "kimi-code" ? "local Kimi default" - : provider === "cursor" - ? "auto (Cursor default)" - : "provider default" + : provider === "opencode" + ? "local OpenCode default" + : provider === "cursor" + ? "auto (Cursor default)" + : "provider default" } className="font-mono" aria-label="Model" diff --git a/packages/web/src/pages/agent-detail/reasoning-effort-section.tsx b/packages/web/src/pages/agent-detail/reasoning-effort-section.tsx index 31f6c23fb..3c82c2274 100644 --- a/packages/web/src/pages/agent-detail/reasoning-effort-section.tsx +++ b/packages/web/src/pages/agent-detail/reasoning-effort-section.tsx @@ -41,6 +41,7 @@ const EFFORT_OPTIONS_BY_PROVIDER: Record = { // cursor agents. The empty entry keeps the Record exhaustive. cursor: [], "kimi-code": [], + opencode: [], }; const EFFORT_HELP_BY_PROVIDER: Record = { @@ -49,6 +50,7 @@ const EFFORT_HELP_BY_PROVIDER: Record = { codex: "Applies to new sessions. Higher means more reasoning per turn; max and ultra require a compatible model.", cursor: "Cursor encodes effort in the model id; there is no separate control.", "kimi-code": "Kimi thinking configuration is inherited from the local Kimi configuration.", + opencode: "OpenCode model variants are provider-native; there is no separate First Tree effort control.", }; export type ReasoningEffortSectionProps = { diff --git a/packages/web/src/pages/agent-detail/runtime-section.tsx b/packages/web/src/pages/agent-detail/runtime-section.tsx index 52d0e6a10..7ddedeca2 100644 --- a/packages/web/src/pages/agent-detail/runtime-section.tsx +++ b/packages/web/src/pages/agent-detail/runtime-section.tsx @@ -32,6 +32,7 @@ const RUNTIME_NAME: Record = { codex: "Codex", cursor: "Cursor", "kimi-code": "Kimi Code", + opencode: "OpenCode", }; export function RuntimeSection(props: RuntimeSectionProps) { diff --git a/packages/web/src/pages/agent-detail/runtime-tab.tsx b/packages/web/src/pages/agent-detail/runtime-tab.tsx index c7a5b0592..226b61d37 100644 --- a/packages/web/src/pages/agent-detail/runtime-tab.tsx +++ b/packages/web/src/pages/agent-detail/runtime-tab.tsx @@ -87,7 +87,9 @@ export function RuntimeTab() { {/* Cursor has no separate reasoning-effort channel — effort/fast variants live in the provider-native model id, so the control is hidden rather than rendered empty. */} - {ctx.setupRuntimeProvider !== "cursor" && ctx.setupRuntimeProvider !== "kimi-code" ? ( + {ctx.setupRuntimeProvider !== "cursor" && + ctx.setupRuntimeProvider !== "kimi-code" && + ctx.setupRuntimeProvider !== "opencode" ? ( configSave.save({ reasoningEffort: v }, { field: "effort" })} diff --git a/packages/web/src/pages/clients/__tests__/opencode-provider-surfaces.test.ts b/packages/web/src/pages/clients/__tests__/opencode-provider-surfaces.test.ts new file mode 100644 index 000000000..db6fc5672 --- /dev/null +++ b/packages/web/src/pages/clients/__tests__/opencode-provider-surfaces.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { + buildInstallCommand, + PROVIDER_LABEL, + providerInstallHint, + runtimeProviderLabel, +} from "../cards/shared/providers.js"; +import { providerSupportsInProductAuth } from "../cards/shared/runtime-auth-view.js"; + +describe("OpenCode provider surfaces", () => { + it("labels OpenCode and keeps auth host-local", () => { + expect(PROVIDER_LABEL.opencode).toBe("OpenCode"); + expect(runtimeProviderLabel("opencode")).toBe("OpenCode"); + expect(providerSupportsInProductAuth("opencode")).toBe(false); + }); + + it("shows the pinned package family and provider-owned login command", () => { + expect(buildInstallCommand("opencode")).toBe("npm install -g opencode-ai@^1.18.7\nopencode auth login"); + expect(providerInstallHint("opencode", "win32")).toContain("opencode auth login"); + expect(providerInstallHint("opencode", "win32")).toContain("Windows PC"); + }); +}); diff --git a/packages/web/src/pages/clients/cards/shared/providers.ts b/packages/web/src/pages/clients/cards/shared/providers.ts index ac651c3ef..26f2f9492 100644 --- a/packages/web/src/pages/clients/cards/shared/providers.ts +++ b/packages/web/src/pages/clients/cards/shared/providers.ts @@ -21,6 +21,7 @@ export const PROVIDER_ORDER: RuntimeProvider[] = [ RUNTIME_PROVIDERS.CODEX, RUNTIME_PROVIDERS.CURSOR, RUNTIME_PROVIDERS.KIMI_CODE, + RUNTIME_PROVIDERS.OPENCODE, ].filter((p) => isRuntimeProviderEnabled(p)); export const PROVIDER_LABEL: Record = { @@ -29,6 +30,7 @@ export const PROVIDER_LABEL: Record = { codex: "Codex", cursor: "Cursor", "kimi-code": "Kimi Code", + opencode: "OpenCode", }; const KNOWN_RUNTIME_PROVIDERS: readonly string[] = Object.values(RUNTIME_PROVIDERS); @@ -71,6 +73,7 @@ export const PROVIDER_NPM_PACKAGE: Record = { // Runtime execution is bundled, but the official CLI remains the supported // operator login/recovery surface for the shared ~/.kimi-code credential. "kimi-code": "@moonshot-ai/kimi-code", + opencode: "opencode-ai@^1.18.7", }; /** @@ -95,6 +98,7 @@ export const PROVIDER_LOGIN_COMMAND: Record = { codex: "codex login", cursor: "cursor-agent login", "kimi-code": "kimi # then run /login", + opencode: "opencode auth login", }; /** @@ -217,5 +221,8 @@ export function providerInstallHint( if (provider === "kimi-code") { return `Install the official Kimi CLI with \`npm install -g @moonshot-ai/kimi-code\` on this ${device}, run \`kimi\`, then \`/login\`. First Tree still executes through its bundled Kimi SDK.`; } + if (provider === "opencode") { + return `Run \`npm install -g opencode-ai@^1.18.7\` on this ${device}, then complete provider-owned setup with \`opencode auth login\`.`; + } return `Install the OpenAI Codex CLI on this ${device}.`; }