diff --git a/README.md b/README.md index 4abbd5bd932cf..5c671943f55c1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigravity, and fx. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigravity, ZCode, and fx. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,7 +13,7 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, Antigravity, and fx. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build, OpenCode, Antigravity, ZCode, and fx. Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` @@ -22,6 +22,7 @@ We wanted something performant, remote-ready, and truly open. If we ever go the > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` > - Antigravity: enable it in Settings, then use **Install Antigravity** and **Sign in with Google**. No CLI is required. > - fx: install [fx](https://fx.sh) and run `fx login` on the machine running the T3 Code server ([setup guide](./docs/user/providers-fx.md)) +> - ZCode: install [ZCode](./docs/user/providers-zcode.md) and run `zcode login` on the machine running the T3 Code server ### Command line diff --git a/apps/marketing/src/pages/llms.txt.ts b/apps/marketing/src/pages/llms.txt.ts index ec7fdde354a2f..f8b8fac4dddd6 100644 --- a/apps/marketing/src/pages/llms.txt.ts +++ b/apps/marketing/src/pages/llms.txt.ts @@ -7,7 +7,7 @@ import { absoluteUrl, SITE_TAGLINE } from "../lib/seo"; export const GET: APIRoute = () => { const body = `# T3 Code -> ${SITE_TAGLINE}. T3 Code is free, MIT-licensed, and built by T3 Tools Inc. It runs Claude Code, Codex, Cursor, OpenCode, Grok, Antigravity, and other coding agents from one desktop, web, and mobile app. +> ${SITE_TAGLINE}. T3 Code is free, MIT-licensed, and built by T3 Tools Inc. It runs Claude Code, Codex, Cursor, OpenCode, Grok, Antigravity, ZCode, and other coding agents from one desktop, web, and mobile app. The only official websites are https://t3.codes and the web app at https://app.t3.codes. Official builds come from ${absoluteUrl("/download")}, ${GITHUB_REPOSITORY_URL}/releases, the \`t3\` package on npm (\`npx t3@latest\`), the iOS App Store, and Google Play. Any other website that offers a T3 Code download is not affiliated with T3 Tools Inc. diff --git a/apps/mobile/src/features/settings/settings-search.ts b/apps/mobile/src/features/settings/settings-search.ts index 4d02bd9789b1d..3eca76666865c 100644 --- a/apps/mobile/src/features/settings/settings-search.ts +++ b/apps/mobile/src/features/settings/settings-search.ts @@ -83,6 +83,7 @@ const SETTINGS_SEARCH_TERMS = { "claude", "cursor", "opencode", + "zcode", "sign in", "login", "auth", diff --git a/apps/server/package.json b/apps/server/package.json index d7e4d21f08b82..44d28bb5d98c4 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -53,6 +53,7 @@ "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", + "@t3tools/provider-zcode": "workspace:*", "@t3tools/source-control-azure-devops": "workspace:*", "@t3tools/source-control-bitbucket": "workspace:*", "@t3tools/source-control-core": "workspace:*", diff --git a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts new file mode 100644 index 0000000000000..26ccb55a805ed --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts @@ -0,0 +1,819 @@ +// Frame-driven unit tests for the ZCode adapter: a fake NDJSON peer is injected through +// ZcodeAdapterV2Options.makeConnection, and the real makeZcodeAdapterV2 drives it end to end. +// Frames are hand-written from the Phase 0 probe notes; live-recorded replay fixtures (Phase 3) +// must replace or augment these before merge confidence is complete. +import { assert, describe, it } from "@effect/vitest"; +import { + EnvironmentId, + MessageId, + NodeId, + type OrchestrationV2AppThread, + type OrchestrationV2ProviderThread, + ProjectId, + ProviderInstanceId, + ProviderSessionId, + RunAttemptId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; + +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { ZcodeAdapterV2Driver } from "@t3tools/provider-zcode/server"; +import { ZCODE_PROVIDER, makeZcodeAdapterV2 } from "@t3tools/provider-zcode/testing"; + +import { + type FakeZcodeScenario, + ZcodeAdapterTestLayer, + fakeZcodeRequestAuth, + makeFakeZcodeConnection, + makeZcodeAdapterOptions, + zcodeDriverKind, + zcodeServerRequest, + zcodeSessionEvent, +} from "./ZcodeAdapterV2.testkit.ts"; +import type { + ZcodeAdapterV2Options, + ZcodeConnection, + ZcodeProtocolError, +} from "@t3tools/provider-zcode/testing"; + +const INSTANCE_ID = ProviderInstanceId.make("zcode_frame_test"); +const THREAD_ID = ThreadId.make("thread-zcode-frame-test"); +const PROVIDER_SESSION_ID = ProviderSessionId.make("provider-session-zcode-frame-test"); +const MODEL_SELECTION = { instanceId: INSTANCE_ID, model: "default" }; +const TURN_TEXT = "Run the zcode frame tests"; + +const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: null, +}); + +const fullAccessRuntimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: null, +}); + +const makeTurnInput = Effect.fnUntraced(function* ( + providerThread: OrchestrationV2ProviderThread, + text = TURN_TEXT, + model = MODEL_SELECTION.model, +): Effect.fn.Return { + const now = yield* DateTime.now; + const threadId = providerThread.appThreadId ?? THREAD_ID; + const appThread: OrchestrationV2AppThread = { + id: threadId, + projectId: ProjectId.make("project-zcode-frame-test"), + title: "ZCode frame test", + createdBy: "user", + creationSource: "web", + providerInstanceId: providerThread.providerInstanceId, + modelSelection: { instanceId: INSTANCE_ID, model }, + runtimeMode: runtimePolicy.runtimeMode, + interactionMode: runtimePolicy.interactionMode, + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + return { + appThread, + threadId, + runId: RunId.make("run-zcode-frame-1"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("attempt-zcode-frame-1"), + rootNodeId: NodeId.make("root-zcode-frame-1"), + providerThread, + message: { + messageId: MessageId.make("message-zcode-frame-1"), + text, + attachments: [], + createdBy: "user", + creationSource: "web", + scheduledTaskId: undefined, + senderThreadId: undefined, + }, + modelSelection: { instanceId: INSTANCE_ID, model }, + runtimePolicy, + }; +}); + +const makeHarness = Effect.fnUntraced(function* ( + override: Partial = {}, + // Lets a test intercept client→server methods on the injected fake connection. + wrapConnection: (connection: ZcodeConnection) => ZcodeConnection = (connection) => connection, +) { + const scenario = yield* makeFakeZcodeConnection(); + const adapter = yield* makeZcodeAdapterV2({ + ...makeZcodeAdapterOptions(), + makeConnection: () => Effect.succeed(wrapConnection(scenario.connection)), + ...override, + }); + const runtime = yield* adapter.openSession({ + threadId: THREAD_ID, + providerSessionId: PROVIDER_SESSION_ID, + modelSelection: MODEL_SELECTION, + runtimePolicy, + }); + const emitted = yield* Queue.unbounded(); + const allEvents: Array = []; + yield* runtime.events.pipe( + Stream.runForEach((event) => + Effect.gen(function* () { + allEvents.push(event); + yield* Queue.offer(emitted, event); + }), + ), + Effect.ignore, + Effect.forkScoped, + ); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: MODEL_SELECTION, + runtimePolicy, + }); + const takeEvent = Effect.fnUntraced(function* < + T extends ProviderAdapter.ProviderAdapterV2Event["type"], + >( + type: T, + predicate: ( + event: Extract, + ) => boolean = () => true, + ) { + while (true) { + const event = yield* Queue.take(emitted); + if ( + event.type === type && + predicate(event as Extract) + ) { + return event as Extract; + } + } + }); + const offerTurnEvent = (type: string, payload: Record) => + scenario.offerEvent( + zcodeSessionEvent(scenario.createdSessionId() ?? "sess_missing", type, payload), + ); + return { + scenario, + runtime, + providerThread, + allEvents, + takeEvent, + offerTurnEvent, + startTurn: (text?: string, model?: string) => + Effect.gen(function* () { + const input = yield* makeTurnInput(providerThread, text, model); + return yield* runtime.startTurn(input); + }), + }; +}); + +const requestFor = (scenario: FakeZcodeScenario, method: string) => + scenario.requests.find((call) => call.method === method); + +describe("ZcodeAdapterV2 (frame-driven)", () => { + it("exposes the driver kind and default config through the package driver", () => { + assert.equal(ZcodeAdapterV2Driver.driverKind, ZCODE_PROVIDER); + assert.equal(zcodeDriverKind, ZCODE_PROVIDER); + assert.equal(ZcodeAdapterV2Driver.defaultConfig().binaryPath, "zcode"); + }); + + it.effect( + "creates the native session, answers server requests, and injects the T3 MCP server", + () => + Effect.gen(function* () { + yield* (yield* McpProviderSessions.McpProviderSessions).set({ + environmentId: EnvironmentId.make("environment-zcode-frame-test"), + threadId: THREAD_ID, + providerSessionId: "zcode-frame-session", + providerInstanceId: INSTANCE_ID, + endpoint: "http://127.0.0.1:43999/mcp", + authorizationHeader: "Bearer zcode-frame-test-token", + browserToolsAvailable: true, + }); + const harness = yield* makeHarness(); + assert.equal(harness.providerThread.nativeThreadRef?.nativeId, "sess_1"); + + const create = requestFor(harness.scenario, "session/create"); + assert.isDefined(create); + const workspace = (create!.params["workspace"] ?? {}) as Record; + assert.equal(workspace["workspaceKey"], workspace["workspacePath"]); + assert.equal((workspace["workspacePath"] as string).length > 0, true); + assert.equal(create!.params["titleGenerationEnabled"], false); + // "default" defers to the CLI's own model selection. + assert.equal(create!.params["model"], undefined); + assert.deepEqual(create!.params["mcpServers"], [ + { + name: "t3-code", + type: "http", + url: "http://127.0.0.1:43999/mcp", + headers: [{ name: "Authorization", value: "Bearer zcode-frame-test-token" }], + }, + ]); + + // The broker requests the fake pushed during session/create must be answered; the + // respond queue synchronizes the assertions with the adapter's pump. + const preferenceRespond = yield* harness.scenario.takeRespond; + assert.deepEqual(preferenceRespond.result, { + nativeSearchEnhancementsEnabled: false, + toolSearchMode: "off", + memoryEnabled: false, + askUserQuestionAutoResolutionEnabled: true, + modelContextBudgetStrategy: "preflight-v1", + }); + const mcpAuthRespond = yield* harness.scenario.takeRespond; + assert.deepEqual(mcpAuthRespond.result, {}); + + yield* harness.takeEvent("provider_thread.updated"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect( + "forceNewNativeThread opens a fresh native session instead of resuming the cached one", + () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + assert.equal(harness.providerThread.nativeThreadRef?.nativeId, "sess_1"); + // Recovery (ProviderTurnStartService) re-ensures the same provider thread row with the + // native ref cleared and demands a fresh native conversation. + const replacement = yield* harness.runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: MODEL_SELECTION, + runtimePolicy, + existingProviderThread: { ...harness.providerThread, nativeThreadRef: null }, + forceNewNativeThread: true, + }); + assert.equal(replacement.id, harness.providerThread.id); + assert.equal(replacement.nativeThreadRef?.nativeId, "sess_2"); + const creates = harness.scenario.requests.filter( + (call) => call.method === "session/create", + ); + assert.equal(creates.length, 2); + assert.isUndefined( + harness.scenario.requests.find((call) => call.method === "session/resume"), + ); + assert.equal(harness.scenario.createdSessionId(), "sess_2"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect("reapplies the runtime mode when a detached session resumes", () => + Effect.gen(function* () { + const openRuntime = (scenario: FakeZcodeScenario, policy: typeof runtimePolicy) => + Effect.gen(function* () { + const adapter = yield* makeZcodeAdapterV2({ + ...makeZcodeAdapterOptions(), + makeConnection: () => Effect.succeed(scenario.connection), + }); + return yield* adapter.openSession({ + threadId: THREAD_ID, + providerSessionId: PROVIDER_SESSION_ID, + modelSelection: MODEL_SELECTION, + runtimePolicy: policy, + }); + }); + + const createdScenario = yield* makeFakeZcodeConnection(); + const created = yield* openRuntime(createdScenario, fullAccessRuntimePolicy); + const providerThread = yield* created.ensureThread({ + threadId: THREAD_ID, + modelSelection: MODEL_SELECTION, + runtimePolicy: fullAccessRuntimePolicy, + }); + assert.equal(requestFor(createdScenario, "session/create")?.params["mode"], "yolo"); + + // thread.runtime-mode.set detaches the session; the next turn reopens and resumes it. + const resumedScenario = yield* makeFakeZcodeConnection(); + const resumed = yield* openRuntime(resumedScenario, runtimePolicy); + yield* resumed.ensureThread({ + threadId: THREAD_ID, + modelSelection: MODEL_SELECTION, + runtimePolicy, + existingProviderThread: providerThread, + }); + assert.isUndefined(requestFor(resumedScenario, "session/create")); + assert.equal(requestFor(resumedScenario, "session/resume")?.params["sessionId"], "sess_1"); + assert.deepEqual(requestFor(resumedScenario, "session/setMode")?.params, { + sessionId: "sess_1", + mode: "guarded", + }); + assert.deepEqual(resumedScenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect("streams text deltas and completes the turn with token usage", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.startTurn(); + const send = requestFor(harness.scenario, "session/send"); + assert.isDefined(send); + assert.equal(send!.params["sessionId"], "sess_1"); + assert.equal(send!.params["content"], TURN_TEXT); + assert.equal(send!.params["modelSelection"], undefined); + + yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + yield* harness.offerTurnEvent("turn.started", {}); + // The CLI's real stream: model.streaming deltas keyed by assistantMessageId. + yield* harness.offerTurnEvent("model.streaming", { + assistantMessageId: "msg_a", + delta: "Hello", + kind: "text_delta", + done: false, + }); + yield* harness.offerTurnEvent("model.streaming", { + assistantMessageId: "msg_a", + delta: " world", + kind: "text_delta", + done: false, + }); + yield* harness.offerTurnEvent("model.streaming", { + assistantMessageId: "msg_a", + delta: "", + kind: "text_delta", + done: true, + }); + yield* harness.offerTurnEvent("turn.completed", { + response: "Hello world", + tokenCount: 120, + toolCallCount: 0, + duration: 420, + resultType: "success", + }); + + const firstDelta = yield* harness.takeEvent("command_output.delta"); + assert.equal(firstDelta.chunk, "Hello"); + const secondDelta = yield* harness.takeEvent("command_output.delta"); + assert.equal(secondDelta.chunk, " world"); + + yield* harness.takeEvent("turn_item.updated", (event) => + event.turnItem.type === "assistant_message" + ? event.turnItem.streaming === false && event.turnItem.text === "Hello world" + : false, + ); + yield* harness.takeEvent("message.updated", (event) => event.message.text === "Hello world"); + // The completed turn update precedes the terminal on the adapter stream. + const finalTurnUpdate = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "completed", + ); + assert.equal(finalTurnUpdate.providerTurn.tokenUsage?.usedTokens, 120); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "completed"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect("keeps reasoning and answer deltas of one message as separate stream items", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.startTurn(); + yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + yield* harness.offerTurnEvent("turn.started", {}); + // One assistant message streams reasoning first, then the answer; each kind must own + // its own item and completion state. + yield* harness.offerTurnEvent("model.streaming", { + assistantMessageId: "msg_mix", + delta: "pondering", + kind: "reasoning_delta", + done: false, + }); + yield* harness.offerTurnEvent("model.streaming", { + assistantMessageId: "msg_mix", + delta: "", + kind: "reasoning_delta", + done: true, + }); + yield* harness.offerTurnEvent("model.streaming", { + assistantMessageId: "msg_mix", + delta: "the answer", + kind: "text_delta", + done: false, + }); + yield* harness.offerTurnEvent("model.streaming", { + assistantMessageId: "msg_mix", + delta: "", + kind: "text_delta", + done: true, + }); + yield* harness.offerTurnEvent("turn.completed", { + response: "the answer", + tokenCount: 8, + toolCallCount: 0, + duration: 90, + resultType: "success", + }); + + const reasoningChunk = yield* harness.takeEvent("command_output.delta"); + assert.equal(reasoningChunk.chunk, "pondering"); + // Takes discard non-matching events, so consume in emission order: the reasoning item + // completes (its done frame) before the answer delta streams. + yield* harness.takeEvent("turn_item.updated", (event) => + event.turnItem.type === "reasoning" + ? event.turnItem.streaming === false && event.turnItem.text === "pondering" + : false, + ); + const answerChunk = yield* harness.takeEvent( + "command_output.delta", + (event) => event.chunk === "the answer", + ); + assert.notEqual(answerChunk.itemId, reasoningChunk.itemId); + + yield* harness.takeEvent("turn_item.updated", (event) => + event.turnItem.type === "assistant_message" + ? event.turnItem.streaming === false && event.turnItem.text === "the answer" + : false, + ); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "completed"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect( + "sends account-model turns with the advertised reasoning level and serves runtime headers", + () => + Effect.gen(function* () { + const harness = yield* makeHarness({ makeRequestAuth: fakeZcodeRequestAuth() }); + // "default" omits the model entirely; account slugs select per turn. + yield* harness.startTurn(TURN_TEXT, "account:zai-individual-coding-plan/GLM-5.3-Flash"); + const send = requestFor(harness.scenario, "session/send"); + assert.isDefined(send); + assert.deepEqual(send!.params["modelSelection"], { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3-Flash", + options: { reasoningLevel: "max" }, + }); + + // Before an account-model request the CLI asks the host for project runtime headers; + // the injected request auth's response must reach the wire verbatim. + const broker = zcodeServerRequest("interaction/requestProviderRuntimeHeaders", { + sessionId: harness.scenario.createdSessionId(), + modelSelection: { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3-Flash", + options: { reasoningLevel: "max" }, + }, + }); + yield* harness.scenario.offerEvent(broker); + // The first two responds are the create-time runtime preferences and MCP auth headers. + yield* harness.scenario.takeRespond; + yield* harness.scenario.takeRespond; + const answered = yield* harness.scenario.takeRespond; + assert.equal(answered.id, broker.id); + assert.deepEqual(answered.result, { + headersApplied: true, + requestAuth: { apiKey: "test", apiKeyId: "k", accountScope: "a" }, + }); + + yield* harness.offerTurnEvent("turn.completed", { + response: "pong", + tokenCount: 3, + toolCallCount: 0, + duration: 60, + resultType: "success", + }); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "completed"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect("maps shell tool lifecycle into command execution items", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.startTurn(); + yield* harness.offerTurnEvent("tool.updated", { + kind: "started", + toolCallId: "call_1", + toolName: "Bash", + input: { command: "echo hi" }, + }); + yield* harness.offerTurnEvent("tool.updated", { + kind: "result", + toolCallId: "call_1", + duration: 12, + result: { output: "hi" }, + }); + yield* harness.offerTurnEvent("turn.completed", { + response: "done", + tokenCount: 10, + toolCallCount: 1, + duration: 100, + resultType: "success", + }); + + const started = yield* harness.takeEvent( + "turn_item.updated", + (event) => + event.turnItem.type === "command_execution" && event.turnItem.status === "running", + ); + assert.equal( + started.turnItem.type === "command_execution" ? started.turnItem.input : "", + "echo hi", + ); + const finished = yield* harness.takeEvent( + "turn_item.updated", + (event) => + event.turnItem.type === "command_execution" && event.turnItem.status === "completed", + ); + assert.equal( + finished.turnItem.type === "command_execution" ? finished.turnItem.output : "", + "hi", + ); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "completed"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect("relays the exact scripted PermissionResponse to the broker request", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.startTurn(); + const allowResponse = { decision: "allow", reason: "Approved for this command" }; + yield* harness.offerTurnEvent("permission.requested", { + requestId: "perm_1", + toolCallId: "call_9", + toolName: "Bash", + riskLevel: "medium", + reason: "Run a shell command", + input: { command: "echo hi" }, + options: [ + { + optionId: "allowOnce", + kind: "allow_once", + name: "Allow once", + response: allowResponse, + }, + { + optionId: "deny", + kind: "deny", + name: "Deny", + response: { decision: "deny", reason: "Denied by user" }, + }, + ], + }); + // The broker request is delivered after the event but before the user answers; the + // adapter must hold it open so the eventual answer still reaches zcode. + const broker = zcodeServerRequest("interaction/requestPermission", { + sessionId: harness.scenario.createdSessionId(), + requestId: "perm_1", + toolCallId: "call_9", + reason: "Run a shell command", + riskLevel: "medium", + }); + yield* harness.scenario.offerEvent(broker); + + const requested = yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.status === "pending", + ); + assert.equal(requested.runtimeRequest.kind, "command"); + assert.equal(requested.runtimeRequest.nativeRequestRef?.nativeId, "perm_1"); + yield* harness.takeEvent( + "turn_item.updated", + (event) => event.turnItem.type === "approval_request", + ); + + yield* harness.runtime.respondToRuntimeRequest({ + requestId: requested.runtimeRequest.id, + decision: "accept", + }); + + // The respond queue synchronizes this with the adapter's pump: the answer may + // resolve while the broker record is still pending. + let relayed = yield* harness.scenario.takeRespond; + while (relayed.id !== broker.id) { + relayed = yield* harness.scenario.takeRespond; + } + assert.deepEqual(relayed.result, allowResponse); + yield* harness.takeEvent( + "runtime_request.updated", + (event) => event.runtimeRequest.status === "resolved", + ); + yield* harness.offerTurnEvent("turn.completed", { + response: "done", + tokenCount: 3, + toolCallCount: 1, + duration: 80, + resultType: "success", + }); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "completed"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect("interrupts via session/stop and reports the cancelled result as interrupted", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.startTurn(); + const running = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: running.providerTurn.id, + }); + const stop = requestFor(harness.scenario, "session/stop"); + assert.isDefined(stop); + assert.equal(stop!.params["sessionId"], "sess_1"); + yield* harness.offerTurnEvent("turn.completed", { + response: "", + tokenCount: 5, + toolCallCount: 0, + duration: 50, + resultType: "cancelled", + }); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "interrupted"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect("keeps the real terminal when session/stop rejects", () => + Effect.gen(function* () { + const stopRejection = { + _tag: "ZcodeProtocolError", + operation: "session/stop", + detail: "session rejected the stop", + } as unknown as ZcodeProtocolError; + const attempted: Array = []; + const harness = yield* makeHarness({}, (connection) => ({ + ...connection, + request: (method, params, timeoutMs) => { + if (method !== "session/stop") return connection.request(method, params, timeoutMs); + attempted.push(method); + return Effect.fail(stopRejection); + }, + })); + yield* harness.startTurn(); + const running = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + const stopError = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: running.providerTurn.id, + }) + .pipe(Effect.flip); + assert.equal(stopError._tag, "ProviderAdapterInterruptError"); + assert.deepEqual(attempted, ["session/stop"]); + // With the stop rejected, the turn keeps its actual outcome instead of being + // reported as interrupted. + yield* harness.offerTurnEvent("turn.completed", { + response: "done", + tokenCount: 5, + toolCallCount: 0, + duration: 50, + resultType: "success", + }); + const terminal = yield* harness.takeEvent("turn.terminal"); + assert.equal(terminal.providerTurnId, running.providerTurn.id); + assert.equal(terminal.status, "completed"); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect("finalizes the failed turn and session when session/send is rejected", () => + Effect.gen(function* () { + // A server-side method rejection surfaces as a ZcodeProtocolError on the request + // channel; the testing export ships the shape as a type only, so tag it directly. + const sendRejection = { + _tag: "ZcodeProtocolError", + operation: "session/send", + detail: "session rejected the prompt", + } as unknown as ZcodeProtocolError; + const attempted: Array = []; + const harness = yield* makeHarness({}, (connection) => ({ + ...connection, + request: (method, params, timeoutMs) => { + if (method !== "session/send") return connection.request(method, params, timeoutMs); + attempted.push(method); + return Effect.fail(sendRejection); + }, + })); + + const startFiber = yield* Effect.forkChild(harness.startTurn().pipe(Effect.flip)); + // The whole terminal sequence must reach the event stream before the start error + // surfaces: the run subscriber stops as soon as the caller sees the start failure. + const running = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + const failed = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "failed", + ); + assert.equal(failed.providerTurn.id, running.providerTurn.id); + yield* harness.takeEvent( + "provider_thread.updated", + (event) => event.providerThread.status === "idle", + ); + const erroredSession = yield* harness.takeEvent( + "provider_session.updated", + (event) => event.providerSession.status === "error", + ); + assert.equal(erroredSession.providerSession.lastError, "ZCode failed to start the turn."); + yield* harness.takeEvent( + "turn_item.updated", + (event) => event.turnItem.status === "failed" && event.turnItem.type === "error", + ); + const terminal = yield* harness.takeEvent( + "turn.terminal", + (event) => event.status === "failed", + ); + assert.equal(terminal.providerTurnId, failed.providerTurn.id); + + const startError = yield* Fiber.join(startFiber); + assert.equal(startError._tag, "ProviderAdapterTurnStartError"); + assert.deepEqual(attempted, ["session/send"]); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); + + it.effect( + "finalizes the failed compact turn and keeps the thread usable when session/compact is rejected", + () => + Effect.gen(function* () { + const compactRejection = { + _tag: "ZcodeProtocolError", + operation: "session/compact", + detail: "session rejected the compaction", + } as unknown as ZcodeProtocolError; + const attempted: Array = []; + const harness = yield* makeHarness({}, (connection) => ({ + ...connection, + request: (method, params, timeoutMs) => { + if (method !== "session/compact") return connection.request(method, params, timeoutMs); + attempted.push(method); + return Effect.fail(compactRejection); + }, + })); + + const compactInput = yield* makeTurnInput(harness.providerThread, "/compact"); + yield* harness.runtime.compactThread!(compactInput); + + yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "running", + ); + yield* harness.takeEvent( + "turn_item.updated", + (event) => event.turnItem.type === "compaction" && event.turnItem.status === "failed", + ); + const failed = yield* harness.takeEvent( + "provider_turn.updated", + (event) => event.providerTurn.status === "failed", + ); + yield* harness.takeEvent( + "provider_thread.updated", + (event) => event.providerThread.status === "idle", + ); + const erroredSession = yield* harness.takeEvent( + "provider_session.updated", + (event) => event.providerSession.status === "error", + ); + assert.equal(erroredSession.providerSession.lastError, "ZCode context compaction failed."); + const terminal = yield* harness.takeEvent( + "turn.terminal", + (event) => event.status === "failed", + ); + assert.equal(terminal.providerTurnId, failed.providerTurn.id); + + // The dead compact turn must not hold the thread: a later startTurn works normally. + yield* harness.startTurn(); + assert.isDefined(requestFor(harness.scenario, "session/send")); + yield* harness.offerTurnEvent("turn.completed", { + response: "after compaction failure", + tokenCount: 4, + toolCallCount: 0, + duration: 30, + resultType: "success", + }); + yield* harness.takeEvent("turn.terminal", (event) => event.status === "completed"); + assert.deepEqual(attempted, ["session/compact"]); + assert.deepEqual(harness.scenario.unexpectedMethods(), []); + }).pipe(Effect.scoped, Effect.provide(ZcodeAdapterTestLayer)), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts new file mode 100644 index 0000000000000..611850c8f76c6 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts @@ -0,0 +1,293 @@ +// Frame-driven unit tests for the ZCode adapter. Every frame below is hand-written from the +// Phase 0 probe notes (PROTOCOL-NOTES.md), i.e. a guess about the protocol; live-recorded +// replay fixtures (Phase 3) must replace or augment these before merge confidence is complete. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FetchHttpClient from "effect/http/FetchHttpClient"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; + +import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import { ZcodeAdapterV2Driver, ZCODE_PROVIDER } from "@t3tools/provider-zcode/server"; +import { + type ZcodeConnection, + type ZcodeProtocolError, + type ZcodeRecord, + type ZcodeAdapterV2Options, +} from "@t3tools/provider-zcode/testing"; + +export interface CapturedZcodeRequest { + readonly method: string; + readonly params: ZcodeRecord; +} + +export interface CapturedZcodeRespond { + readonly id: string; + readonly result: unknown; +} + +export interface FakeZcodeScenario { + readonly connection: ZcodeConnection; + /** Client→server requests in wire order. */ + readonly requests: ReadonlyArray; + /** Responses the adapter sent to server→client requests, in order. */ + readonly responds: ReadonlyArray; + /** Blocks until the adapter answers the next server→client request. */ + readonly takeRespond: Effect.Effect; + /** Pushes a notification record onto the wire for the adapter's pump. */ + readonly offerEvent: (record: ZcodeRecord) => Effect.Effect; + /** The session id the fake handed out for session/create. */ + readonly createdSessionId: () => string | undefined; + /** Methods the adapter sent that this fake does not script; must stay empty. */ + readonly unexpectedMethods: () => ReadonlyArray; +} + +const KNOWN_METHODS = new Set([ + "provider/updateAccountConfig", + "provider/refreshRegistry", + "session/create", + "session/read", + "session/resume", + "session/send", + "session/setMode", + "session/stop", + "session/subscribe", +]); + +const SESSION_CREATE_EPOCH_MS = 1_794_206_541_509; + +function zcodeSessionCreateResult(sessionId: string, params: ZcodeRecord): ZcodeRecord { + const workspace = (params["workspace"] ?? {}) as ZcodeRecord; + return { + messages: [], + projection: { + activeToolCalls: [], + backgroundJobs: [], + contextUsed: 0, + contextWindow: 200000, + mode: "build", + pendingPermissions: [], + sessionId: "unknown", + status: "idle", + target: null, + totalTokenCount: 0, + turnCount: 0, + }, + protocol: { name: "ZCode Protocol", version: 1 }, + runtime: { + eventSeq: 0, + pendingRequestIds: [], + goalVerifications: [], + goalVerificationTimeline: [], + stateRevision: 0, + }, + session: { + createdAt: SESSION_CREATE_EPOCH_MS, + mode: "build", + traceId: "trace-fake-zcode", + sessionId, + sessionKind: "interactive", + status: "idle", + target: null, + title: "", + updatedAt: SESSION_CREATE_EPOCH_MS, + workspace: { + workspacePath: String(workspace["workspacePath"] ?? ""), + workspaceKey: String(workspace["workspaceKey"] ?? ""), + }, + }, + settings: { + mode: { current: "build" }, + model: { + available: [ + { + ref: { providerId: "account:zai-individual-coding-plan", modelId: "GLM-5.3-Flash" }, + label: "GLM-5.3 Flash", + contextWindow: 200000, + maxOutputTokens: 128000, + reasoning: { levels: ["low", "high", "max"], defaultLevel: "max" }, + }, + ], + }, + permission: { mode: "build" }, + thoughtLevel: { available: [], enabled: false }, + }, + slashCommands: [], + }; +} + +/** Builds the fake `zcode app-server` NDJSON peer the adapter talks to. */ +export const makeFakeZcodeConnection = Effect.fnUntraced(function* (): Effect.fn.Return< + FakeZcodeScenario, + never, + never +> { + const events = yield* Queue.unbounded(); + const respondQueue = yield* Queue.unbounded(); + const requests: Array = []; + const responds: Array = []; + let sessionCounter = 0; + let serverRequestCounter = 0; + let createdSessionId: string | undefined; + + const offerServerRequest = (method: string, params: ZcodeRecord) => + Queue.offer(events, { + id: `server-${++serverRequestCounter}`, + method, + params, + }).pipe(Effect.asVoid); + + const request = ( + method: string, + params: ZcodeRecord, + ): Effect.Effect => + Effect.gen(function* () { + requests.push({ method, params }); + if (!KNOWN_METHODS.has(method)) return {}; + switch (method) { + case "session/create": { + createdSessionId = `sess_${++sessionCounter}`; + // The real server asks these while materializing the session runtime. + yield* offerServerRequest("session/requestRuntimePreferences", { + sessionId: createdSessionId, + scope: "runtime-materialization", + }); + yield* offerServerRequest("interaction/requestOfficialMcpAuthHeaders", { + mcpKey: "image_search", + pluginId: "image-search@zcode-plugins-official", + requestId: "official-mcp-auth:1", + targetOrigin: "https://zcode.z.ai", + workspace: { + workspacePath: String(params["workspace"] ?? ""), + workspaceKey: String(params["workspace"] ?? ""), + }, + }); + return zcodeSessionCreateResult(createdSessionId, params); + } + case "session/subscribe": + return { sessionId: createdSessionId, eventSeq: 1, events: [] }; + case "session/send": { + yield* Queue.offer(events, { + method: "state.updated", + params: { + patch: { status: "running" }, + reason: "prompt_started", + revision: 1, + scope: "session", + sessionId: createdSessionId, + }, + }); + return { sessionId: createdSessionId, accepted: true, stateRevision: 1 }; + } + case "provider/updateAccountConfig": + return { + receivedRevision: String(params["revision"] ?? ""), + providerCount: Object.keys((params["providers"] ?? {}) as ZcodeRecord).length, + status: "received", + }; + case "provider/refreshRegistry": + return { refreshed: true }; + case "session/read": + return zcodeSessionCreateResult(createdSessionId ?? "sess_unknown", params); + case "session/stop": + case "session/resume": + return {}; + default: + return {}; + } + }); + + const connection: ZcodeConnection = { + request, + respond: (id, result) => + Effect.gen(function* () { + const captured: CapturedZcodeRespond = { id, result }; + responds.push(captured); + yield* Queue.offer(respondQueue, captured); + }), + events, + exited: Effect.never, + terminate: Effect.void, + }; + + return { + connection, + requests, + responds, + takeRespond: Queue.take(respondQueue), + offerEvent: (record) => Queue.offer(events, record).pipe(Effect.asVoid), + createdSessionId: () => createdSessionId, + unexpectedMethods: () => + requests.filter((call) => !KNOWN_METHODS.has(call.method)).map((call) => call.method), + }; +}); + +let sharedEventCounter = 0; +let sharedServerRequestCounter = 0; + +/** + * session/event notification with the full envelope inside `params`, exactly as the wire + * carries it (PROTOCOL-NOTES.md: "params = envelope + type"). + */ +export const zcodeSessionEvent = ( + sessionId: string, + type: string, + payload: ZcodeRecord, +): ZcodeRecord => ({ + method: "session/event", + params: { + eventId: `evt_${++sharedEventCounter}`, + sessionId, + turnId: "turn_native_1", + seq: sharedEventCounter, + timestamp: SESSION_CREATE_EPOCH_MS + sharedEventCounter, + deliveryKind: "desktop-continuous", + type, + payload, + }, +}); + +/** Server→client request record the adapter must answer via connection.respond. */ +export const zcodeServerRequest = (method: string, params: ZcodeRecord): ZcodeRecord => ({ + id: `server-frame-${++sharedServerRequestCounter}`, + method, + params, +}); + +export const makeZcodeAdapterOptions = (): ZcodeAdapterV2Options => ({ + instanceId: ProviderInstanceId.make(ZcodeAdapterV2Driver.driverKind), + settings: ZcodeAdapterV2Driver.defaultConfig(), + environment: {}, +}); + +/** + * Request auth the adapter can drive without touching the local credential store or network: + * every runtime-headers request is answered with the fixed successful exchange. + */ +export const fakeZcodeRequestAuth = + (): NonNullable => () => ({ + answerRuntimeHeaders: () => + Effect.succeed({ + headersApplied: true, + requestAuth: { apiKey: "test", apiKeyId: "k", accountScope: "a" }, + }), + }); + +/** The driver kind the package driver and the hand-built adapter options must agree on. */ +export const zcodeDriverKind: typeof ZCODE_PROVIDER = ZCODE_PROVIDER; + +/** + * Context for driving the real adapter: ProviderHost (paths/credentials), node runtime services + * (spawner/file system/clock), derived id allocation, and the MCP session registry. + */ +export const ZcodeAdapterTestLayer = Layer.mergeAll( + TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), + NodeServices.layer, + IdAllocator.layer, + McpProviderSessions.layer, + // The adapter captures an HttpClient for the runtime-headers exchange; fakes never fetch. + FetchHttpClient.layer, +); diff --git a/apps/server/src/policy/managedProviders.ts b/apps/server/src/policy/managedProviders.ts index 059f6aeb4b782..08b564651f476 100644 --- a/apps/server/src/policy/managedProviders.ts +++ b/apps/server/src/policy/managedProviders.ts @@ -23,6 +23,7 @@ import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; +import { zcodeClient } from "@t3tools/provider-zcode/client"; import type { ProviderClientDefinition } from "@t3tools/provider-core/client"; interface KnownDriver { @@ -50,6 +51,7 @@ const PACKAGED_CLIENTS: ReadonlyArray = [ museClient, openCodeClient, piClient, + zcodeClient, acpRegistryClient, ]; diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 342a6780e9ad0..feea373399aeb 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -16,6 +16,7 @@ import type { ProviderUsageReaderEnv, } from "@t3tools/provider-core/server/driver"; import { KiroDriver, type KiroDriverEnv } from "@t3tools/provider-kiro/server"; +import { ZcodeDriver, type ZcodeDriverEnv } from "@t3tools/provider-zcode/server"; export type BuiltInDriversEnv = | AcpRegistryDriverEnv @@ -28,7 +29,8 @@ export type BuiltInDriversEnv = | KiroDriverEnv | OpenCodeDriverEnv | PiDriverEnv - | MuseDriverEnv; + | MuseDriverEnv + | ZcodeDriverEnv; export const BUILT_IN_DRIVERS: ReadonlyArray> = [ CodexDriver, @@ -41,6 +43,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray=2.27.0", "ranges": [{ "range": ">=2.27.0", "status": "supported" }] }, + { + "driver": "zcode", + "t3CodeRange": ">=0.0.46", + "recommendedRange": ">=0.17.1", + "ranges": [{ "range": ">=0.17.1", "status": "supported" }] + }, { "driver": "pi", "t3CodeRange": ">=0.0.46", diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 66e7d64d70a64..7130fe223d7bb 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -36,6 +36,7 @@ const BUILT_IN_DRIVER_ORDER: ReadonlyArray = [ "antigravity", "fx", "kiro", + "zcode", ]; const driverRank = (driver: string): number => { diff --git a/apps/server/src/settingsSecrets.ts b/apps/server/src/settingsSecrets.ts index 22ea8ae882583..8555d7ca4fb18 100644 --- a/apps/server/src/settingsSecrets.ts +++ b/apps/server/src/settingsSecrets.ts @@ -20,6 +20,7 @@ import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; +import { zcodeClient } from "@t3tools/provider-zcode/client"; import { readSecretFieldValues, SOURCE_CONTROL_HOST_SECRET_FIELDS, @@ -40,6 +41,7 @@ const providerSecretFields = new Map([ museClient, openCodeClient, piClient, + zcodeClient, acpRegistryClient, ].map( (definition) => diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index f7492035874da..52c1f65a166e7 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -941,7 +941,7 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Providers", to: "/settings/providers", searchTerms: [ - "agents cli codex claude cursor grok fx kiro opencode antigravity google sign in sign out install subscription instances authentication api key models configuration binary path config directory codex_home shadow home endpoint arguments environment variables display name accent color custom favorite hidden auto compact native sessions import resume", + "agents cli codex claude cursor grok fx kiro opencode antigravity google zcode z ai glm sign in sign out install subscription instances authentication api key models configuration binary path config directory codex_home shadow home endpoint arguments environment variables display name accent color custom favorite hidden auto compact native sessions import resume", ], }, { diff --git a/docs/user/faq.md b/docs/user/faq.md index 8d34de5e2ea45..4c6f1cc0bc3c0 100644 --- a/docs/user/faq.md +++ b/docs/user/faq.md @@ -6,7 +6,7 @@ No. A harness is what turns an LLM into an agent: it gives the model tools, a wa Chef = LLM. Kitchen = harness. T3 Code = the restaurant that lets you bring whatever kitchen you want. Our MCP is a cookbook we hand the kitchen, but the chef won't open it unless you tell them to :) I wrote the long version here: [What is a harness?](https://t3.gg/blog/post/what-is-a-harness) -Bring your own: Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigravity, Pi, or Muse Code. [Provider setup](./install.md#providers) +Bring your own: Claude Code, Codex, Cursor, Grok Build, OpenCode, Google Antigravity, Pi, ZCode, or Muse Code. [Provider setup](./install.md#providers) ## Why use T3 Code instead of Claude Code or the Codex app? diff --git a/docs/user/install.md b/docs/user/install.md index 7b28781e69612..8eb54f03426de 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -198,6 +198,7 @@ computer. | Pi | Install [Pi](./providers-pi.md#set-up-pi) with Node.js 22.19 or newer, then run `pi` and `/login`. | | Muse Code | Install [Muse Code](https://dev.meta.ai/docs/muse-code) on the server, run `muse login`, then enable it in Settings → Providers. | | fx | Install [fx](https://fx.sh) with `curl -fsSL https://fx.sh/setup.sh \| bash`, then run `fx login`. | +| ZCode | Install [ZCode](./providers-zcode.md#set-up-zcode), then run `zcode login`. | Provider CLIs must be on the server's `PATH`. If T3 Code cannot find one, set its **Binary path** in provider settings, especially when using a version manager. @@ -230,7 +231,8 @@ their original values. For provider-specific setup and accounts, see [Codex](./providers-codex.md), [Claude](./providers-claude.md), [OpenCode](./providers-opencode.md), [Antigravity](./providers-antigravity.md), [Kiro](./providers-kiro.md), -[Pi](./providers-pi.md), [Muse Code](./providers-muse.md), and [fx](./providers-fx.md). +[Pi](./providers-pi.md), [Muse Code](./providers-muse.md), [fx](./providers-fx.md), and +[ZCode](./providers-zcode.md). ## Next steps diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 796e3f316122d..a49b7a8501cb3 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -57,4 +57,7 @@ approvals for actions that support them. Kiro offers only **Supervised** and **Full access**. In **Supervised** Kiro also asks you to review a turn's file changes. See [Kiro permissions](./providers-kiro.md#permission-modes). +ZCode maps **Supervised** to its guarded mode, which gates every tool call through T3 Code. +Approvals offer only the choices ZCode sends. See [ZCode permissions](./providers-zcode.md#permission-modes). + See the [provider guides](./install.md#providers) for setup and provider-specific limits. diff --git a/docs/user/providers-zcode.md b/docs/user/providers-zcode.md new file mode 100644 index 0000000000000..f24de6f6567df --- /dev/null +++ b/docs/user/providers-zcode.md @@ -0,0 +1,45 @@ +# ZCode + +T3 Code runs the ZCode CLI as an agent, using your own Z.AI account, skills, plugins, and MCP +configuration. + +## Set Up ZCode + +1. Install ZCode on the machine running the T3 Code server. The ZCode app installs the `zcode` + CLI; make sure it is on that machine's `PATH`, or set its **Binary path** in provider settings. +2. Run `zcode login` once in a terminal on that machine. Login starts an OAuth authorization for + your Z.AI account, which works on headless servers too: finish it from a browser on any device. +3. Open **Settings → Providers**, enable ZCode, and refresh it. + +ZCode runs on the server's machine only. A desktop ZCode app elsewhere does not share its sign-in +with T3 Code; the server's own `zcode login` is what counts. + +## Credentials + +T3 Code never stores your Z.AI sign-in. The ZCode agent asks its host for credentials before every +account-model request, so the server stands in as that host: it reads the sign-in `zcode login` +already wrote on the same machine, exchanges it for a short-lived access token through the same +Z.AI endpoints the ZCode app uses, and holds that token in memory only. The store is never +written, so signing out or signing in again from a terminal applies on the next refresh. + +## Models + +After signing in, the model picker lists the GLM models your Z.AI plan offers — no API key or +model configuration needed. Reasoning follows each model's own default level unless you pick one. +New threads start on `default`, which defers to the model your ZCode session resolves on its own. +A model your account cannot use stops the turn with an error instead of running on another model. + +## Permission Modes + +ZCode gates tool calls by its own permission modes, and T3 Code answers by the permission mode: + +- **Supervised** maps to ZCode's guarded mode and sends tool approvals to you. Approvals offer + only the choices ZCode sends. +- **Auto-accept edits** maps to ZCode's edit mode, which approves file changes without asking. +- **Full access** maps to ZCode's yolo mode and approves tool calls without asking you. + +## Steering, Forks and Compaction + +- A message you send while ZCode is working joins the running turn. +- Forking a ZCode thread is not available yet, and the fork command is hidden for these threads. +- `/compact` uses ZCode's own compaction. diff --git a/package.json b/package.json index 9017f1117394b..ad63d042cc42d 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "knip": "knip --preprocessor ./scripts/knip-schemas.ts", "knip:check": "vp run knip:check:all", "knip:check:files": "knip --include files,dependencies --no-config-hints", - "knip:check:exports": "knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-fx --workspace packages/provider-grok --workspace packages/provider-kiro --workspace packages/secret-provider-core --workspace packages/secret-provider-definitions --workspace packages/secret-provider-onepassword --workspace packages/source-control-core --workspace packages/source-control-cursor-origin --workspace packages/source-control-definitions --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check:exports": "knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-fx --workspace packages/provider-grok --workspace packages/provider-kiro --workspace packages/provider-zcode --workspace packages/secret-provider-core --workspace packages/secret-provider-definitions --workspace packages/secret-provider-onepassword --workspace packages/source-control-core --workspace packages/source-control-cursor-origin --workspace packages/source-control-definitions --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "mobile:install": "node scripts/mobile-native-client.ts ensure", diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index c9e1260b956f7..f0c4e0985d3c6 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -572,6 +572,7 @@ "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", + "@t3tools/provider-zcode": "workspace:*", "@t3tools/secret-provider-definitions": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/source-control-core": "workspace:*", diff --git a/packages/client-runtime/src/providerClients.ts b/packages/client-runtime/src/providerClients.ts index 3759cecbc7c7d..125d127c2915d 100644 --- a/packages/client-runtime/src/providerClients.ts +++ b/packages/client-runtime/src/providerClients.ts @@ -15,6 +15,7 @@ import { kiroClient } from "@t3tools/provider-kiro/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; +import { zcodeClient } from "@t3tools/provider-zcode/client"; // OpenRouter's Ink and Cloud glyphs from https://openrouter.ai/brand. export const openRouterIcon: ProviderIcon = { @@ -56,6 +57,7 @@ export const providerClients = makeProviderClientRegistry([ kiroClient, museClient, piClient, + zcodeClient, acpRegistryClient, ]); diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 1a2a519e1abc7..a6fa700cc59f5 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -124,6 +124,7 @@ const MUSE_DRIVER_KIND = ProviderDriverKind.make("muse"); const PI_DRIVER_KIND = ProviderDriverKind.make("pi"); const ACP_REGISTRY_DRIVER_KIND = ProviderDriverKind.make("acpRegistry"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const ZCODE_DRIVER_KIND = ProviderDriverKind.make("zcode"); export const DEFAULT_MODEL = "gpt-6-astra"; @@ -155,6 +156,8 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [ACP_REGISTRY_DRIVER_KIND]: "ACP Registry", [PI_DRIVER_KIND]: "Pi", [OPENCODE_DRIVER_KIND]: "OpenCode", + [ZCODE_DRIVER_KIND]: "ZCode", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cd112d84bf9f4..ed5178305a491 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1565,7 +1565,7 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un // Must match each driver's enabled decoding default. const DEFAULT_DISABLED_PROVIDER_DRIVERS: ReadonlySet = new Set( - ["cursor", "grok", "fx", "kiro", "muse", "pi", "opencode", "antigravity"].map((driver) => + ["cursor", "grok", "fx", "kiro", "muse", "pi", "opencode", "antigravity", "zcode"].map((driver) => ProviderDriverKind.make(driver), ), ); diff --git a/packages/provider-zcode/package.json b/packages/provider-zcode/package.json new file mode 100644 index 0000000000000..8199c290b53d4 --- /dev/null +++ b/packages/provider-zcode/package.json @@ -0,0 +1,40 @@ +{ + "name": "@t3tools/provider-zcode", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./testing": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + } + }, + "scripts": { + "typecheck": "effect-tsgo tsrs --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@t3tools/provider-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-zcode/src/client.ts b/packages/provider-zcode/src/client.ts new file mode 100644 index 0000000000000..7b4d738e60e9c --- /dev/null +++ b/packages/provider-zcode/src/client.ts @@ -0,0 +1,20 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { ZcodeSettings } from "./settings.ts"; + +export const zcodeClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("zcode"), + label: "ZCode", + settingsSchema: ZcodeSettings, + icon: { + viewBox: "0 0 1024 1024", + fill: { light: "#18181B", dark: "#FAFAFA" }, + paths: [ + { + // Simplified trace of the official ZCode italic "Z" glyph. + d: "M304 288 H804 V340 L560 684 H804 V736 H280 V684 L524 340 H304 Z", + }, + ], + }, +}); diff --git a/packages/provider-zcode/src/server.ts b/packages/provider-zcode/src/server.ts new file mode 100644 index 0000000000000..132a4ce0c57be --- /dev/null +++ b/packages/provider-zcode/src/server.ts @@ -0,0 +1,6 @@ +export { ZcodeDriver, type ZcodeDriverEnv } from "./server/driver.ts"; +export { + ZCODE_PROVIDER, + ZcodeAdapterV2Driver, + type ZcodeAdapterV2DriverEnv, +} from "./server/adapter.ts"; diff --git a/packages/provider-zcode/src/server/adapter.ts b/packages/provider-zcode/src/server/adapter.ts new file mode 100644 index 0000000000000..6dfb85848796a --- /dev/null +++ b/packages/provider-zcode/src/server/adapter.ts @@ -0,0 +1,2444 @@ +import { AgentScope } from "@t3tools/shared/AgentScope"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Predicate from "effect/Predicate"; +import { + type OrchestrationV2ConversationMessage, + type OrchestrationV2ExecutionNode, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderFailure, + type OrchestrationV2ProviderRef, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2RuntimeRequest, + type OrchestrationV2TurnItem, + type OrchestrationV2UserInputQuestion, + type ProviderApprovalDecision, + type ProviderApprovalOption, + ProviderDriverKind, + type ProviderInstanceId, + type ProviderRequestKind, + type RuntimeMode, + type RuntimeRequestId, +} from "@t3tools/contracts"; +import { ZcodeSettings } from "../settings.ts"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as HttpClient from "effect/http/HttpClient"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; +import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import type { McpProviderSessionConfig } from "@t3tools/provider-core/server/mcpSession"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "@t3tools/provider-core/server/adapterDriver"; + +import { + isServerRequest, + makeZcodeConnection, + zcodeJsonStringify, + zcodeRecordArray, + zcodeRecordField, + zcodeRecordNumber, + zcodeRecordString, + type ZcodeConnection, + type ZcodeRecord, +} from "./zcodeProtocol.ts"; +import { + readZcodeAppVersion, + resolveZcodeCatalogFiles, + resolveZcodeLaunchCommand, + zcodeDataRoot, + zcodeModelSelectionFromSlug, + zcodeRuntimeEnvironment, + zcodeSpawnArgs, + type ZcodeCatalogFileSet, +} from "./zcodeRuntime.ts"; +import { + makeZcodeRequestAuth, + zcodeAccountConfigPushFromLadder, + zcodeFamilyFromProviderId, +} from "./zcodeRequestAuth.ts"; + +export const ZCODE_PROVIDER: ProviderDriverKind = ProviderDriverKind.make("zcode"); +const DEFAULT_ZCODE_SETTINGS = Schema.decodeSync(ZcodeSettings)({}); + +const STREAM_FLUSH_MS = 50; +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +// MCP boot dominates session startup; the probe measured 10s+ with plugins enabled. +const SESSION_CREATE_TIMEOUT_MS = 150_000; +const T3_MCP_SERVER_NAME = "t3-code"; +const USAGE_LIMIT_ERROR_PATTERN = /rate limit|quota|usage limit|too many requests/i; + +const ZcodeProviderCapabilitiesV2: OrchestrationV2ProviderCapabilities = { + runtimePolicy: { enforcement: "client-boundary" }, + sessions: { + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: true, + supportsProviderSwitchingViaHandoff: true, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: true, + // The protocol has no conversation rollback method. + canRollbackThread: false, + // session/fork exists on the wire, but its thread-handoff mapping into T3 is unwired. + canForkThread: false, + canForkFromTurn: false, + canForkFromSubagentThread: false, + exposesNativeThreadId: true, + }, + turns: { + exposesNativeTurnId: false, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: true, + activeSteeringInterruptsTools: false, + activeSteeringAcceptsAttachments: false, + supportsSteeringByInterruptRestart: false, + supportsQueuedMessages: true, + terminalStatusQuality: "strong", + }, + streaming: { + streamsAssistantText: true, + streamsReasoning: true, + streamsToolOutput: true, + streamsPlanText: false, + emitsMessageCompleted: true, + }, + tools: { + exposesToolItemIds: true, + emitsToolStarted: true, + emitsToolCompleted: true, + emitsToolOutput: true, + supportsMcpTools: true, + supportsDynamicToolCallbacks: false, + }, + approvals: { + supportsCommandApproval: true, + supportsFileReadApproval: false, + supportsFileChangeApproval: false, + supportsApplyPatchApproval: false, + approvalsHaveNativeRequestIds: true, + approvalCallbacksAreLiveOnly: true, + approvalsCanOriginateFromSubagents: false, + }, + planning: { + emitsPlanUpdated: false, + emitsTodoList: false, + emitsProposedPlan: false, + supportsStructuredQuestions: true, + planDeltasHaveItemIds: false, + }, + subagents: { + // tool.updated carries subagent attribution, but the child-session projection shape is + // unverified; wrong OrchestrationV2Subagent records would corrupt the run tree. + supportsSubagents: false, + exposesSubagentThreadIds: false, + emitsSubagentLifecycle: false, + canWaitForSubagents: false, + canCloseSubagents: false, + canForkSubagentThread: false, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: true, + canGenerateSummaries: false, + canConsumeHandoffSummaries: true, + supportsDeltaHandoff: false, + supportsFullThreadHandoff: false, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: true, + supportsNestedCheckpointScopes: false, + providerCanRollbackConversation: false, + providerRollbackReturnsSnapshot: false, + providerCanReadConversationSnapshot: true, + }, + identity: { + nativeThreadIds: "strong", + nativeTurnIds: "weak", + nativeItemIds: "strong", + nativeRequestIds: "strong", + }, +}; + +export interface ZcodeAdapterV2Options { + readonly instanceId: ProviderInstanceId; + readonly settings: ZcodeSettings; + /** Instance environment already merged with the host process environment. */ + readonly environment: NodeJS.ProcessEnv; + /** Testkit injection point; production spawns the real `zcode app-server`. */ + readonly makeConnection?: typeof makeZcodeConnection; + /** Testkit injection point; production mints OAuth project tokens from the local store. */ + readonly makeRequestAuth?: typeof makeZcodeRequestAuth; +} + +function providerRef( + nativeId: string, + strength: "strong" | "weak" = "strong", +): OrchestrationV2ProviderRef { + return { driver: ZCODE_PROVIDER, nativeId, strength }; +} + +/** + * Zcode enforces tool gating itself: guarded gates everything through the permission client, + * edit auto-accepts file changes, yolo never asks. Unoffered stored modes arrive as + * Supervised, so anything unexpected falls back to the strictest mode. + */ +function zcodeSessionMode(runtimeMode: RuntimeMode | undefined): ZcodeSessionMode { + switch (runtimeMode) { + case "auto-accept-edits": + return "edit"; + case "full-access": + return "yolo"; + default: + return "guarded"; + } +} + +function contentText(content: unknown): string { + if (!Array.isArray(content)) return typeof content === "string" ? content : ""; + return content + .map((block) => + zcodeRecordString(block ?? {}, "type") === "text" + ? (zcodeRecordString(block, "text") ?? "") + : "", + ) + .join(""); +} + +interface ZcodeAvailableModel { + readonly providerId: string; + readonly modelId: string; + readonly defaultReasoningLevel?: string; +} + +/** Parses the create result's `settings.model.available` descriptors. */ +function parseZcodeAvailableModels(input: unknown): ReadonlyArray { + if (!Array.isArray(input)) return []; + const out: Array = []; + for (const entry of input) { + if (!Predicate.isObject(entry)) continue; + const ref = entry["ref"]; + const providerId = zcodeRecordString(ref, "providerId"); + const modelId = zcodeRecordString(ref, "modelId"); + if (providerId === undefined || modelId === undefined) continue; + const reasoning = entry["reasoning"]; + const defaultLevel = Predicate.isObject(reasoning) + ? zcodeRecordString(reasoning, "defaultLevel") + : undefined; + out.push({ + providerId, + modelId, + ...(defaultLevel === undefined ? {} : { defaultReasoningLevel: defaultLevel }), + }); + } + return out; +} + +function toolResultOutput(result: unknown): string | undefined { + if (!Predicate.isObject(result)) return undefined; + const direct = zcodeRecordString(result, "output") ?? zcodeRecordString(result, "text"); + if (direct !== undefined) return direct; + const content = zcodeRecordField(result, "content"); + if (content !== undefined) { + const text = contentText(content); + if (text.length > 0) return text; + } + return undefined; +} + +const SHELL_TOOL_NAMES = new Set(["bash", "shell", "powershell", "cmd", "terminal", "exec"]); +const FILE_WRITE_TOOL_NAMES = new Set(["write", "edit", "applypatch", "multiedit", "notebookedit"]); + +interface ZcodeStreamItemState { + readonly nativeItemId: string; + readonly kind: "assistant_message" | "reasoning"; + text: string; + completed: boolean; + flushScheduled: boolean; + readonly startedAt: DateTime.Utc; +} + +interface ZcodeToolState { + readonly toolCallId: string; + name: string | undefined; + input: unknown; + readonly startedAt: DateTime.Utc; + output: string | undefined; +} + +interface ZcodePermissionOptionRecord { + readonly optionId: string; + readonly kind: string; + readonly name: string; + readonly response: ZcodeRecord; +} + +function zcodePermissionOptions(value: unknown): ReadonlyArray { + const raw = zcodeRecordArray(value ?? {}, "options") ?? []; + const options: Array = []; + for (const entry of raw) { + const optionId = zcodeRecordString(entry ?? {}, "optionId"); + const kind = zcodeRecordString(entry ?? {}, "kind"); + const name = zcodeRecordString(entry ?? {}, "name"); + const response = zcodeRecordField(entry ?? {}, "response"); + if ( + optionId === undefined || + kind === undefined || + name === undefined || + !Predicate.isObject(response) + ) { + continue; + } + options.push({ optionId, kind, name, response }); + } + return options; +} + +/** Chooses the exact response object zcode's broker expects for a T3 decision. */ +function zcodeApprovalResponseForDecision( + options: ReadonlyArray, + decision: ProviderApprovalDecision, +): ZcodeRecord | undefined { + const byKind = (kinds: ReadonlyArray) => + options.find((option) => kinds.includes(option.kind))?.response; + const byResponseDecision = (value: string) => + options.find((option) => zcodeRecordString(option.response, "decision") === value)?.response; + switch (decision) { + case "accept": + return byKind(["allow_once"]) ?? byResponseDecision("allow"); + case "acceptForSession": + case "acceptAlways": + return byKind(["allow_always"]) ?? byKind(["allow_once"]) ?? byResponseDecision("allow"); + case "decline": + case "cancel": + return byKind(["deny", "reject"]) ?? byResponseDecision("deny"); + } +} + +function zcodeApprovalDecisionForOption( + option: ZcodePermissionOptionRecord, +): ProviderApprovalDecision { + if (option.kind === "allow_once") return "accept"; + if (option.kind === "allow_always") return "acceptForSession"; + if (option.kind === "deny" || option.kind === "reject") return "decline"; + return zcodeRecordString(option.response, "decision") === "allow" ? "accept" : "decline"; +} + +interface ZcodePendingApproval { + readonly nativeRequestId: string; + readonly options: ReadonlyArray; + runtimeRequest: OrchestrationV2RuntimeRequest; + readonly node: OrchestrationV2ExecutionNode; + readonly turnItem: OrchestrationV2TurnItem; + /** The exact PermissionResponse object zcode's broker expects; null until the user answers. */ + chosenResponse: ZcodeRecord | null; +} + +interface ZcodePendingQuestion { + readonly nativeRequestId: string; + readonly questionId: string; + runtimeRequest: OrchestrationV2RuntimeRequest; + readonly node: OrchestrationV2ExecutionNode; + readonly turnItem: OrchestrationV2TurnItem; + answer: { readonly value: string } | { readonly cancelled: true } | null; +} + +interface ZcodeAwaitingRequest { + readonly respondId: string; + readonly nativeRequestId: string; +} + +interface ActiveZcodeTurn { + readonly turnInput: ProviderAdapter.ProviderAdapterV2TurnInput; + providerTurn: OrchestrationV2ProviderTurn; + readonly itemOrdinals: Map; + nextItemOrdinal: number; + readonly streamItems: Map; + readonly tools: Map; + upsertedItemId: string | null; + interrupted: boolean; + failure: OrchestrationV2ProviderFailure | null; + finalized: boolean; +} + +interface ZcodeThreadState { + providerThread: OrchestrationV2ProviderThread; + readonly nativeSessionId: string; + activeTurn: ActiveZcodeTurn | null; + sessionMode: ZcodeSessionMode; +} + +type ZcodeSessionMode = "guarded" | "edit" | "yolo"; + +function zcodeFailureFromErrorDetail(error: unknown): OrchestrationV2ProviderFailure { + const record = Predicate.isObject(error) ? error : {}; + const message = zcodeRecordString(record, "message") ?? "ZCode reported a turn error."; + const usageLimited = USAGE_LIMIT_ERROR_PATTERN.test(message); + const configurationGap = + zcodeRecordString(record, "code") === "CONFIGURATION_ERROR" && + message.toLowerCase().includes("select a model"); + return makeProviderFailure({ + message: configurationGap + ? "ZCode resolved no usable model for this environment. Update the ZCode CLI on the server machine, then refresh the provider. Model provisioning over the local protocol is broken in CLI 0.17.1." + : message, + code: zcodeRecordString(record, "code") ?? null, + class: usageLimited ? "usage_limit" : "provider_error", + ...(usageLimited ? { retryable: true as const } : {}), + }); +} + +export const makeZcodeAdapterV2 = Effect.fn("makeZcodeAdapterV2")(function* ( + options: ZcodeAdapterV2Options, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const agentScope = yield* AgentScope; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const host = yield* ProviderHost.ProviderHost; + const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const httpClient = yield* HttpClient.HttpClient; + + const protocolError = (detail: string, payload?: unknown) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: ZCODE_PROVIDER, + detail, + ...(payload === undefined ? {} : { payload }), + }); + + // The CLI's own host reports the installed version in the token exchange; unknown stays omitted. + const dataRoot = zcodeDataRoot(options.environment); + const clientVersion = + dataRoot === undefined + ? undefined + : yield* readZcodeAppVersion(fileSystem, `${dataRoot}/.zcode/server/current.json`); + + const requestAuth = (options.makeRequestAuth ?? makeZcodeRequestAuth)({ + fileSystem, + httpClient, + environment: options.environment, + clientVersion, + }); + + const zcodeMcpServers = ( + session: McpProviderSessionConfig | undefined, + ): ReadonlyArray | undefined => + session === undefined + ? undefined + : [ + { + name: T3_MCP_SERVER_NAME, + type: "http", + url: session.endpoint, + headers: [{ name: "Authorization", value: session.authorizationHeader }], + }, + ]; + + return ProviderAdapter.ProviderAdapterV2.of({ + instanceId: options.instanceId, + driver: ZCODE_PROVIDER, + getCapabilities: Effect.fn("ZcodeAdapter.getCapabilities")(function () { + return Effect.succeed(ZcodeProviderCapabilitiesV2); + }), + planSelectionTransition: Effect.fn("ZcodeAdapter.planSelectionTransition")(function () { + return Effect.succeed(turnScopedSelectionTransition()); + }), + openSession: Effect.fn("ZcodeAdapterV2.openSession")(function* ( + input: ProviderAdapter.ProviderAdapterV2OpenSessionInput, + ) { + const scope = yield* Effect.scope; + const cwd = input.runtimePolicy.cwd ?? host.paths.cwd; + // Catalog files must exist before spawn: the CLI fails closed on dangling env paths. + const catalogFiles: ZcodeCatalogFileSet = yield* resolveZcodeCatalogFiles( + fileSystem, + options.environment, + ); + const env = zcodeRuntimeEnvironment(options.environment, catalogFiles); + const launchCommand = yield* resolveZcodeLaunchCommand( + options.settings, + options.environment, + fileSystem, + ); + const scopedLaunch = yield* agentScope.wrap({ + command: launchCommand, + args: zcodeSpawnArgs(options.settings.launchArgs), + name: "zcode", + threadId: input.threadId, + env, + }); + // Finalizers run in reverse order: reap detached work after terminating zcode. + const scopeUnit = scopedLaunch.unit; + const stopScope = + scopeUnit === undefined ? Effect.void : yield* Effect.cached(agentScope.stop(scopeUnit)); + yield* Effect.addFinalizer(() => stopScope); + const connection: ZcodeConnection = yield* (options.makeConnection ?? makeZcodeConnection)({ + command: scopedLaunch.command, + args: scopedLaunch.args, + cwd, + env, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: ZCODE_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + // Detached children can keep stdout open after zcode exits. + if (scopeUnit !== undefined) { + yield* connection.exited.pipe( + Effect.matchEffect({ onFailure: () => stopScope, onSuccess: () => stopScope }), + Effect.forkIn(scope), + ); + } + + const now = yield* DateTime.now; + let sessionEntity: OrchestrationV2ProviderSession = { + id: input.providerSessionId, + driver: ZCODE_PROVIDER, + providerInstanceId: options.instanceId, + status: "ready", + cwd, + model: input.modelSelection.model, + capabilities: ZcodeProviderCapabilitiesV2, + extensionStatuses: [], + createdAt: now, + updatedAt: now, + lastError: null, + }; + const events = yield* Queue.unbounded< + ProviderAdapter.ProviderAdapterV2Event, + ProviderAdapter.ProviderAdapterV2Error | Cause.Done + >(); + const pendingApprovals = new Map(); + const pendingQuestions = new Map(); + const awaitingPermissionRequests = new Map(); + const awaitingUserInputRequests = new Map(); + // Pump, turn installs, and dialog answers share one permit so terminal events cannot + // overtake the runtime request resolution they correlate with. + const sessionEventPermit = yield* Semaphore.make(1); + let threadState: ZcodeThreadState | null = null; + // Session models come from the CLI's own create-result advertisement; per-session, so a + // resume re-populates them. + let availableModels: ReadonlyArray = []; + let transportDown = false; + + yield* Effect.addFinalizer(() => + Effect.sync(() => { + transportDown = true; + }), + ); + + const emit = (event: ProviderAdapter.ProviderAdapterV2Event) => + Queue.offer(events, event).pipe(Effect.asVoid); + + const updateProviderSession = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + status: OrchestrationV2ProviderSession["status"], + lastError: string | null = sessionEntity.lastError, + ) { + const updatedAt = yield* DateTime.now; + sessionEntity = { ...sessionEntity, status, lastError, updatedAt }; + yield* emit({ + type: "provider_session.updated", + driver: ZCODE_PROVIDER, + providerSession: sessionEntity, + }); + }); + + const updateProviderThread = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + state: ZcodeThreadState, + patch: Partial, + ) { + const updatedAt = yield* DateTime.now; + state.providerThread = { ...state.providerThread, ...patch, updatedAt }; + yield* emit({ + type: "provider_thread.updated", + driver: ZCODE_PROVIDER, + providerThread: state.providerThread, + }); + }); + + const request = ( + method: string, + params: ZcodeRecord, + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + ) => connection.request(method, params, timeoutMs); + + const itemOrdinal = (turn: ActiveZcodeTurn, nativeItemId: string): number => { + const existing = turn.itemOrdinals.get(nativeItemId); + if (existing !== undefined) return existing; + const ordinal = turn.nextItemOrdinal++; + turn.itemOrdinals.set(nativeItemId, ordinal); + return ordinal; + }; + + const baseItemFields = ( + turn: ActiveZcodeTurn, + nativeItemId: string, + startedAt: DateTime.Utc, + updatedAt: DateTime.Utc, + ) => ({ + id: idAllocator.derive.turnItemFromProviderItem({ + driver: ZCODE_PROVIDER, + nativeItemId, + }), + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + nodeId: idAllocator.derive.nodeFromProviderItem({ + driver: ZCODE_PROVIDER, + nativeItemId, + }), + providerThreadId: turn.turnInput.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: providerRef(nativeItemId), + parentItemId: null, + ordinal: itemOrdinal(turn, nativeItemId), + startedAt, + updatedAt, + }); + + const emitItemNode = ( + turn: ActiveZcodeTurn, + nativeItemId: string, + kind: OrchestrationV2ExecutionNode["kind"], + status: OrchestrationV2ExecutionNode["status"], + startedAt: DateTime.Utc, + completedAt: DateTime.Utc | null, + ) => + emit({ + type: "node.updated", + driver: ZCODE_PROVIDER, + node: { + id: idAllocator.derive.nodeFromProviderItem({ + driver: ZCODE_PROVIDER, + nativeItemId, + }), + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + parentNodeId: turn.turnInput.rootNodeId, + rootNodeId: turn.turnInput.rootNodeId, + kind, + status, + countsForRun: false, + providerThreadId: turn.turnInput.providerThread.id, + providerTurnId: turn.providerTurn.id, + nativeItemRef: providerRef(nativeItemId), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt, + }, + }); + + const emitStreamItem = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* ( + turn: ActiveZcodeTurn, + item: ZcodeStreamItemState, + streaming: boolean, + ) { + const emittedAt = yield* DateTime.now; + const base = baseItemFields(turn, item.nativeItemId, item.startedAt, emittedAt); + yield* emitItemNode( + turn, + item.nativeItemId, + item.kind, + streaming ? "running" : "completed", + item.startedAt, + streaming ? null : emittedAt, + ); + if (item.kind === "assistant_message") { + const messageId = idAllocator.derive.messageFromProviderItem({ + driver: ZCODE_PROVIDER, + nativeItemId: item.nativeItemId, + }); + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...base, + status: streaming ? "running" : "completed", + title: null, + completedAt: streaming ? null : emittedAt, + type: "assistant_message", + messageId, + text: item.text, + streaming, + }, + }); + yield* emit({ + type: "message.updated", + driver: ZCODE_PROVIDER, + message: { + id: messageId, + threadId: turn.turnInput.threadId, + runId: turn.turnInput.runId, + nodeId: base.nodeId, + role: "assistant", + text: item.text, + attachments: [], + streaming, + createdBy: "agent", + creationSource: "provider", + createdAt: item.startedAt, + updatedAt: emittedAt, + }, + }); + return; + } + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...base, + status: streaming ? "running" : "completed", + title: null, + completedAt: streaming ? null : emittedAt, + type: "reasoning", + text: item.text, + streaming, + }, + }); + }); + + const scheduleStreamFlush = + /* Provider events include streaming deltas; avoid a span for each update. */ + Effect.fnUntraced(function* (turn: ActiveZcodeTurn, item: ZcodeStreamItemState) { + if (item.flushScheduled || item.completed || turn.finalized) return; + item.flushScheduled = true; + yield* Effect.sleep(Duration.millis(STREAM_FLUSH_MS)).pipe( + Effect.andThen( + Effect.suspend(() => { + item.flushScheduled = false; + return item.completed || turn.finalized + ? Effect.void + : emitStreamItem(turn, item, true); + }), + ), + Effect.forkIn(scope), + ); + }); + + const streamItemFor = Effect.fnUntraced(function* ( + turn: ActiveZcodeTurn, + kind: ZcodeStreamItemState["kind"], + messageId: string, + partId: string, + ) { + const nativeItemId = `${messageId}:${partId}`; + const existing = turn.streamItems.get(nativeItemId); + if (existing !== undefined) return existing; + const startedAt = yield* DateTime.now; + const item: ZcodeStreamItemState = { + nativeItemId, + kind, + text: "", + completed: false, + flushScheduled: false, + startedAt, + }; + turn.streamItems.set(nativeItemId, item); + itemOrdinal(turn, nativeItemId); + return item; + }); + + const completeStreamItem = ( + turn: ActiveZcodeTurn, + item: ZcodeStreamItemState, + text?: string, + ) => + Effect.suspend(() => { + if (item.completed) return Effect.void; + item.completed = true; + if (text !== undefined && text.length > 0) item.text = text; + return item.text.length === 0 ? Effect.void : emitStreamItem(turn, item, false); + }); + + const completeOpenStreamItems = (turn: ActiveZcodeTurn) => + Effect.forEach( + Array.from(turn.streamItems.values()).filter((item) => !item.completed), + (item) => completeStreamItem(turn, item), + { discard: true }, + ); + + const toolStateFor = ( + turn: ActiveZcodeTurn, + payload: ZcodeRecord, + startedAt: DateTime.Utc, + ): ZcodeToolState | undefined => { + const toolCallId = zcodeRecordString(payload, "toolCallId"); + if (toolCallId === undefined) return undefined; + const existing = turn.tools.get(toolCallId); + if (existing !== undefined) { + const name = zcodeRecordString(payload, "toolName"); + if (name !== undefined) existing.name = name; + return existing; + } + const state: ZcodeToolState = { + toolCallId, + name: zcodeRecordString(payload, "toolName"), + input: zcodeRecordField(payload, "input"), + startedAt, + output: undefined, + }; + turn.tools.set(toolCallId, state); + return state; + }; + + const emitToolItem = Effect.fnUntraced(function* ( + turn: ActiveZcodeTurn, + tool: ZcodeToolState, + status: OrchestrationV2TurnItem["status"], + ) { + const emittedAt = yield* DateTime.now; + const completed = status === "completed" || status === "failed" || status === "interrupted"; + const base = baseItemFields(turn, tool.toolCallId, tool.startedAt, emittedAt); + const nodeStatus: OrchestrationV2ExecutionNode["status"] = + status === "failed" ? "failed" : completed ? status : "running"; + yield* emitItemNode( + turn, + tool.toolCallId, + "tool_call", + nodeStatus, + tool.startedAt, + completed ? emittedAt : null, + ); + const toolName = tool.name ?? "tool"; + const shared = { + ...base, + status, + completedAt: completed ? emittedAt : null, + } as const; + if (SHELL_TOOL_NAMES.has(toolName.toLowerCase())) { + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...shared, + title: toolName, + type: "command_execution", + input: + Predicate.isObject(tool.input) && typeof tool.input["command"] === "string" + ? tool.input["command"] + : zcodeJsonStringify(tool.input ?? {}), + ...(tool.output === undefined ? {} : { output: tool.output }), + }, + }); + return; + } + const fileName = Predicate.isObject(tool.input) + ? (zcodeRecordString(tool.input, "file_path") ?? zcodeRecordString(tool.input, "path")) + : undefined; + if (FILE_WRITE_TOOL_NAMES.has(toolName.toLowerCase()) && fileName !== undefined) { + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...shared, + title: toolName, + type: "file_change", + fileName, + ...(tool.output === undefined ? {} : { diffStr: tool.output }), + }, + }); + return; + } + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...shared, + title: toolName, + type: "dynamic_tool", + ...mcpToolPresentation({ toolName }), + toolName, + input: Predicate.isObject(tool.input) ? tool.input : {}, + ...(tool.output === undefined ? {} : { output: tool.output }), + }, + }); + }); + + const emitRuntimeRequestShell = Effect.fnUntraced(function* ( + nativeRequestId: string, + kind: OrchestrationV2RuntimeRequest["kind"], + questions: ReadonlyArray | undefined, + approval: + | { + readonly requestKind: ProviderRequestKind; + readonly prompt: string; + readonly options: ReadonlyArray; + } + | undefined, + state: ZcodeThreadState | null, + turn: ActiveZcodeTurn | null, + ): Effect.fn.Return { + const createdAt = yield* DateTime.now; + const threadId = + turn?.turnInput.threadId ?? state?.providerThread.appThreadId ?? input.threadId; + const providerThreadId = state?.providerThread.id ?? null; + const providerTurnId = turn?.providerTurn.id ?? null; + const requestId = yield* idAllocator.allocate.runtimeRequest({ + driver: ZCODE_PROVIDER, + ...(providerTurnId === null ? {} : { providerTurnId }), + nativeRequestId, + }); + const nodeId = idAllocator.derive.approvalNode({ requestId }); + const runtimeRequest: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId, + nativeRequestRef: providerRef(nativeRequestId), + kind, + status: "pending", + responseCapability: { type: "live", providerSessionId: input.providerSessionId }, + createdAt, + resolvedAt: null, + }; + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId, + runId: turn?.turnInput.runId ?? null, + parentNodeId: turn?.turnInput.rootNodeId ?? null, + rootNodeId: turn?.turnInput.rootNodeId ?? nodeId, + kind: questions !== undefined ? "user_input_request" : "approval_request", + status: "waiting", + countsForRun: false, + providerThreadId, + providerTurnId, + nativeItemRef: providerRef(nativeRequestId), + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: createdAt, + completedAt: null, + }; + const itemBase = { + id: idAllocator.derive.approvalTurnItem({ requestId }), + threadId, + runId: turn?.turnInput.runId ?? null, + nodeId, + providerThreadId, + providerTurnId, + nativeItemRef: providerRef(nativeRequestId), + parentItemId: null, + ordinal: turn === null ? 0 : itemOrdinal(turn, nativeRequestId), + status: "waiting" as const, + title: null, + startedAt: createdAt, + completedAt: null, + updatedAt: createdAt, + }; + const turnItem: OrchestrationV2TurnItem = + questions !== undefined + ? { + ...itemBase, + type: "user_input_request", + requestId, + questions: [...questions], + } + : { + ...itemBase, + type: "approval_request", + requestId, + requestKind: approval?.requestKind ?? "command", + ...(approval === undefined ? {} : { prompt: approval.prompt }), + ...(approval === undefined || approval.options.length === 0 + ? {} + : { options: approval.options }), + }; + if (questions !== undefined) { + pendingQuestions.set(String(requestId), { + nativeRequestId, + questionId: questions[0]?.id ?? nativeRequestId, + runtimeRequest, + node, + turnItem, + answer: null, + }); + } else { + pendingApprovals.set(String(requestId), { + nativeRequestId, + options: [], + runtimeRequest, + node, + turnItem, + chosenResponse: null, + }); + } + yield* emit({ + type: "runtime_request.updated", + driver: ZCODE_PROVIDER, + threadId, + runtimeRequest, + }); + yield* emit({ type: "node.updated", driver: ZCODE_PROVIDER, node }); + yield* emit({ type: "turn_item.updated", driver: ZCODE_PROVIDER, turnItem }); + return requestId; + }); + + const resolveRuntimeRequestRecords = Effect.fnUntraced(function* ( + pending: ZcodePendingApproval | ZcodePendingQuestion, + resolvedAt: DateTime.Utc, + ) { + pending.runtimeRequest = { ...pending.runtimeRequest, status: "resolved", resolvedAt }; + yield* emit({ + type: "runtime_request.updated", + driver: ZCODE_PROVIDER, + threadId: pending.node.threadId, + runtimeRequest: pending.runtimeRequest, + }); + yield* emit({ + type: "node.updated", + driver: ZCODE_PROVIDER, + node: { ...pending.node, status: "completed", completedAt: resolvedAt }, + }); + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...pending.turnItem, + status: "completed", + completedAt: resolvedAt, + updatedAt: resolvedAt, + }, + }); + }); + + const cancelRuntimeRequest = Effect.fnUntraced(function* ( + pending: ZcodePendingApproval | ZcodePendingQuestion, + respondWith: unknown, + ) { + const resolvedAt = yield* DateTime.now; + pending.runtimeRequest = { ...pending.runtimeRequest, status: "cancelled", resolvedAt }; + if ("chosenResponse" in pending) { + pendingApprovals.delete(String(pending.runtimeRequest.id)); + for (const [respondId, awaitingRequest] of awaitingPermissionRequests) { + if (awaitingRequest.nativeRequestId === pending.nativeRequestId) { + awaitingPermissionRequests.delete(respondId); + // The transport may already be down during teardown; the deny is best-effort. + yield* connection.respond(respondId, respondWith).pipe(Effect.ignore); + } + } + } else { + pendingQuestions.delete(String(pending.runtimeRequest.id)); + for (const [respondId, awaitingRequest] of awaitingUserInputRequests) { + if (awaitingRequest.nativeRequestId === pending.nativeRequestId) { + awaitingUserInputRequests.delete(respondId); + yield* connection.respond(respondId, respondWith).pipe(Effect.ignore); + } + } + } + yield* emit({ + type: "runtime_request.updated", + driver: ZCODE_PROVIDER, + threadId: pending.node.threadId, + runtimeRequest: pending.runtimeRequest, + }); + yield* emit({ + type: "node.updated", + driver: ZCODE_PROVIDER, + node: { ...pending.node, status: "cancelled", completedAt: resolvedAt }, + }); + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...pending.turnItem, + status: "cancelled", + completedAt: resolvedAt, + updatedAt: resolvedAt, + }, + }); + }); + + const cancelPendingRuntimeRequests = Effect.fnUntraced(function* () { + for (const pending of Array.from(pendingApprovals.values())) { + yield* cancelRuntimeRequest(pending, { decision: "deny", reason: "No user response" }); + } + for (const pending of Array.from(pendingQuestions.values())) { + yield* cancelRuntimeRequest(pending, { cancelled: true }); + } + }); + + const finalizeTurn = Effect.fnUntraced(function* (state: ZcodeThreadState) { + const turn = state.activeTurn; + if (turn === null || turn.finalized) return; + turn.finalized = true; + state.activeTurn = null; + const completedAt = yield* DateTime.now; + yield* completeOpenStreamItems(turn); + yield* cancelPendingRuntimeRequests(); + const failure = turn.interrupted ? null : turn.failure; + yield* emit({ + type: "provider_turn.updated", + driver: ZCODE_PROVIDER, + threadId: turn.turnInput.threadId, + providerTurn: { + ...turn.providerTurn, + status: turn.interrupted ? "interrupted" : failure !== null ? "failed" : "completed", + completedAt, + }, + }); + yield* updateProviderThread(state, { status: "idle" }); + yield* updateProviderSession( + failure !== null ? "error" : "ready", + failure?.message ?? null, + ); + if (failure !== null) { + const failureItemId = `terminal-failure:${turn.providerTurn.id}`; + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...baseItemFields(turn, failureItemId, completedAt, completedAt), + status: "failed", + title: null, + completedAt, + type: "error", + failure, + }, + }); + yield* emit({ + type: "turn.terminal", + driver: ZCODE_PROVIDER, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + runOrdinal: turn.turnInput.runOrdinal, + failureItemOrdinal: itemOrdinal(turn, failureItemId), + status: "failed", + failure, + threadDisposition: "reusable", + }); + return; + } + yield* emit({ + type: "turn.terminal", + driver: ZCODE_PROVIDER, + providerThreadId: state.providerThread.id, + providerTurnId: turn.providerTurn.id, + runOrdinal: turn.turnInput.runOrdinal, + status: turn.interrupted ? "interrupted" : "completed", + failure: null, + threadDisposition: "reusable", + }); + }); + + const findApprovalByNativeId = (nativeRequestId: string) => + Array.from(pendingApprovals.values()).find( + (candidate) => candidate.nativeRequestId === nativeRequestId, + ); + const findQuestionByNativeId = (nativeRequestId: string) => + Array.from(pendingQuestions.values()).find( + (candidate) => candidate.nativeRequestId === nativeRequestId, + ); + // The broker request can arrive after the user already answered; keep chosen answers + // findable so those late requests still carry the decision instead of parking forever. + const answeredPermissions = new Map(); + const answeredUserInputs = new Map< + string, + { readonly value: string } | { readonly cancelled: true } + >(); + + const handleSessionEvent = Effect.fnUntraced(function* ( + eventType: string, + payload: ZcodeRecord, + ) { + const state = threadState; + const turn = state === null ? null : state.activeTurn; + switch (eventType) { + case "turn.started": { + if (turn !== null) yield* updateProviderSession("running", null); + return; + } + case "part.delta": { + if (turn === null) return; + const messageId = zcodeRecordString(payload, "messageId"); + const partId = zcodeRecordString(payload, "partId"); + const delta = zcodeRecordString(payload, "delta"); + const field = zcodeRecordString(payload, "field") ?? "text"; + if (messageId === undefined || partId === undefined || delta === undefined) return; + if (field !== "text" && field !== "reasoning") return; + const item = yield* streamItemFor( + turn, + field === "text" ? "assistant_message" : "reasoning", + messageId, + partId, + ); + item.text += delta; + yield* emit({ + type: "command_output.delta", + driver: ZCODE_PROVIDER, + threadId: turn.turnInput.threadId, + itemId: idAllocator.derive.turnItemFromProviderItem({ + driver: ZCODE_PROVIDER, + nativeItemId: item.nativeItemId, + }), + chunk: delta, + }); + yield* scheduleStreamFlush(turn, item); + return; + } + case "part.started": + case "part.upserted": { + if (turn === null) return; + const part = zcodeRecordField(payload, "part"); + if (!Predicate.isObject(part)) return; + const partType = zcodeRecordString(part, "type"); + const messageId = zcodeRecordString(part, "messageId"); + const partId = zcodeRecordString(part, "partId"); + if (messageId === undefined || partId === undefined) return; + if (partType !== "text" && partType !== "reasoning") return; + const item = yield* streamItemFor( + turn, + partType === "text" ? "assistant_message" : "reasoning", + messageId, + partId, + ); + yield* completeStreamItem(turn, item, zcodeRecordString(part, "text") ?? item.text); + return; + } + case "message.upserted": { + if (turn === null) return; + // Fallback for providers that skip part deltas: upserts carry no message id, so + // they only contribute content when no streaming item exists for this turn. + const text = zcodeRecordString(payload, "content") ?? contentText(payload); + if (text.length === 0) return; + const nativeItemId = turn.upsertedItemId ?? `upserted:${turn.providerTurn.id}:0`; + const hasStreamItem = Array.from(turn.streamItems.keys()).some( + (key) => key.startsWith("upserted:") === false, + ); + if (hasStreamItem) return; + turn.upsertedItemId = nativeItemId; + const item = yield* streamItemFor(turn, "assistant_message", nativeItemId, "final"); + yield* completeStreamItem(turn, item, text); + return; + } + case "model.streaming": { + // The CLI's real text/reasoning stream: {assistantMessageId, delta, kind, done}. + if (turn === null) return; + const messageId = zcodeRecordString(payload, "assistantMessageId"); + const delta = zcodeRecordString(payload, "delta"); + if (messageId === undefined || delta === undefined) return; + const kind = zcodeRecordString(payload, "kind") ?? "text_delta"; + if (kind !== "text_delta" && kind !== "reasoning_delta") return; + const item = yield* streamItemFor( + turn, + kind === "text_delta" ? "assistant_message" : "reasoning", + messageId, + kind === "text_delta" ? "text" : "reasoning", + ); + item.text += delta; + yield* emit({ + type: "command_output.delta", + driver: ZCODE_PROVIDER, + threadId: turn.turnInput.threadId, + itemId: idAllocator.derive.turnItemFromProviderItem({ + driver: ZCODE_PROVIDER, + nativeItemId: item.nativeItemId, + }), + chunk: delta, + }); + if (payload["done"] === true) { + yield* completeStreamItem(turn, item); + return; + } + yield* scheduleStreamFlush(turn, item); + return; + } + case "tool.updated": { + if (turn === null) return; + // Subagent-attributed calls belong to child sessions; projecting them as parent + // tools would misplace work, and subagent capabilities are off. + if (zcodeRecordString(payload, "source") === "subagent") return; + const kind = zcodeRecordString(payload, "kind"); + const toolState = toolStateFor(turn, payload, yield* DateTime.now); + if (toolState === undefined) return; + switch (kind) { + case "scheduled": + case "started": { + yield* emitToolItem(turn, toolState, "running"); + return; + } + case "progress": { + const stdoutTail = zcodeRecordString(payload, "stdoutTail"); + const stderrTail = zcodeRecordString(payload, "stderrTail"); + const outputPreview = zcodeRecordField(payload, "outputPreview"); + const previewText = Predicate.isObject(outputPreview) + ? (zcodeRecordString(outputPreview, "text") ?? + zcodeRecordString(outputPreview, "output")) + : undefined; + const tail = [stdoutTail, stderrTail].filter((part) => part !== undefined).join(""); + const next = tail.length > 0 ? tail : previewText; + if (next !== undefined && next.length > 0 && next !== toolState.output) { + toolState.output = next; + yield* emitToolItem(turn, toolState, "running"); + } + return; + } + case "result": { + const output = toolResultOutput(zcodeRecordField(payload, "result")); + if (output !== undefined) toolState.output = output; + yield* emitToolItem(turn, toolState, "completed"); + return; + } + case "error": { + const error = zcodeRecordField(payload, "error"); + const message = + (Predicate.isObject(error) ? zcodeRecordString(error, "message") : undefined) ?? + "Tool failed."; + toolState.output = message; + yield* emitToolItem(turn, toolState, turn.interrupted ? "interrupted" : "failed"); + return; + } + default: + return; + } + } + case "permission.requested": { + const nativeRequestId = + zcodeRecordString(payload, "requestId") ?? zcodeRecordString(payload, "toolCallId"); + if (nativeRequestId === undefined) return; + const options = zcodePermissionOptions(payload); + const requestId = yield* emitRuntimeRequestShell( + nativeRequestId, + "command", + undefined, + { + requestKind: "command", + prompt: + zcodeRecordString(payload, "reason") ?? + `${zcodeRecordString(payload, "toolName") ?? "Tool"} needs approval`, + options: options.map((option) => ({ + decision: zcodeApprovalDecisionForOption(option), + label: option.name, + })), + }, + state, + turn, + ); + const pending = pendingApprovals.get(String(requestId)); + if (pending !== undefined) + pendingApprovals.set(String(requestId), { ...pending, options }); + return; + } + case "permission.resolved": { + const nativeRequestId = + zcodeRecordString(payload, "requestId") ?? zcodeRecordString(payload, "toolCallId"); + if (nativeRequestId === undefined) return; + const pending = findApprovalByNativeId(nativeRequestId); + if (pending === undefined) return; + pendingApprovals.delete(String(pending.runtimeRequest.id)); + yield* resolveRuntimeRequestRecords(pending, yield* DateTime.now); + return; + } + case "userInput.requested": { + const nativeRequestId = zcodeRecordString(payload, "requestId"); + if (nativeRequestId === undefined) return; + const prompt = zcodeRecordString(payload, "prompt") ?? "The agent asked a question"; + const inputType = zcodeRecordString(payload, "inputType") ?? "text"; + const choices = (zcodeRecordArray(payload, "choices") ?? []).filter( + (choice): choice is string => typeof choice === "string" && choice.length > 0, + ); + yield* emitRuntimeRequestShell( + nativeRequestId, + "user_input", + [ + { + id: nativeRequestId, + header: "Question", + question: prompt, + options: choices.map((choice) => ({ + label: choice, + description: choice, + value: choice, + })), + // Free-text asks keep a custom answer even with preset choices. + ...(inputType === "text" || choices.length === 0 + ? { allowCustomAnswer: true } + : {}), + }, + ], + undefined, + state, + turn, + ); + return; + } + case "userInput.resolved": { + const nativeRequestId = zcodeRecordString(payload, "requestId"); + if (nativeRequestId === undefined) return; + const pending = findQuestionByNativeId(nativeRequestId); + if (pending === undefined) return; + pendingQuestions.delete(String(pending.runtimeRequest.id)); + yield* resolveRuntimeRequestRecords(pending, yield* DateTime.now); + return; + } + case "turn.completed": { + if (state === null || turn === null || turn.finalized) return; + const resultType = zcodeRecordString(payload, "resultType") ?? "success"; + if (resultType === "success") { + const tokenCount = zcodeRecordNumber(payload, "tokenCount"); + if (tokenCount !== undefined && tokenCount > 0) { + const updatedAt = yield* DateTime.now; + turn.providerTurn = { + ...turn.providerTurn, + tokenUsage: { usedTokens: tokenCount, updatedAt: DateTime.formatIso(updatedAt) }, + }; + } + // The completed payload carries the authoritative full response; make sure an + // assistant message exists even when the stream produced no mapped items. + const response = zcodeRecordString(payload, "response"); + if (response !== undefined && response.length > 0) { + const textItems = Array.from(turn.streamItems.entries()).filter( + ([, item]) => item.kind === "assistant_message", + ); + const last = textItems.at(-1); + if (last === undefined) { + const item = yield* streamItemFor( + turn, + "assistant_message", + `final:${turn.providerTurn.id}`, + "final", + ); + yield* completeStreamItem(turn, item, response); + } else { + yield* completeStreamItem(turn, last[1], response); + } + } + yield* finalizeTurn(state); + return; + } + if (resultType === "cancelled") { + turn.interrupted = true; + yield* finalizeTurn(state); + return; + } + turn.failure = + resultType === "error_max_budget" + ? makeProviderFailure({ + message: "The model provider usage limit was reached.", + class: "usage_limit", + code: resultType, + retryable: true, + }) + : makeProviderFailure({ + message: `ZCode turn failed (${resultType}).`, + class: "provider_error", + code: resultType, + }); + yield* finalizeTurn(state); + return; + } + case "turn.failed": { + if (state === null || turn === null || turn.finalized) return; + turn.failure = zcodeFailureFromErrorDetail(zcodeRecordField(payload, "error")); + yield* finalizeTurn(state); + return; + } + case "turn.steerQueued": + case "turn.steerDrained": + case "session.created": + case "session.resumed": + case "session.updated": + case "session.titleUpdated": + case "session.closed": + case "checkpoint.created": + case "rewind.triggered": + case "part.removed": + case "message.removed": + return; + default: + yield* Effect.logDebug("Ignoring zcode session event.", { eventType }); + return; + } + }); + + const handleServerRequest = Effect.fnUntraced(function* (record: ZcodeRecord) { + const method = zcodeRecordString(record, "method") ?? ""; + const respondId = String(zcodeRecordField(record, "id") ?? ""); + switch (method) { + case "session/requestRuntimePreferences": { + yield* connection.respond(respondId, { + nativeSearchEnhancementsEnabled: false, + toolSearchMode: "off", + memoryEnabled: false, + askUserQuestionAutoResolutionEnabled: true, + modelContextBudgetStrategy: "preflight-v1", + }); + return; + } + case "interaction/requestOfficialMcpAuthHeaders": { + yield* connection.respond(respondId, {}); + return; + } + case "interaction/requestProviderRuntimeHeaders": { + const params = zcodeRecordField(record, "params"); + const providerId = + zcodeRecordString(params ?? {}, "providerId") ?? + zcodeRecordString( + zcodeRecordField(params ?? {}, "modelSelection") ?? {}, + "providerId", + ); + const family = zcodeFamilyFromProviderId(providerId); + if (family === undefined) { + yield* connection.respond(respondId, { + headersApplied: false, + errorMessage: "Unsupported ZCode account provider.", + }); + return; + } + const rejectedProjectTokenFingerprint = zcodeRecordString( + params ?? {}, + "rejectedProjectTokenFingerprint", + ); + const answer = requestAuth.answerRuntimeHeaders({ + family, + ...(rejectedProjectTokenFingerprint === undefined + ? {} + : { rejectedProjectTokenFingerprint }), + }); + // Minting can take seconds on a cache miss; the CLI correlates the reply by id, so + // fork it instead of stalling every session event this permit serializes. + yield* answer.pipe( + Effect.flatMap((response) => connection.respond(respondId, response)), + Effect.catchCause((cause) => + Effect.logWarning("ZCode runtime headers answer failed.", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.forkIn(scope), + ); + return; + } + case "interaction/requestPermission": { + const params = zcodeRecordField(record, "params"); + const nativeRequestId = + zcodeRecordString(params ?? {}, "requestId") ?? + zcodeRecordString(params ?? {}, "toolCallId") ?? + respondId; + const answered = findApprovalByNativeId(nativeRequestId); + if (answered !== undefined && answered.chosenResponse !== null) { + yield* connection.respond(respondId, answered.chosenResponse); + return; + } + const stored = answeredPermissions.get(nativeRequestId); + if (stored !== undefined) { + yield* connection.respond(respondId, stored); + return; + } + // Hold the broker request so a slower user answer still reaches zcode; cancel + // paths answer it with a "No user response" denial instead. + awaitingPermissionRequests.set(respondId, { respondId, nativeRequestId }); + return; + } + case "interaction/requestUserInput": { + const params = zcodeRecordField(record, "params"); + const nativeRequestId = zcodeRecordString(params ?? {}, "requestId") ?? respondId; + const answered = findQuestionByNativeId(nativeRequestId); + if (answered !== undefined && answered.answer !== null) { + yield* connection.respond(respondId, answered.answer); + return; + } + const stored = answeredUserInputs.get(nativeRequestId); + if (stored !== undefined) { + yield* connection.respond(respondId, stored); + return; + } + awaitingUserInputRequests.set(respondId, { respondId, nativeRequestId }); + return; + } + default: { + yield* Effect.logDebug("Answering unknown zcode server request with {}.", { method }); + yield* connection.respond(respondId, {}); + return; + } + } + }); + + const handleLifecycle = Effect.fnUntraced(function* (params: ZcodeRecord) { + const sessionId = zcodeRecordString(params, "sessionId"); + const state = threadState; + if (state !== null && sessionId !== undefined && sessionId !== state.nativeSessionId) + return; + const lifecycleState = zcodeRecordString(params, "state"); + const lastError = zcodeRecordField(params, "lastError"); + switch (lifecycleState) { + case "ready": + yield* updateProviderSession(state?.activeTurn != null ? "running" : "ready", null); + return; + case "unloaded": { + const errorDetail = Predicate.isObject(lastError) + ? zcodeRecordString(lastError, "message") + : undefined; + if (state?.activeTurn != null) { + state.activeTurn.failure = makeProviderFailure({ + message: errorDetail ?? "ZCode unloaded the session while a turn was running.", + class: "transport_error", + retryable: false, + }); + yield* finalizeTurn(state); + } + yield* updateProviderSession("stopped", errorDetail ?? null); + return; + } + default: + return; + } + }); + + const handleRecord = Effect.fnUntraced(function* (record: ZcodeRecord) { + if (isServerRequest(record)) { + yield* handleServerRequest(record); + return; + } + const method = zcodeRecordString(record, "method"); + if (method === "session/event") { + const params = zcodeRecordField(record, "params"); + if (!Predicate.isObject(params)) return; + const eventType = zcodeRecordString(params, "type"); + if (eventType === undefined) return; + const payload = zcodeRecordField(params, "payload"); + yield* handleSessionEvent(eventType, Predicate.isObject(payload) ? payload : {}); + return; + } + if (method === "session/lifecycle/changed") { + const params = zcodeRecordField(record, "params"); + if (Predicate.isObject(params)) yield* handleLifecycle(params); + return; + } + }); + + yield* Effect.gen(function* () { + while (true) { + const record = yield* Queue.take(connection.events); + yield* sessionEventPermit.withPermits(1)(handleRecord(record)); + } + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + transportDown = true; + const state = threadState; + const interrupted = state?.activeTurn?.interrupted === true; + if (state?.activeTurn != null) { + state.activeTurn.failure = interrupted + ? null + : makeProviderFailure({ + cause, + message: "zcode app-server exited unexpectedly.", + class: "transport_error", + }); + yield* finalizeTurn(state); + } else { + yield* cancelPendingRuntimeRequests(); + } + yield* updateProviderSession( + interrupted ? "stopped" : "error", + interrupted + ? "zcode app-server was stopped." + : "zcode app-server exited unexpectedly.", + ); + yield* Queue.fail( + events, + new ProviderAdapter.ProviderAdapterEventStreamError({ + driver: ZCODE_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ); + }), + ), + Effect.forkIn(scope), + ); + + // Provisioning must precede session/create: the CLI resolves models against the registry + // it builds from this push. Best-effort — a rejected push surfaces honestly as a + // "Select a model" turn failure instead of failing the session open. + const bootstrapProviderRegistry = Effect.fn("ZcodeAdapter.bootstrapProviderRegistry")( + function* () { + if (catalogFiles.builtinCatalog === undefined || catalogFiles.builtinPath === undefined) { + yield* Effect.logWarning( + "ZCode builtin catalog unavailable; account models will report as missing.", + { builtinPath: catalogFiles.builtinPath ?? null }, + ); + return; + } + const push = yield* zcodeAccountConfigPushFromLadder(fileSystem, { + builtinPath: catalogFiles.builtinPath, + builtinCatalog: catalogFiles.builtinCatalog, + environment: options.environment, + }); + if (push === undefined) { + yield* Effect.logWarning( + "ZCode catalog declares no account providers; account models will report as missing.", + ); + return; + } + yield* request("provider/updateAccountConfig", push).pipe( + Effect.tapError((error) => + Effect.logWarning("ZCode account config push failed.", { + operation: error.operation, + }), + ), + Effect.ignore, + ); + }, + ); + yield* bootstrapProviderRegistry(); + + const subscribeSession = Effect.fnUntraced(function* (sessionId: string) { + const result = yield* request("session/subscribe", { + sessionId, + deliveryKind: "desktop-continuous", + includeSnapshot: true, + }); + // The snapshot advertises the session's executable models; resume needs it as much as + // create, since a restart replays threads through resume without a fresh create result. + const snapshot = zcodeRecordField(Predicate.isObject(result) ? result : {}, "snapshot"); + const settings = zcodeRecordField(Predicate.isObject(snapshot) ? snapshot : {}, "settings"); + const model = zcodeRecordField(Predicate.isObject(settings) ? settings : {}, "model"); + const models = parseZcodeAvailableModels(model); + if (models.length > 0) availableModels = models; + return result; + }); + + const createNativeSession = Effect.fnUntraced(function* ( + threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, + ) { + const mcpServers = zcodeMcpServers(yield* mcpSessions.read(threadInput.threadId)); + const createParams: ZcodeRecord = { + workspace: { workspacePath: cwd, workspaceKey: cwd }, + mode: zcodeSessionMode(threadInput.runtimePolicy?.runtimeMode), + titleGenerationEnabled: false, + ...(mcpServers === undefined ? {} : { mcpServers }), + }; + // The model rides on session/send instead: pinning it at create breaks whenever the + // CLI's registry lacks the model, while the send-time selection resolves per turn and + // carries the reasoning level the CLI requires. + const result = yield* request("session/create", createParams, SESSION_CREATE_TIMEOUT_MS); + const resultRecord = Predicate.isObject(result) ? result : {}; + const sessionRecord = zcodeRecordField(resultRecord, "session"); + const nativeSessionId = zcodeRecordString(sessionRecord ?? {}, "sessionId"); + if (nativeSessionId === undefined) { + return yield* protocolError("session/create returned no sessionId", result); + } + availableModels = parseZcodeAvailableModels( + zcodeRecordField( + zcodeRecordField( + Predicate.isObject(resultRecord["settings"]) ? resultRecord["settings"] : {}, + "model", + ), + "available", + ), + ); + yield* subscribeSession(nativeSessionId); + return nativeSessionId; + }); + + const resumeNativeSession = Effect.fnUntraced(function* ( + nativeSessionId: string, + threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, + ) { + const mcpServers = zcodeMcpServers(yield* mcpSessions.read(threadInput.threadId)); + yield* request( + "session/resume", + { + sessionId: nativeSessionId, + workspace: { workspacePath: cwd, workspaceKey: cwd }, + ...(mcpServers === undefined ? {} : { mcpServers }), + }, + SESSION_CREATE_TIMEOUT_MS, + ); + // session/resume carries no mode, so a mode chosen while the session was detached (or + // before a host restart) must be applied explicitly. + yield* request("session/setMode", { + sessionId: nativeSessionId, + mode: zcodeSessionMode(threadInput.runtimePolicy?.runtimeMode), + }); + yield* subscribeSession(nativeSessionId); + return nativeSessionId; + }); + + const registerThread = Effect.fnUntraced(function* ( + threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput, + ) { + if (threadState !== null && threadState.activeTurn !== null) { + return yield* protocolError("Cannot switch ZCode threads while a turn is active"); + } + const existing = threadInput.existingProviderThread; + if ( + !threadInput.forceNewNativeThread && + threadState !== null && + existing !== undefined && + threadState.providerThread.id === existing.id + ) { + return threadState.providerThread; + } + // Recovery (forceNewNativeThread) must open a fresh conversation even though the + // provider thread row and threadState still reference the old native session. + const nativeId = threadInput.forceNewNativeThread + ? undefined + : (existing?.nativeThreadRef?.nativeId ?? threadState?.nativeSessionId); + const nativeSessionId = + nativeId !== undefined + ? yield* resumeNativeSession(nativeId, threadInput) + : yield* createNativeSession(threadInput); + const createdAt = yield* DateTime.now; + const providerThread: OrchestrationV2ProviderThread = + existing !== undefined + ? { + ...existing, + providerSessionId: input.providerSessionId, + nativeThreadRef: providerRef(nativeSessionId), + status: "idle", + updatedAt: createdAt, + } + : { + id: idAllocator.derive.providerThread({ + driver: ZCODE_PROVIDER, + nativeThreadId: nativeSessionId, + }), + driver: ZCODE_PROVIDER, + providerInstanceId: options.instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadInput.threadId, + ownerNodeId: null, + nativeThreadRef: providerRef(nativeSessionId), + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + pendingBackgroundTasks: [], + createdAt, + updatedAt: createdAt, + }; + threadState = { + providerThread, + nativeSessionId, + activeTurn: null, + sessionMode: zcodeSessionMode(threadInput.runtimePolicy?.runtimeMode), + }; + yield* emit({ + type: "provider_thread.updated", + driver: ZCODE_PROVIDER, + providerThread, + }); + return providerThread; + }); + + const resolveTurnPayload = Effect.fnUntraced(function* ( + text: string, + attachments: ProviderAdapter.ProviderAdapterV2TurnMessage["attachments"], + ) { + return yield* providerMessageTextWithAttachmentPaths({ + text, + attachments, + resolveAttachmentPath: host.resolveAttachmentPath, + resolveExtractedTextPath: host.resolveExtractedTextPath, + }); + }); + + const installTurn = Effect.fnUntraced(function* ( + state: ZcodeThreadState, + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + nativeTurnId: string, + ): Effect.fn.Return { + if ( + threadState !== state || + state.activeTurn !== null || + transportDown || + state.providerThread.nativeThreadRef?.nativeId !== + turnInput.providerThread.nativeThreadRef?.nativeId + ) { + return yield* protocolError("ZCode turn ownership changed before it could start"); + } + const startedAt = yield* DateTime.now; + return { + turnInput, + providerTurn: { + id: idAllocator.derive.providerTurn({ + driver: ZCODE_PROVIDER, + nativeTurnId, + }), + providerThreadId: turnInput.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: providerRef(nativeTurnId, "weak"), + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }, + itemOrdinals: new Map(), + nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, + streamItems: new Map(), + tools: new Map(), + upsertedItemId: null, + interrupted: false, + failure: null, + finalized: false, + } satisfies ActiveZcodeTurn; + }); + + const beginTurnWork = Effect.fnUntraced(function* ( + state: ZcodeThreadState, + turn: ActiveZcodeTurn, + ) { + state.providerThread = turn.turnInput.providerThread; + state.activeTurn = turn; + yield* emit({ + type: "provider_turn.updated", + driver: ZCODE_PROVIDER, + threadId: turn.turnInput.threadId, + providerTurn: turn.providerTurn, + }); + yield* updateProviderThread(state, { + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turn.turnInput.runOrdinal, + lastRunOrdinal: turn.turnInput.runOrdinal, + }); + yield* updateProviderSession("running", null); + }); + + const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { + instanceId: options.instanceId, + driver: ZCODE_PROVIDER, + providerSessionId: input.providerSessionId, + get providerSession() { + return sessionEntity; + }, + // session/resume after a host restart is unverified against a logged-in CLI; until it + // is, restarts keep the conversation alive rather than attempting a lossy restore. + canResumeAfterRestart: false, + events: Stream.fromQueue(events), + ensureThread: (threadInput) => + registerThread(threadInput).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterEnsureThreadError({ + driver: ZCODE_PROVIDER, + threadId: threadInput.threadId, + cause, + }), + ), + ), + resumeThread: (threadInput) => + registerThread({ + threadId: + threadInput.threadId ?? threadInput.providerThread.appThreadId ?? input.threadId, + modelSelection: threadInput.modelSelection ?? input.modelSelection, + runtimePolicy: threadInput.runtimePolicy ?? input.runtimePolicy, + existingProviderThread: threadInput.providerThread, + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterResumeThreadError({ + driver: ZCODE_PROVIDER, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), + ), + startTurn: Effect.fn("ZcodeAdapter.startTurn")( + function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { + const state = threadState; + if (state === null) { + return yield* protocolError("ZCode session has no registered thread"); + } + if (state.activeTurn !== null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: ZCODE_PROVIDER, + detail: `ZCode provider thread ${turnInput.providerThread.id} already has an active turn`, + reason: "previous_turn_active", + }); + } + if ( + state.providerThread.nativeThreadRef?.nativeId !== + turnInput.providerThread.nativeThreadRef?.nativeId + ) { + return yield* protocolError("ZCode turn requested for a different native session"); + } + const content = yield* resolveTurnPayload( + turnInput.message.text, + turnInput.message.attachments, + ); + const nativeTurnId = `${state.nativeSessionId}:attempt:${turnInput.attemptId}`; + const sendParams: ZcodeRecord = { + sessionId: state.nativeSessionId, + content, + }; + const modelSelection = zcodeModelSelectionFromSlug(turnInput.modelSelection.model); + if (modelSelection !== undefined) { + // Level priority: the user's Effort pick (the "reasoningLevel" descriptor lands in + // model selections) beats the slug suffix beats the CLI-advertised default. + const pickedLevel = turnInput.modelSelection.options?.find( + (selection) => selection.id === "reasoningLevel" || selection.id === "effort", + )?.value; + const advertised = availableModels.find( + (entry) => + entry.providerId === modelSelection.providerId && + entry.modelId === modelSelection.modelId, + ); + const reasoningLevel = + (typeof pickedLevel === "string" && pickedLevel.length > 0 + ? pickedLevel + : undefined) ?? + modelSelection.options?.reasoningLevel ?? + advertised?.defaultReasoningLevel; + sendParams["modelSelection"] = { + ...modelSelection, + ...(reasoningLevel === undefined ? {} : { options: { reasoningLevel } }), + }; + } + // The orchestrator detaches on a mode change, but in-process reuse and CLI-side mode + // edits still leave the native session on an older mode. + const requestedMode = zcodeSessionMode(turnInput.runtimePolicy?.runtimeMode); + if (state.sessionMode !== requestedMode) { + yield* request("session/setMode", { + sessionId: state.nativeSessionId, + mode: requestedMode, + }); + state.sessionMode = requestedMode; + } + yield* sessionEventPermit.withPermits(1)( + Effect.gen(function* () { + const turn = yield* installTurn(state, turnInput, nativeTurnId); + yield* beginTurnWork(state, turn); + yield* request("session/send", sendParams).pipe( + Effect.tapError((cause) => + Effect.gen(function* () { + if (state.activeTurn !== turn) return; + turn.failure = makeProviderFailure({ + cause, + message: "ZCode failed to start the turn.", + class: "provider_error", + }); + // The rejection reaches the caller as a turn start error, whose handler + // stops the event subscriber; the terminal sequence must be queued first. + yield* finalizeTurn(state); + }), + ), + ); + }), + ); + // session/send acks before turn events; rejections there surface via turn.failed. + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: ZCODE_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + ), + compactThread: Effect.fn("ZcodeAdapter.compactThread")( + function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { + const state = threadState; + if (state === null) { + return yield* protocolError("ZCode session has no registered thread"); + } + if (state.activeTurn !== null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: ZCODE_PROVIDER, + detail: `ZCode provider thread ${turnInput.providerThread.id} already has an active turn`, + reason: "previous_turn_active", + }); + } + const nativeTurnId = `${state.nativeSessionId}:compact:${turnInput.attemptId}`; + const instructions = + turnInput.message.text.trim() === "/compact" || + turnInput.message.text.trim().length === 0 + ? undefined + : turnInput.message.text; + yield* sessionEventPermit.withPermits(1)( + Effect.gen(function* () { + const turn = yield* installTurn(state, turnInput, nativeTurnId); + yield* beginTurnWork(state, turn); + const result = yield* request( + "session/compact", + { + sessionId: state.nativeSessionId, + ...(instructions === undefined ? {} : { instructions }), + }, + SESSION_CREATE_TIMEOUT_MS, + ).pipe( + // Recover instead of re-failing: the failed-item + finalizeTurn sequence below + // must still run or state.activeTurn stays installed and blocks later turns. + Effect.catch((cause) => { + turn.failure = makeProviderFailure({ + cause, + message: "ZCode context compaction failed.", + class: "provider_error", + }); + return Effect.undefined; + }), + ); + const resultRecord = Predicate.isObject(result) ? result : {}; + const summary = zcodeRecordString(resultRecord, "response"); + const compactState = zcodeRecordString( + zcodeRecordField(resultRecord, "compact") ?? {}, + "state", + ); + if (compactState === "already_running") { + turn.failure = makeProviderFailure({ + message: "ZCode is already compacting this thread.", + class: "provider_error", + retryable: true, + }); + } + const emittedAt = yield* DateTime.now; + const compactionItemId = `compaction:${turn.providerTurn.id}`; + const failed = turn.failure !== null; + yield* emitItemNode( + turn, + compactionItemId, + "system", + failed ? "failed" : "completed", + turn.providerTurn.startedAt ?? emittedAt, + emittedAt, + ); + yield* emit({ + type: "turn_item.updated", + driver: ZCODE_PROVIDER, + turnItem: { + ...baseItemFields( + turn, + compactionItemId, + turn.providerTurn.startedAt ?? emittedAt, + emittedAt, + ), + status: failed ? "failed" : "completed", + title: failed ? "Context compaction failed" : "Context compacted", + completedAt: emittedAt, + type: "compaction", + driver: ZCODE_PROVIDER, + ...(failed || summary === undefined ? {} : { summary }), + }, + }); + yield* finalizeTurn(state); + }), + ); + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: ZCODE_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + ), + steerTurn: Effect.fn("ZcodeAdapter.steerTurn")( + function* (steerInput: ProviderAdapter.ProviderAdapterV2SteerInput) { + const state = threadState; + const turn = state?.activeTurn ?? null; + if ( + state === null || + turn === null || + turn.providerTurn.id !== steerInput.providerTurnId + ) { + return yield* protocolError(`ZCode turn ${steerInput.providerTurnId} is not active`); + } + const content = yield* resolveTurnPayload( + steerInput.message.text, + steerInput.message.attachments, + ); + // zcode queues steering natively; turn.steerQueued/drain events follow. + yield* request("session/send", { sessionId: state.nativeSessionId, content }); + }, + (effect, steerInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterSteerRunError({ + driver: ZCODE_PROVIDER, + providerThreadId: steerInput.providerThread.id, + providerTurnId: steerInput.providerTurnId, + cause, + }), + ), + ), + ), + interruptTurn: Effect.fn("ZcodeAdapter.interruptTurn")( + function* (interruptInput: ProviderAdapter.ProviderAdapterV2InterruptInput) { + const state = threadState; + const turn = state?.activeTurn ?? null; + if ( + state === null || + turn === null || + turn.providerTurn.id !== interruptInput.providerTurnId + ) { + return yield* protocolError( + `ZCode turn ${interruptInput.providerTurnId} is not active`, + ); + } + const wasInterrupted = turn.interrupted; + turn.interrupted = true; + yield* request("session/stop", { sessionId: state.nativeSessionId }).pipe( + Effect.onError(() => + Effect.sync(() => { + turn.interrupted = wasInterrupted; + }), + ), + ); + }, + (effect, interruptInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterInterruptError({ + driver: ZCODE_PROVIDER, + providerThreadId: interruptInput.providerThread.id, + providerTurnId: interruptInput.providerTurnId, + cause, + }), + ), + ), + ), + respondToRuntimeRequest: Effect.fn("ZcodeAdapter.respondToRuntimeRequest")( + function* (requestInput: ProviderAdapter.ProviderAdapterV2RuntimeRequestResponseInput) { + const key = String(requestInput.requestId); + const approval = pendingApprovals.get(key); + const question = pendingQuestions.get(key); + if (approval === undefined && question === undefined) { + return yield* protocolError( + `No pending ZCode runtime request ${requestInput.requestId}`, + ); + } + if (approval !== undefined) { + const decision: ProviderApprovalDecision = + requestInput.decision ?? + (requestInput.feedback !== undefined ? "decline" : "cancel"); + const response = zcodeApprovalResponseForDecision(approval.options, decision) ?? { + decision: "deny", + reason: requestInput.feedback ?? "Denied in T3 Code", + }; + approval.chosenResponse = response; + pendingApprovals.delete(key); + answeredPermissions.set(approval.nativeRequestId, response); + for (const [respondId, awaiting] of awaitingPermissionRequests) { + if (awaiting.nativeRequestId === approval.nativeRequestId) { + awaitingPermissionRequests.delete(respondId); + yield* connection.respond(respondId, response); + } + } + yield* resolveRuntimeRequestRecords(approval, yield* DateTime.now); + return; + } + const pendingQuestion = question!; + const answerValue = requestInput.answers?.[pendingQuestion.questionId]; + pendingQuestion.answer = + requestInput.decision === "cancel" || typeof answerValue !== "string" + ? { cancelled: true } + : { value: answerValue }; + pendingQuestions.delete(key); + answeredUserInputs.set(pendingQuestion.nativeRequestId, pendingQuestion.answer); + for (const [respondId, awaiting] of awaitingUserInputRequests) { + if (awaiting.nativeRequestId === pendingQuestion.nativeRequestId) { + awaitingUserInputRequests.delete(respondId); + yield* connection.respond(respondId, pendingQuestion.answer); + } + } + yield* resolveRuntimeRequestRecords(pendingQuestion, yield* DateTime.now); + }, + (effect, requestInput) => + effect.pipe( + sessionEventPermit.withPermits(1), + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ + driver: ZCODE_PROVIDER, + requestId: requestInput.requestId, + cause, + }), + ), + ), + ), + readThreadSnapshot: Effect.fn("ZcodeAdapter.readThreadSnapshot")( + function* (snapshotInput: ProviderAdapter.ProviderAdapterV2ReadThreadSnapshotInput) { + const state = threadState; + const wantedNativeId = snapshotInput.providerThread.nativeThreadRef?.nativeId; + if ( + state === null || + wantedNativeId == null || + state.nativeSessionId !== wantedNativeId + ) { + return yield* protocolError( + "ZCode snapshot requested for a thread this session does not host", + ); + } + const result = yield* request("session/read", { sessionId: state.nativeSessionId }); + const resultRecord = Predicate.isObject(result) ? result : {}; + const messagesRaw = zcodeRecordArray(resultRecord, "messages") ?? []; + const threadId = state.providerThread.appThreadId ?? input.threadId; + const messages = messagesRaw.flatMap( + (entry: unknown, index: number): Array => { + const record = Predicate.isObject(entry) ? entry : {}; + const info = zcodeRecordField(record, "info"); + const role = zcodeRecordString(info ?? {}, "role"); + if ( + (role !== "user" && role !== "assistant") || + info === undefined || + !Predicate.isObject(info) + ) { + return []; + } + const parts = zcodeRecordArray(record, "parts") ?? []; + const text = parts + .filter((part) => zcodeRecordString(part ?? {}, "type") === "text") + .map((part) => zcodeRecordString(part, "text") ?? "") + .join(""); + if (text.length === 0) return []; + const createdMs = zcodeRecordNumber( + zcodeRecordField(info, "time") ?? {}, + "created", + ); + const at = + createdMs !== undefined + ? Option.getOrElse( + DateTime.make(createdMs), + () => state.providerThread.createdAt, + ) + : state.providerThread.createdAt; + return [ + { + id: idAllocator.derive.messageFromProviderItem({ + driver: ZCODE_PROVIDER, + nativeItemId: `${wantedNativeId}:snapshot-message:${index}`, + }), + threadId, + runId: null, + nodeId: null, + role: role === "user" ? ("user" as const) : ("assistant" as const), + text, + attachments: [], + streaming: false, + createdBy: role === "user" ? ("user" as const) : ("agent" as const), + creationSource: "provider" as const, + createdAt: at, + updatedAt: at, + }, + ]; + }, + ); + return { + providerThread: state.providerThread, + providerTurns: [], + messages, + runtimeRequests: [], + }; + }, + (effect, snapshotInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ + driver: ZCODE_PROVIDER, + providerThreadId: snapshotInput.providerThread.id, + cause, + }), + ), + ), + ), + rollbackThread: (_rollbackInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: ZCODE_PROVIDER, + detail: "ZCode has no rollback method", + }), + ), + forkThread: (_forkInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: ZCODE_PROVIDER, + detail: + "ZCode session/fork exists, but its thread-handoff mapping into T3 is not implemented", + }), + ), + }; + return runtime; + }), + }); +}); + +export type ZcodeAdapterV2DriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | IdAllocator.IdAllocatorV2 + | McpProviderSessions.McpProviderSessions + | ProviderHost.ProviderHost; + +export const ZcodeAdapterV2Driver: ProviderAdapterDriver = { + driverKind: ZCODE_PROVIDER, + configSchema: ZcodeSettings, + defaultConfig: (): ZcodeSettings => DEFAULT_ZCODE_SETTINGS, + create: Effect.fn("ZcodeAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcess.Environment; + const httpClient = yield* HttpClient.HttpClient; + return yield* makeZcodeAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: yield* mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: ZCODE_PROVIDER, + instanceId: input.instanceId, + detail: "Failed to create ZCode adapter.", + cause, + }), + ), + ), + ), +}; diff --git a/packages/provider-zcode/src/server/driver.ts b/packages/provider-zcode/src/server/driver.ts new file mode 100644 index 0000000000000..bc9e710317bde --- /dev/null +++ b/packages/provider-zcode/src/server/driver.ts @@ -0,0 +1,152 @@ +import { ProviderDriverKind, TextGenerationError } from "@t3tools/contracts"; +import { ZcodeSettings } from "../settings.ts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "@t3tools/provider-core/server/driver"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "@t3tools/provider-core/server/snapshotSettings"; +import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; +import { ZcodeAdapterV2Driver, type ZcodeAdapterV2DriverEnv } from "./adapter.ts"; +import { buildInitialZcodeProviderSnapshot, checkZcodeProviderStatus } from "./status.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("zcode"); +const decodeZcodeSettings = Schema.decodeSync(ZcodeSettings); +// Auth is CLI-managed (`zcode login`), and zcode updates itself in the background, +// so T3 offers no one-click install/update. +const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, +}); + +const unsupportedTextGeneration: ProviderTextGeneration = (() => { + const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "ZCode does not provide application text generation.", + }), + ); + return { + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + generateReviewGuide: () => unsupported("generateReviewGuide"), + generateTranscriptionPostProcessing: () => unsupported("generateTranscriptionPostProcessing"), + }; +})(); + +export type ZcodeDriverEnv = + | ZcodeAdapterV2DriverEnv + | ChildProcessSpawner.ChildProcessSpawner + | ProviderHost.ProviderHost; + +export const ZcodeDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "ZCode", + supportsMultipleInstances: true, + }, + configSchema: ZcodeSettings, + defaultConfig: (): ZcodeSettings => decodeZcodeSettings({}), + create: Effect.fn("ZcodeDriver.create")(function* ({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const processEnv = yield* mergeProviderInstanceEnvironment( + environment, + yield* HostProcess.Environment, + ); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies ZcodeSettings; + const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance: () => Effect.succeed(MAINTENANCE), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialZcodeProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider: checkZcodeProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + ), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + // The cause keeps the failure; the detail reaches the snapshot, so it stays fixed. + detail: "Failed to build ZCode snapshot.", + cause, + }), + ), + ); + + const orchestrationAdapter = yield* ZcodeAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build ZCode orchestration adapter.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration: unsupportedTextGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/packages/provider-zcode/src/server/status.test.ts b/packages/provider-zcode/src/server/status.test.ts new file mode 100644 index 0000000000000..decbbb7e669cb --- /dev/null +++ b/packages/provider-zcode/src/server/status.test.ts @@ -0,0 +1,305 @@ +import * as NodeOS from "node:os"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import { vi } from "vite-plus/test"; + +import { writeFakeCli } from "@t3tools/provider-testing/fakeCli"; + +import { ZcodeSettings } from "../settings.ts"; +import { checkZcodeProviderStatus, zcodeAuthFromCredentials } from "./status.ts"; + +// os.homedir()'s ESM namespace cannot be spied on, so tests stub it for the Windows-shaped env cases. +const homedirStub = vi.hoisted(() => ({ value: undefined as string | undefined })); +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => homedirStub.value ?? actual.homedir(), + }; +}); + +const settings = Schema.decodeSync(ZcodeSettings); +const BASE_ENV = { PATH: process.env.PATH }; + +const credentialsWithActiveProvider = JSON.stringify([ + ["oauth:active_provider", "account:zai-individual-coding-plan"], + ["oauth:account:zai-individual-coding-plan", { accessToken: "redacted" }], +]); + +const settingWithZaiPlan = JSON.stringify({ + providerFamilyConnectionSelections: { zai: { kind: "individual-coding-plan" } }, +}); + +const credentialsWithZaiToken = JSON.stringify({ "oauth:zai:access_token": "enc:v1:redacted" }); + +const fakeDoctor = (source: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-status-test-" }); + return writeFakeCli({ directory, name: "zcode", source }); + }); + +const dataDirWithCredentials = (credentials: string | null, setting?: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-credentials-test-" }); + if (credentials !== null) { + yield* fs.makeDirectory(`${root}/.zcode/v2`, { recursive: true }); + yield* fs.writeFileString(`${root}/.zcode/v2/credentials.json`, credentials); + if (setting !== undefined) { + yield* fs.writeFileString(`${root}/.zcode/v2/setting.json`, setting); + } + } + return root; + }); + +describe("zcodeAuthFromCredentials", () => { + it("derives object-store auth from the provisioning entitlement ladder", () => { + assert.equal( + zcodeAuthFromCredentials(JSON.parse(credentialsWithActiveProvider), undefined), + "authenticated", + ); + assert.equal(zcodeAuthFromCredentials([], undefined), "unauthenticated"); + assert.equal(zcodeAuthFromCredentials([["other", "value"]], undefined), "unauthenticated"); + assert.equal( + zcodeAuthFromCredentials([["oauth:active_provider", ""]], undefined), + "unauthenticated", + ); + assert.equal(zcodeAuthFromCredentials(["oauth:active_provider"], undefined), "unauthenticated"); + assert.equal(zcodeAuthFromCredentials(undefined, undefined), "unknown"); + // Object stores are judged like provisioning: a family credential counts only with its + // individual-coding-plan selection; the oauth:active_provider marker is not consulted. + assert.equal(zcodeAuthFromCredentials({}, undefined), "unauthenticated"); + assert.equal(zcodeAuthFromCredentials({ oauth: true }, undefined), "unauthenticated"); + assert.equal( + zcodeAuthFromCredentials({ "oauth:active_provider": "zai" }, undefined), + "unauthenticated", + ); + assert.equal( + zcodeAuthFromCredentials(JSON.parse(credentialsWithZaiToken), JSON.parse(settingWithZaiPlan)), + "authenticated", + ); + assert.equal( + zcodeAuthFromCredentials(JSON.parse(credentialsWithZaiToken), undefined), + "unauthenticated", + ); + assert.equal( + zcodeAuthFromCredentials( + JSON.parse(credentialsWithZaiToken), + JSON.parse( + '{"providerFamilyConnectionSelections":{"zai":{"kind":"individual-coding-plan"}},"modelProviderFamilyModes":{"zai":"apiKey"}}', + ), + ), + "unauthenticated", + ); + assert.equal(zcodeAuthFromCredentials({}, JSON.parse(settingWithZaiPlan)), "unauthenticated"); + }); +}); + +it.layer(NodeServices.layer)("Zcode status", (it) => { + it.effect("reports an authenticated ready provider from doctor JSON", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stdout.write(\'{"cli":{"version":"3.17.2"}}\');process.exit(0);', + ); + const dataDir = yield* dataDirWithCredentials(credentialsWithActiveProvider); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + ZCODE_DATA_BASE_DIR: dataDir, + }); + assert.equal(snapshot.enabled, true); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, "3.17.2"); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + assert.equal(snapshot.message, undefined); + }), + ), + ); + + it.effect("finds credentials through the OS home when only USERPROFILE is set", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stdout.write(\'{"cli":{"version":"3.17.2"}}\');process.exit(0);', + ); + const dataDir = yield* dataDirWithCredentials(credentialsWithActiveProvider); + yield* Effect.acquireRelease( + Effect.sync(() => { + homedirStub.value = dataDir; + }), + () => + Effect.sync(() => { + homedirStub.value = undefined; + }), + ); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + USERPROFILE: dataDir, + }); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, "3.17.2"); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + assert.equal(snapshot.message, undefined); + }), + ), + ); + + it.effect("still prefers HOME over the OS home", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stdout.write(\'{"cli":{"version":"3.17.2"}}\');process.exit(0);', + ); + const dataDir = yield* dataDirWithCredentials(credentialsWithActiveProvider); + yield* Effect.acquireRelease( + Effect.sync(() => { + homedirStub.value = `${dataDir}/elsewhere`; + }), + () => + Effect.sync(() => { + homedirStub.value = undefined; + }), + ); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + HOME: dataDir, + }); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + }), + ), + ); + + it.effect("flags a signed-out CLI as warning", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stdout.write(\'{"cli":{"version":"3.17.2"}}\');process.exit(0);', + ); + const dataDir = yield* dataDirWithCredentials(null); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + ZCODE_DATA_BASE_DIR: dataDir, + }); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, "3.17.2"); + assert.equal(snapshot.status, "warning"); + assert.equal(snapshot.auth.status, "unauthenticated"); + assert.match(snapshot.message ?? "", /zcode login/); + }), + ), + ); + + it.effect("accepts the plan-selected family credential provisioning accepts", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stdout.write(\'{"cli":{"version":"3.17.2"}}\');process.exit(0);', + ); + const dataDir = yield* dataDirWithCredentials(credentialsWithZaiToken, settingWithZaiPlan); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + ZCODE_DATA_BASE_DIR: dataDir, + }); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, "3.17.2"); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + assert.equal(snapshot.message, undefined); + }), + ), + ); + + it.effect("falls back to plain stdout when doctor output is not JSON", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stdout.write("zcode agent CLI 0.17.1\\n");process.exit(0);', + ); + const dataDir = yield* dataDirWithCredentials(credentialsWithActiveProvider); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + ZCODE_DATA_BASE_DIR: dataDir, + }); + assert.equal(snapshot.version, "0.17.1"); + assert.equal(snapshot.status, "ready"); + }), + ), + ); + + it.effect("reports an unsupported version as error", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stdout.write(\'{"cli":{"version":"0.16.0"}}\');process.exit(0);', + ); + const dataDir = yield* dataDirWithCredentials(credentialsWithActiveProvider); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + ZCODE_DATA_BASE_DIR: dataDir, + }); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, "0.16.0"); + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.auth.status, "unknown"); + assert.match(snapshot.message ?? "", /unsupported/i); + }), + ), + ); + + it.effect("reports a failing doctor run without leaking its stderr", () => + Effect.scoped( + Effect.gen(function* () { + const binaryPath = yield* fakeDoctor( + 'process.stderr.write("secret-doctor-value");process.exit(2);', + ); + const dataDir = yield* dataDirWithCredentials(null); + const snapshot = yield* checkZcodeProviderStatus(settings({ enabled: true, binaryPath }), { + ...BASE_ENV, + ZCODE_DATA_BASE_DIR: dataDir, + }); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, null); + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.message ?? "", "ZCode CLI is installed but failed to run."); + assert.equal((snapshot.message ?? "").includes("secret-doctor-value"), false); + }), + ), + ); + + it.effect("reports a missing binary as uninstalled", () => + Effect.scoped( + Effect.gen(function* () { + const snapshot = yield* checkZcodeProviderStatus( + settings({ enabled: true, binaryPath: "/nonexistent/zcode-missing" }), + BASE_ENV, + ); + assert.equal(snapshot.installed, false); + assert.equal(snapshot.version, null); + assert.equal(snapshot.status, "error"); + assert.match(snapshot.message ?? "", /not installed or not on PATH/); + }), + ), + ); + + it.effect("skips probing while the provider is disabled", () => + Effect.scoped( + Effect.gen(function* () { + const snapshot = yield* checkZcodeProviderStatus( + settings({ enabled: false, binaryPath: "/nonexistent/zcode-missing" }), + BASE_ENV, + ); + assert.equal(snapshot.enabled, false); + assert.equal(snapshot.installed, false); + assert.equal(snapshot.status, "disabled"); + assert.match(snapshot.message ?? "", /disabled/i); + }), + ), + ); +}); diff --git a/packages/provider-zcode/src/server/status.ts b/packages/provider-zcode/src/server/status.ts new file mode 100644 index 0000000000000..7955e64ff444d --- /dev/null +++ b/packages/provider-zcode/src/server/status.ts @@ -0,0 +1,381 @@ +import * as NodeOS from "node:os"; +import { + type CustomModelSetting, + type ModelCapabilities, + type ServerProviderModel, +} from "@t3tools/contracts"; +import type { ZcodeSettings } from "../settings.ts"; +import { resolveZcodeCatalogFiles, resolveZcodeLaunchCommand } from "./zcodeRuntime.ts"; +import { zcodeEntitledModels, zcodeOAuthSelectedFamilies } from "./zcodeRequestAuth.ts"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "@t3tools/provider-core/server/snapshotProbe"; + +const ZCODE_PRESENTATION = { + displayName: "ZCode", + showInteractionModeToggle: false, + supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"], + reportsContextWindow: true, +} as const; + +const VERSION_PROBE_TIMEOUT_MS = 10_000; + +/** `zcode doctor --json` reports cli.version, the wire-protocol carrier T3 depends on. */ +const MINIMUM_ZCODE_VERSION = "0.17.0"; + +const decodeDoctorOutput = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + cli: Schema.Struct({ version: Schema.optional(Schema.String) }), + }), + ), +); + +/** + * "default" defers to the model the signed-in ZCode CLI resolves on its own; the rest of the + * picker is enumerated from the CLI's injected catalog + entitlement ladder at probe time, so + * models the plan adds later appear without an adapter change. + */ +const ZCODE_DEFAULT_MODEL: ServerProviderModel = { + slug: "default", + name: "ZCode default", + isCustom: false, + isDefault: true, + capabilities: {}, +}; + +function zcodeReasoningCapabilities(levels: ReadonlyArray): ModelCapabilities { + if (levels.length === 0) return {}; + return createModelCapabilities({ + optionDescriptors: [ + { + id: "reasoningLevel", + label: "Reasoning", + type: "select", + role: "reasoning", + options: levels.map((level) => ({ + id: level, + label: level.charAt(0).toUpperCase() + level.slice(1), + })), + }, + ], + }); +} + +const zcodeEntitledPickerModels = Effect.fn("ZcodeProviderStatus.entitledModels")(function* ( + fileSystem: FileSystem.FileSystem, + environment: NodeJS.ProcessEnv, +) { + const catalogFiles = yield* resolveZcodeCatalogFiles(fileSystem, environment); + if (catalogFiles.builtinCatalog === undefined || catalogFiles.builtinPath === undefined) { + return []; + } + const entitled = yield* zcodeEntitledModels(fileSystem, { + builtinPath: catalogFiles.builtinPath, + builtinCatalog: catalogFiles.builtinCatalog, + environment, + }); + return entitled.map((model): ServerProviderModel => { + const capabilities = zcodeReasoningCapabilities(model.reasoningLevels); + return { + slug: `${model.providerId}/${model.modelId}`, + name: model.modelId, + subProvider: model.providerId, + isCustom: false, + capabilities: Object.keys(capabilities).length === 0 ? {} : capabilities, + }; + }); +}); + +function zcodeModelsFromSettings( + customModels: ReadonlyArray | undefined, + entitledModels: ReadonlyArray = [], +): ReadonlyArray { + return providerModelsFromSettings( + [ZCODE_DEFAULT_MODEL, ...entitledModels], + customModels ?? [], + {}, + ); +} + +const runZcodeDoctor = Effect.fn("ZcodeProviderStatus.runDoctor")(function* ( + zcodeSettings: ZcodeSettings, + environment: NodeJS.ProcessEnv, + fileSystem: FileSystem.FileSystem, +) { + const command = yield* resolveZcodeLaunchCommand(zcodeSettings, environment, fileSystem); + const spawnCommand = yield* resolveSpawnCommand(command, ["doctor", "--json"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); +}); + +/** + * Auth is CLI-managed (`zcode login`); the probe derives status from the same credential-store + * keys and setting.json plan selections the provisioning ladder accepts, so an account that can + * make ZCode requests never shows a false `zcode login` warning. + */ +export const zcodeAuthFromCredentials = ( + data: unknown, + setting: unknown, +): "authenticated" | "unauthenticated" | "unknown" => { + if (Array.isArray(data)) { + const hasActiveProvider = data.some( + (entry) => + Array.isArray(entry) && + entry[0] === "oauth:active_provider" && + typeof entry[1] === "string" && + entry[1].length > 0, + ); + return hasActiveProvider ? "authenticated" : "unauthenticated"; + } + // Object stores are judged like provisioning, which ignores any oauth:active_provider marker. + if (typeof data === "object" && data !== null) { + return zcodeOAuthSelectedFamilies(setting, data as Record).size > 0 + ? "authenticated" + : "unauthenticated"; + } + return "unknown"; +}; + +const decodeStoreJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +const readZcodeAuthStatus = Effect.fn("ZcodeProviderStatus.readAuthStatus")(function* ( + fileSystem: FileSystem.FileSystem, + credentialsPath: string, + settingPath: string, +): Effect.fn.Return<"authenticated" | "unauthenticated" | "unknown", never, never> { + const raw = yield* fileSystem.readFile(credentialsPath).pipe( + Effect.map((bytes) => new TextDecoder().decode(bytes)), + Effect.orElseSucceed(() => undefined), + ); + if (raw === undefined) return "unauthenticated" as const; + const setting = yield* fileSystem.readFileString(settingPath).pipe(Effect.option); + return zcodeAuthFromCredentials( + Option.getOrUndefined(decodeStoreJson(raw)), + Option.isNone(setting) ? undefined : Option.getOrUndefined(decodeStoreJson(setting.value)), + ); +}); + +const resolveZcodeDataPath = (environment: NodeJS.ProcessEnv, file: string): string => { + // Windows runs without HOME; os.homedir() (USERPROFILE there) keeps the probe on the real store. + const dataBaseDir = + environment.ZCODE_DATA_BASE_DIR?.trim() || environment.HOME || NodeOS.homedir(); + return `${dataBaseDir}/.zcode/v2/${file}`; +}; + +export const buildInitialZcodeProviderSnapshot = Effect.fn( + "ZcodeProviderStatus.buildInitialSnapshot", +)(function* (zcodeSettings: ZcodeSettings): Effect.fn.Return { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = zcodeModelsFromSettings(zcodeSettings.customModels); + if (!zcodeSettings.enabled) { + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "ZCode is disabled in T3 Code settings.", + }, + }); + } + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking ZCode CLI availability...", + }, + }); +}); + +export const checkZcodeProviderStatus = Effect.fn("checkZcodeProviderStatus")(function* ( + zcodeSettings: ZcodeSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fileSystem = yield* FileSystem.FileSystem; + const fallbackModels = zcodeModelsFromSettings(zcodeSettings.customModels); + + if (!zcodeSettings.enabled) { + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "ZCode is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runZcodeDoctor(zcodeSettings, environment, fileSystem).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + const detail = + error instanceof PlatformError.PlatformError + ? `${error.reason._tag}: ${error.message}` + : error._tag; + yield* Effect.logWarning("ZCode CLI health check failed.", { errorTag: error._tag, detail }); + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: zcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "ZCode CLI (`zcode`) is not installed or not on PATH. Install the ZCode CLI, then run `zcode login`." + : "Failed to execute ZCode CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: zcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "ZCode CLI is installed but timed out while running `zcode doctor`.", + }, + }); + } + + const probeOutput = versionResult.success.value; + if (probeOutput.code !== 0) { + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: zcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "ZCode CLI is installed but failed to run.", + }, + }); + } + + const doctor = Option.getOrUndefined( + Option.map(decodeDoctorOutput(probeOutput.stdout), (decoded) => decoded.cli), + ); + const version = doctor?.version ?? parseGenericCliVersion(probeOutput.stdout); + if (version === null) { + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: zcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: `T3 Code could not determine the ZCode CLI version. ZCode ${MINIMUM_ZCODE_VERSION} or newer is required.`, + }, + }); + } + + if (compareSemverVersions(version, MINIMUM_ZCODE_VERSION) < 0) { + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: zcodeSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `ZCode CLI ${version} is unsupported. Update to ZCode ${MINIMUM_ZCODE_VERSION} or newer.`, + }, + }); + } + + const authStatus = yield* readZcodeAuthStatus( + fileSystem, + resolveZcodeDataPath(environment, "credentials.json"), + resolveZcodeDataPath(environment, "setting.json"), + ); + // Enumerate the picker from the CLI's own catalog + entitlement ladder; fall back to the + // default-only list when the catalog files are absent (fresh installs before first login). + const entitledModels = yield* zcodeEntitledPickerModels(fileSystem, environment).pipe( + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + const models = zcodeModelsFromSettings(zcodeSettings.customModels, entitledModels); + return buildServerProvider({ + presentation: ZCODE_PRESENTATION, + enabled: zcodeSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version, + status: authStatus === "authenticated" ? "ready" : "warning", + auth: { status: authStatus, type: "zcode" }, + ...(authStatus === "authenticated" + ? {} + : { + message: + "ZCode CLI is installed but not signed in. Run `zcode login` in a terminal, then refresh this provider.", + }), + }, + }); +}); diff --git a/packages/provider-zcode/src/server/zcodeProtocol.test.ts b/packages/provider-zcode/src/server/zcodeProtocol.test.ts new file mode 100644 index 0000000000000..aa02b7350764e --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeProtocol.test.ts @@ -0,0 +1,249 @@ +import { assert, describe, it } from "@effect/vitest"; +import { expect, vi } from "vite-plus/test"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Sink from "effect/Sink"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/process"; +import * as HostProcess from "@t3tools/shared/HostProcess"; + +import { makeZcodeConnection, ZcodeProtocolError, type ZcodeRecord } from "./zcodeProtocol.ts"; + +// Mirrors the private EXIT_DRAIN_GRACE and TERMINATION_GRACE in zcodeProtocol.ts. +const EXIT_DRAIN_GRACE_MS = 1_000; +const TERMINATION_GRACE_MS = 1_000; + +const processGroupState = vi.hoisted(() => ({ + groupAlive: true, + signals: [] as Array, +})); + +vi.mock("@t3tools/provider-core/server/processGroup", () => ({ + signalProcessGroup: (pid: number, signal: NodeJS.Signals | 0) => { + if (!processGroupState.groupAlive) { + throw Object.assign(new Error(`kill ESRCH: no process group ${pid}`), { code: "ESRCH" }); + } + processGroupState.signals.push(signal); + }, +})); + +const setupProcessGroup = (groupAlive: boolean) => { + processGroupState.groupAlive = groupAlive; + processGroupState.signals = []; +}; + +const makeHeldOpenChild = Effect.fnUntraced(function* (scope?: Scope.Closeable) { + const exitCode = yield* Deferred.make(); + const stdout = yield* Queue.unbounded>(); + const stdinChunks = yield* Queue.unbounded(); + const handle = ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(55_000), + exitCode: Deferred.await(exitCode), + isRunning: Effect.succeed(true), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.forEach((chunk: Uint8Array) => Queue.offer(stdinChunks, chunk).pipe(Effect.asVoid)), + stdout: Stream.fromQueue(stdout), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + const unscoped = makeZcodeConnection({ + command: "zcode", + args: ["app-server", "--stdio"], + cwd: undefined, + env: {}, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.succeed(handle)), + ), + Effect.provideService(HostProcess.Platform, "linux"), + ); + const connection = yield* scope === undefined ? unscoped : unscoped.pipe(Scope.provide(scope)); + return { connection, exitCode, stdout, stdinChunks }; +}); + +const takeSentRecord = Effect.fnUntraced(function* (stdinChunks: Queue.Dequeue) { + const line = new TextDecoder().decode(yield* Queue.take(stdinChunks)); + return JSON.parse(line) as ZcodeRecord; +}); + +describe("makeZcodeConnection", () => { + it.effect("fails the transport once the exit grace elapses with stdout still open", () => + Effect.scoped( + Effect.gen(function* () { + // Group modeled empty, so the scope-close termination below is a no-op. + setupProcessGroup(false); + const { connection, exitCode, stdout } = yield* makeHeldOpenChild(); + + yield* Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(0)); + assert.equal(yield* connection.exited, 0); + + // Records buffered when zcode died still drain while the grace runs. + yield* Queue.offer(stdout, new TextEncoder().encode('{"event":"turn.completed"}\n')); + const drained = yield* Queue.take(connection.events); + assert.equal(drained["event"], "turn.completed"); + + const down = yield* Deferred.make(); + yield* Queue.take(connection.events).pipe( + Effect.catch((error) => Deferred.succeed(down, error)), + Effect.forkScoped, + ); + yield* TestClock.adjust(Duration.millis(EXIT_DRAIN_GRACE_MS - 1)); + assert.isTrue(Option.isNone(yield* Deferred.poll(down))); + yield* TestClock.adjust(Duration.millis(1)); + const error = yield* Deferred.await(down); + assert.instanceOf(error, ZcodeProtocolError); + assert.equal(error.operation, "read"); + assert.equal(error.detail, "zcode app-server exited with code 0, but stdout never closed"); + }), + ), + ); + + it.effect("stdout closing after exit still reports the exit code", () => + Effect.scoped( + Effect.gen(function* () { + // Group modeled empty, so the scope-close termination below is a no-op. + setupProcessGroup(false); + const { connection, exitCode, stdout } = yield* makeHeldOpenChild(); + const down = yield* Deferred.make(); + yield* Queue.take(connection.events).pipe( + Effect.catch((error) => Deferred.succeed(down, error)), + Effect.forkScoped, + ); + + yield* Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(7)); + assert.equal(yield* connection.exited, 7); + yield* Queue.end(stdout); + + const error = yield* Deferred.await(down); + assert.equal(error.operation, "read"); + assert.equal(error.detail, "zcode app-server exited with code 7"); + }), + ), + ); + + it.effect("escalates to SIGKILL after the leader exits while tools keep the group alive", () => + Effect.scoped( + Effect.gen(function* () { + setupProcessGroup(true); + const scope = yield* Scope.make(); + const { connection, exitCode } = yield* makeHeldOpenChild(scope); + + yield* Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(0)); + assert.equal(yield* connection.exited, 0); + + // Detached tools keep the pgid alive after the leader is reaped; termination must + // still SIGTERM the group and, after the grace, SIGKILL the survivors. + const closed = yield* Scope.close(scope, Exit.void).pipe(Effect.forkScoped); + yield* TestClock.adjust(Duration.millis(TERMINATION_GRACE_MS)); + yield* Fiber.join(closed); + + // hasExited probes interleave signal 0; assert on the delivered signals only. + const sent = processGroupState.signals.filter((signal) => signal !== 0); + expect(sent).toEqual(["SIGTERM", "SIGKILL"]); + }), + ), + ); + + it.effect("skips group signalling once the leader exited and the group is empty", () => + Effect.scoped( + Effect.gen(function* () { + setupProcessGroup(false); + const scope = yield* Scope.make(); + const { connection, exitCode } = yield* makeHeldOpenChild(scope); + + yield* Deferred.succeed(exitCode, ChildProcessSpawner.ExitCode(0)); + assert.equal(yield* connection.exited, 0); + + // No survivors: termination returns without waiting the grace or signalling. + yield* Scope.close(scope, Exit.void); + + expect(processGroupState.signals).toEqual([]); + }), + ), + ); + + it.effect("a rejected request reports the real method with bounded detail", () => + Effect.scoped( + Effect.gen(function* () { + // Group modeled empty, so the scope-close termination below is a no-op. + setupProcessGroup(false); + const { connection, stdout, stdinChunks } = yield* makeHeldOpenChild(); + + const pending = yield* connection + .request("provider/updateAccountConfig", { accountId: "acct_1" }) + .pipe( + Effect.catch((error) => Effect.succeed(error)), + Effect.forkScoped, + ); + + const sent = yield* takeSentRecord(stdinChunks); + assert.equal(sent["method"], "provider/updateAccountConfig"); + + const payload = "SECRET_ZCODE_SERVER_PAYLOAD"; + const serverError = { code: "accountConfigRejected", message: payload }; + yield* Queue.offer( + stdout, + new TextEncoder().encode(`${JSON.stringify({ id: sent["id"], error: serverError })}\n`), + ); + + const error = yield* Fiber.join(pending); + assert.instanceOf(error, ZcodeProtocolError); + // Raw payload stays in cause only; message and detail carry bounded metadata. + assert.isFalse(`${error.detail}`.includes(payload)); + assert.isFalse(error.message.includes(payload)); + assert.equal( + error.detail, + `code accountConfigRejected, ${JSON.stringify(serverError).length}-char payload`, + ); + assert.include(JSON.stringify(error.cause), payload); + // The failure names the method that failed, not the generic "request". + assert.equal(error.operation, "provider/updateAccountConfig"); + }), + ), + ); + + it.effect("a string error payload also stays confined to cause", () => + Effect.scoped( + Effect.gen(function* () { + // Group modeled empty, so the scope-close termination below is a no-op. + setupProcessGroup(false); + const { connection, stdout, stdinChunks } = yield* makeHeldOpenChild(); + + const pending = yield* connection + .request("session/subscribe", { sessionId: "sess_1" }) + .pipe( + Effect.catch((error) => Effect.succeed(error)), + Effect.forkScoped, + ); + + const sent = yield* takeSentRecord(stdinChunks); + + const payload = "SECRET_BARE_ZCODE_PAYLOAD"; + yield* Queue.offer( + stdout, + new TextEncoder().encode(`${JSON.stringify({ id: sent["id"], error: payload })}\n`), + ); + + const error = yield* Fiber.join(pending); + assert.instanceOf(error, ZcodeProtocolError); + assert.isFalse(`${error.detail}`.includes(payload)); + assert.isFalse(error.message.includes(payload)); + assert.equal(error.detail, `${JSON.stringify(payload).length}-char payload`); + assert.include(JSON.stringify(error.cause), payload); + assert.equal(error.operation, "session/subscribe"); + }), + ), + ); +}); diff --git a/packages/provider-zcode/src/server/zcodeProtocol.ts b/packages/provider-zcode/src/server/zcodeProtocol.ts new file mode 100644 index 0000000000000..45a67c9a81588 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeProtocol.ts @@ -0,0 +1,480 @@ +// ZCode app-server speaks NDJSON on stdio; responses carry {id, result|error} while server→client +// requests repeat the {id, method, params} shape and must be answered via respond(). +import * as Clock from "effect/Clock"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; + +export class ZcodeProtocolError extends Schema.TaggedError()( + "ZcodeProtocolError", + { + operation: Schema.String, + detail: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `ZCode protocol ${this.operation} failed${this.detail === undefined ? "" : `: ${this.detail}`}.`; + } +} + +export class ZcodeProtocolTimeoutError extends Schema.TaggedError()( + "ZcodeProtocolTimeoutError", + { + operation: Schema.String, + timeoutMs: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })), + }, +) { + override get message(): string { + return `ZCode protocol ${this.operation} failed: timed out after ${this.timeoutMs}ms.`; + } +} + +export type ZcodeRecord = Record; + +export function zcodeRecordField(input: unknown, key: string): unknown { + return Predicate.isObject(input) ? input[key] : undefined; +} + +export function zcodeRecordString(input: unknown, key: string): string | undefined { + const value = zcodeRecordField(input, key); + return Predicate.isString(value) ? value : undefined; +} + +export function zcodeRecordNumber(input: unknown, key: string): number | undefined { + const value = zcodeRecordField(input, key); + return Predicate.isNumber(value) && Number.isFinite(value) ? value : undefined; +} + +export function zcodeRecordArray(input: unknown, key: string): ReadonlyArray | undefined { + const value = zcodeRecordField(input, key); + return Array.isArray(value) ? value : undefined; +} + +export const isServerRequest = (record: ZcodeRecord): boolean => + record["id"] !== undefined && Predicate.isString(record["method"]); + +export interface ZcodeSpawnOptions { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string | undefined; + readonly env: NodeJS.ProcessEnv; +} + +export interface ZcodeConnection { + readonly request: ( + method: string, + params: ZcodeRecord, + timeoutMs?: number, + ) => Effect.Effect; + readonly respond: (id: string, result: unknown) => Effect.Effect; + readonly events: Queue.Queue; + readonly exited: Effect.Effect; + readonly terminate: Effect.Effect; +} + +const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; +const TERMINATION_GRACE = Duration.seconds(1); +const EXIT_DRAIN_GRACE = Duration.seconds(1); + +interface PendingZcodeRequest { + readonly deferred: Deferred.Deferred; + readonly method: string; +} + +const MAX_ZCODE_RECORD_CHARS = 16 * 1024 * 1024; + +function makeJsonlFramer() { + let buffer = ""; + let dropping = false; + return (chunk: string) => { + const lines: string[] = []; + const droppedLengths: number[] = []; + let start = 0; + while (start < chunk.length) { + const newline = chunk.indexOf("\n", start); + const end = newline < 0 ? chunk.length : newline; + if (!dropping) { + if (buffer.length + end - start > MAX_ZCODE_RECORD_CHARS) { + droppedLengths.push(buffer.length + end - start); + buffer = ""; + dropping = true; + } else { + buffer += chunk.slice(start, end); + } + } + if (newline < 0) break; + if (!dropping && buffer.length > 0) { + lines.push(buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer); + } + buffer = ""; + dropping = false; + start = newline + 1; + } + return { lines, droppedLengths }; + }; +} + +const logDroppedZcodeRecords = (droppedLengths: ReadonlyArray) => + Effect.forEach( + droppedLengths, + (recordLength) => + Effect.logWarning("Dropping oversized zcode stdout record.", { + recordLength, + maxRecordLength: MAX_ZCODE_RECORD_CHARS, + }), + { discard: true }, + ); + +const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); +const decodeJsonLine = Schema.decodeSync(UnknownFromJsonString); +const encodeJsonLine = Schema.encodeSync(UnknownFromJsonString); + +/** JSON text for wire params/diagnostics; pure, so it may fall back to an empty string. */ +export function zcodeJsonStringify(value: unknown): string { + try { + return encodeJsonLine(value); + } catch { + return ""; + } +} + +const ZCODE_SERVER_ERROR_CODE_MAX_CHARS = 100; + +// Bounded server-error metadata; the raw payload travels only in `cause`. +function describeZcodeServerError(error: unknown): string { + const code = zcodeRecordField(error, "code"); + const codeText = + typeof code === "string" || typeof code === "number" + ? `code ${String(code).slice(0, ZCODE_SERVER_ERROR_CODE_MAX_CHARS)}` + : undefined; + const payloadLength = JSON.stringify(error)?.length ?? 0; + return codeText === undefined + ? `${payloadLength}-char payload` + : `${codeText}, ${payloadLength}-char payload`; +} + +function describeZcodeStdoutClosure(exitCode: number | undefined): string { + if (exitCode === undefined || !Number.isFinite(exitCode)) { + return "zcode app-server closed stdout"; + } + return `zcode app-server exited with code ${exitCode}`; +} + +function parseZcodeRecord(line: string): ZcodeRecord | undefined { + try { + const parsed: unknown = decodeJsonLine(line); + return Predicate.isObject(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +const terminateZcodeProcess = Effect.fn("ZcodeProtocol.terminateZcodeProcess")(function* ( + kill: (signal: NodeJS.Signals) => boolean, + hasExited: () => boolean, +) { + if (hasExited()) return; + if (!kill("SIGTERM")) return; + yield* Effect.sleep(TERMINATION_GRACE); + if (hasExited()) return; + kill("SIGKILL"); +}); + +export const makeZcodeConnection = Effect.fnUntraced(function* (options: ZcodeSpawnOptions) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = yield* HostProcess.Platform; + const scope = yield* Effect.scope; + + const spawnCommand = yield* resolveSpawnCommand(options.command, [...options.args], { + env: options.env, + }).pipe(Effect.mapError((cause) => new ZcodeProtocolError({ operation: "spawn", cause }))); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + env: options.env, + extendEnv: false, + shell: spawnCommand.shell, + detached: platform !== "win32", + stdin: { stream: "pipe", endOnDone: false }, + }), + ) + .pipe(Effect.mapError((cause) => new ZcodeProtocolError({ operation: "spawn", cause }))); + + let childExited = false; + let diagnosingStdoutClose = false; + + const killProcessGroup = (signal: NodeJS.Signals): boolean => { + try { + if (platform === "win32") { + process.kill(Number(child.pid), signal); + } else { + signalProcessGroup(Number(child.pid), signal); + } + return true; + } catch { + return false; + } + }; + + const hasExited = (): boolean => { + // A dead POSIX leader does not empty its group: detached tools keep the pgid alive and + // must stay reachable for the SIGKILL escalation. + if (childExited && platform === "win32") return true; + try { + if (platform === "win32") process.kill(Number(child.pid), 0); + else signalProcessGroup(Number(child.pid), 0); + return false; + } catch { + return true; + } + }; + + const terminateWindowsTree = Effect.gen(function* () { + if (hasExited()) return; + const taskkill = yield* spawner.spawn( + ChildProcess.make("taskkill", ["/PID", String(child.pid), "/T", "/F"]), + ); + yield* taskkill.exitCode; + }).pipe(Effect.scoped, Effect.ignore); + + const terminateProcess = + platform === "win32" + ? terminateWindowsTree + : terminateZcodeProcess(killProcessGroup, hasExited); + + yield* Scope.addFinalizer(scope, terminateProcess.pipe(Effect.ignore, Effect.uninterruptible)); + + const pendingRequests = new Map(); + const events = yield* Queue.unbounded(); + const outgoing = yield* Queue.unbounded(); + const transportDown = yield* Deferred.make(); + const exitDeferred = yield* Deferred.make(); + let nextRequestId = 1; + + const failTransport = Effect.fn("ZcodeProtocol.failTransport")(function* ( + error: ZcodeProtocolError, + ) { + const claimed = yield* Deferred.fail(transportDown, error); + if (!claimed) return; + for (const [key, pending] of pendingRequests) { + pendingRequests.delete(key); + yield* Deferred.fail(pending.deferred, error); + } + yield* Queue.fail(outgoing, error); + yield* Queue.fail(events, error); + }); + + const routeRecord = + /* Streaming deltas ride through here; avoid a span per record. */ + Effect.fnUntraced(function* (record: ZcodeRecord) { + if (isServerRequest(record)) { + // Server→client requests are surfaced as events; the adapter answers via respond(). + yield* Queue.offer(events, record); + return; + } + if (record["id"] !== undefined) { + const key = String(record["id"]); + const pending = pendingRequests.get(key); + if (pending !== undefined) { + pendingRequests.delete(key); + if (record["error"] === undefined) { + yield* Deferred.succeed(pending.deferred, record["result"]); + } else { + yield* Deferred.fail( + pending.deferred, + new ZcodeProtocolError({ + operation: pending.method, + detail: describeZcodeServerError(record["error"]), + ...(record["error"] === undefined ? {} : { cause: record["error"] }), + }), + ); + } + return; + } + } + yield* Queue.offer(events, record); + }); + + yield* child.exitCode.pipe( + Effect.matchEffect({ + onFailure: (cause) => + Deferred.fail(exitDeferred, new ZcodeProtocolError({ operation: "exit", cause })), + onSuccess: (code) => + Effect.suspend(() => { + childExited = true; + return Deferred.succeed(exitDeferred, Number(code)); + }), + }), + Effect.forkIn(scope), + ); + + // A detached tool child can inherit stdout and keep it open after zcode exits, so stdout + // EOF never arrives and stdout closure cannot fail the transport. Give records buffered + // at death the grace window to drain, then fail the transport without EOF; the + // transportDown claim keeps a later stdout close from double-failing. + yield* Deferred.await(exitDeferred).pipe( + Effect.matchEffect({ + onFailure: (cause) => + Effect.sleep(EXIT_DRAIN_GRACE).pipe( + Effect.andThen(failTransport(new ZcodeProtocolError({ operation: "exit", cause }))), + ), + onSuccess: (exitCode) => + Effect.sleep(EXIT_DRAIN_GRACE).pipe( + Effect.andThen( + failTransport( + new ZcodeProtocolError({ + operation: "read", + detail: `zcode app-server exited with code ${exitCode}, but stdout never closed`, + }), + ), + ), + ), + }), + Effect.forkIn(scope), + ); + + yield* Effect.gen(function* () { + const frame = makeJsonlFramer(); + yield* child.stdout.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + Effect.gen(function* () { + const { lines, droppedLengths } = frame(chunk); + yield* logDroppedZcodeRecords(droppedLengths); + for (const line of lines) { + const record = parseZcodeRecord(line); + if (record === undefined) { + yield* Effect.logDebug("Dropping non-JSON zcode stdout line.", { + lineLength: line.length, + }); + continue; + } + yield* routeRecord(record); + } + }), + ), + ); + const flushed = frame("\n"); + yield* logDroppedZcodeRecords(flushed.droppedLengths); + for (const line of flushed.lines) { + const trailing = parseZcodeRecord(line); + if (trailing !== undefined) yield* routeRecord(trailing); + } + }).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => failTransport(new ZcodeProtocolError({ operation: "read", cause })), + onSuccess: Effect.fnUntraced(function* () { + diagnosingStdoutClose = true; + const polled = yield* Deferred.poll(exitDeferred); + const maybeExitCode = Option.isSome(polled) + ? yield* polled.value.pipe( + Effect.asSome, + Effect.orElseSucceed(() => Option.none()), + ) + : yield* Deferred.await(exitDeferred).pipe( + Effect.timeoutOption(Duration.millis(250)), + // Stdout-close waits need wall time; TestClock would strand Stop-with-restart without an exit. + Effect.provideService(Clock.Clock, Clock.Clock.defaultValue()), + Effect.orElseSucceed(() => Option.none()), + ); + return yield* failTransport( + new ZcodeProtocolError({ + operation: "read", + detail: describeZcodeStdoutClosure( + Option.isSome(maybeExitCode) ? maybeExitCode.value : undefined, + ), + }), + ); + }), + }), + Effect.forkIn(scope), + ); + + yield* child.stderr.pipe( + Stream.decodeText(), + Stream.runForEach((chunk) => + chunk.trim().length === 0 + ? Effect.void + : Effect.logDebug("zcode app-server stderr", { stderrLength: chunk.length }), + ), + Effect.ignore, + Effect.forkIn(scope), + ); + + yield* Stream.fromQueue(outgoing).pipe( + Stream.run(child.stdin), + Effect.catchCause((cause) => + diagnosingStdoutClose + ? Effect.void + : failTransport(new ZcodeProtocolError({ operation: "write", cause })), + ), + Effect.forkIn(scope), + ); + + const send = + /* Streaming deltas ride through here; avoid a span per record. */ + Effect.fnUntraced(function* (record: ZcodeRecord): Effect.fn.Return { + const accepted = yield* Queue.offer( + outgoing, + new TextEncoder().encode(`${encodeJsonLine(record)}\n`), + ); + if (!accepted) { + return yield* Deferred.await(transportDown); + } + }); + + const request = Effect.fn("ZcodeProtocol.request")(function* ( + method: string, + params: ZcodeRecord, + timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + ): Effect.fn.Return { + const id = nextRequestId++; + const deferred = yield* Deferred.make(); + pendingRequests.set(String(id), { deferred, method }); + yield* send({ id, method, params }).pipe( + Effect.tapError(() => Effect.sync(() => pendingRequests.delete(String(id)))), + ); + return yield* Effect.raceFirst(Deferred.await(deferred), Deferred.await(transportDown)).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(timeoutMs), + orElse: () => Effect.fail(new ZcodeProtocolTimeoutError({ operation: method, timeoutMs })), + }), + Effect.onInterrupt(() => Effect.sync(() => pendingRequests.delete(String(id)))), + Effect.onError(() => Effect.sync(() => pendingRequests.delete(String(id)))), + ); + }); + + const respond = Effect.fnUntraced(function* ( + id: string, + result: unknown, + ): Effect.fn.Return { + yield* send({ id, result }); + }); + + return { + request, + respond, + events, + exited: Deferred.await(exitDeferred), + terminate: failTransport( + new ZcodeProtocolError({ + operation: "terminate", + detail: "zcode app-server was stopped", + }), + ).pipe(Effect.andThen(terminateProcess), Effect.ignore, Effect.uninterruptible), + } satisfies ZcodeConnection; +}); diff --git a/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts b/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts new file mode 100644 index 0000000000000..b85c402b959a7 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeRequestAuth.test.ts @@ -0,0 +1,236 @@ +// @effect-diagnostics nodeBuiltinImport:off -- tests craft the CLI's own AES-GCM store values with node crypto +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { assert, describe, it } from "@effect/vitest"; +import * as NodeCrypto from "node:crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +import { + decryptZcodeCredential, + makeZcodeRequestAuth, + resolveZcodeCredentialSecret, + zcodeAccountScope, + zcodeFamilyFromProviderId, +} from "./zcodeRequestAuth.ts"; +import { zcodeRecordField, zcodeRecordString } from "./zcodeProtocol.ts"; + +/** Produces store values the CLI's fallback cipher would hold, keyed the same way. */ +const encryptZcodeCredential = (value: string, secret: string): string => { + const iv = NodeCrypto.randomBytes(12); + const cipher = NodeCrypto.createCipheriv( + "aes-256-gcm", + NodeCrypto.createHash("sha256").update(secret).digest(), + iv, + ); + const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + return `enc:v1:${iv.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}.${ciphertext.toString("base64url")}`; +}; + +describe("resolveZcodeCredentialSecret", () => { + it("prefers ZCODE_CREDENTIAL_SECRET and trims it", () => { + assert.equal( + resolveZcodeCredentialSecret({ ZCODE_CREDENTIAL_SECRET: " custom-secret " }, "linux"), + "custom-secret", + ); + const blankOverride = resolveZcodeCredentialSecret( + { ZCODE_CREDENTIAL_SECRET: " ", HOME: "/home/dev" }, + "linux", + ); + assert.match(blankOverride, /^zcode-credential-fallback:linux:\/home\/dev:/); + }); + + it("derives the CLI's machine-bound fallback with the HOME from the environment", () => { + // Username comes from os.userInfo() where resolvable (env names never win); on runtimes + // where it throws, the env fallback speaks. Only the stable parts are asserted here. + const secret = resolveZcodeCredentialSecret( + { USER: "someoneElse", HOME: "/home/dev" }, + "darwin", + ); + assert.match(secret, /^zcode-credential-fallback:darwin:\/home\/dev:/); + assert.match(resolveZcodeCredentialSecret({}, "linux"), /^zcode-credential-fallback:linux:/); + }); +}); + +describe("decryptZcodeCredential", () => { + it("round-trips the CLI's encrypted store values", () => { + const secret = "zcode-credential-fallback:linux:/home/dev:jame"; + for (const plaintext of ["oauth-login-token", "值-with-unicode 🔐", ""]) { + assert.equal( + decryptZcodeCredential(encryptZcodeCredential(plaintext, secret), secret), + plaintext, + ); + } + }); + + it("passes plain store values through and rejects malformed ciphertext", () => { + assert.equal(decryptZcodeCredential("plain-token", "s"), "plain-token"); + assert.throws(() => decryptZcodeCredential("enc:v1:not-a-cipher", "s")); + assert.throws(() => decryptZcodeCredential("enc:v1:only.two", "s")); + }); +}); + +describe("zcodeFamilyFromProviderId", () => { + it("maps account plan provider ids to their auth family", () => { + assert.equal(zcodeFamilyFromProviderId("account:zai-individual-coding-plan"), "zai"); + assert.equal(zcodeFamilyFromProviderId("account:bigmodel-individual-coding-plan"), "bigmodel"); + assert.equal(zcodeFamilyFromProviderId("account:zai"), "zai"); + }); + + it("returns undefined for provider ids outside the account namespace", () => { + assert.equal(zcodeFamilyFromProviderId(undefined), undefined); + assert.equal(zcodeFamilyFromProviderId("account:"), undefined); + assert.equal(zcodeFamilyFromProviderId(""), undefined); + }); +}); + +describe("zcodeAccountScope", () => { + it("digests the plan identity, login token, and JWT exactly like the CLI", () => { + assert.equal( + zcodeAccountScope("zai", "login-token", undefined), + NodeCrypto.createHash("sha256") + .update(JSON.stringify(["zai", "individual-coding-plan", null, "login-token", null])) + .digest("hex"), + ); + assert.equal( + zcodeAccountScope("zai", "login-token", "jwt"), + NodeCrypto.createHash("sha256") + .update(JSON.stringify(["zai", "individual-coding-plan", null, "login-token", "jwt"])) + .digest("hex"), + ); + }); + + it("is deterministic and changes with any input", () => { + assert.equal( + zcodeAccountScope("zai", "tok", undefined), + zcodeAccountScope("zai", "tok", undefined), + ); + assert.notEqual( + zcodeAccountScope("zai", "tok", undefined), + zcodeAccountScope("bigmodel", "tok", undefined), + ); + assert.notEqual( + zcodeAccountScope("zai", "tok", undefined), + zcodeAccountScope("zai", "tok2", undefined), + ); + }); +}); + +describe("makeZcodeRequestAuth", () => { + /** A temp data root with the zai login on disk plus an HTTP fake answering the mint chain. */ + const harness = Effect.fnUntraced(function* (clientVersion: string | undefined) { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "zcode-request-auth-test-" }); + yield* fileSystem.makeDirectory(`${root}/.zcode/v2`, { recursive: true }); + const platform = yield* HostProcess.Platform; + const environment = { ZCODE_DATA_BASE_DIR: root, HOME: "/home/dev", USER: "dev" }; + const secret = resolveZcodeCredentialSecret(environment, platform); + const writeLogin = (loginToken: string | undefined) => + fileSystem.writeFileString( + `${root}/.zcode/v2/credentials.json`, + JSON.stringify( + loginToken === undefined + ? {} + : { "oauth:zai:access_token": encryptZcodeCredential(loginToken, secret) }, + ), + ); + yield* writeLogin("login-a"); + const exchangeLogins: Array = []; + const exchangeBodies: Array = []; + const httpClient = HttpClient.make((request, url) => { + const ok = (data: unknown) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ code: 200, data }))); + if (url.pathname.endsWith("/api/biz/customer/getCustomerInfo")) { + return ok({ + organizations: [ + { + organizationId: "org-1", + organizationName: "默认机构", + projects: [{ projectId: "proj-1", projectName: "默认项目" }], + }, + ], + }); + } + if (url.pathname.endsWith("/api_keys")) { + return ok([{ name: "zcode-api-key", apiKey: "key-1" }]); + } + exchangeLogins.push((request.headers.authorization ?? "").replace(/^Bearer /, "")); + exchangeBodies.push( + request.body._tag === "Uint8Array" + ? JSON.parse(new TextDecoder().decode(request.body.body)) + : undefined, + ); + return ok({ accessToken: `access-token-${exchangeLogins.length}` }); + }); + return { + auth: makeZcodeRequestAuth({ fileSystem, httpClient, environment, clientVersion }), + writeLogin, + exchangeLogins, + exchangeBodies, + }; + }); + + const requestAuthOf = (response: unknown): Record => + (zcodeRecordField(response, "requestAuth") ?? {}) as Record; + + it.effect("serves a cache hit while the family's credentials stay the same", () => + Effect.gen(function* () { + const h = yield* harness("test"); + const first = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); + assert.equal(zcodeRecordString(requestAuthOf(first), "apiKey"), "access-token-1"); + assert.equal( + zcodeRecordString(requestAuthOf(first), "accountScope"), + zcodeAccountScope("zai", "login-a", undefined), + ); + // The CLI may rewrite the store with a fresh IV for the same token; the credential scope, + // not the ciphertext, must decide validity. + yield* h.writeLogin("login-a"); + const second = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); + assert.equal(zcodeRecordString(requestAuthOf(second), "apiKey"), "access-token-1"); + assert.deepEqual(h.exchangeLogins, ["login-a"]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("re-mints against the new account when the family's login changes", () => + Effect.gen(function* () { + const h = yield* harness("test"); + const first = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); + assert.equal(zcodeRecordString(requestAuthOf(first), "apiKey"), "access-token-1"); + yield* h.writeLogin("login-b"); + const second = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); + assert.equal(zcodeRecordString(requestAuthOf(second), "apiKey"), "access-token-2"); + assert.equal( + zcodeRecordString(requestAuthOf(second), "accountScope"), + zcodeAccountScope("zai", "login-b", undefined), + ); + assert.equal(zcodeRecordString(requestAuthOf(second), "apiKeyId"), "key-1"); + assert.deepEqual(h.exchangeLogins, ["login-a", "login-b"]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("declines once the family's login disappears", () => + Effect.gen(function* () { + const h = yield* harness("test"); + const first = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); + assert.equal(zcodeRecordString(requestAuthOf(first), "apiKey"), "access-token-1"); + yield* h.writeLogin(undefined); + const second = yield* h.auth.answerRuntimeHeaders({ family: "zai" }); + assert.equal(zcodeRecordField(second, "headersApplied"), false); + assert.match(zcodeRecordString(second, "errorMessage") ?? "", /no OAuth login for family/); + assert.deepEqual(h.exchangeLogins, ["login-a"]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reports the installed CLI version in the token exchange only when known", () => + Effect.gen(function* () { + const known = yield* harness("3.17.2"); + yield* known.auth.answerRuntimeHeaders({ family: "zai" }); + const unknown = yield* harness(undefined); + yield* unknown.auth.answerRuntimeHeaders({ family: "zai" }); + assert.deepEqual(known.exchangeBodies, [{ clientType: "zcode", clientVersion: "3.17.2" }]); + assert.deepEqual(unknown.exchangeBodies, [{ clientType: "zcode" }]); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/provider-zcode/src/server/zcodeRequestAuth.ts b/packages/provider-zcode/src/server/zcodeRequestAuth.ts new file mode 100644 index 0000000000000..d54975e429918 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeRequestAuth.ts @@ -0,0 +1,630 @@ +// @effect-diagnostics nodeBuiltinImport:off -- the push revision hashes the injected catalog path with sha256 +// Account provisioning and request auth for the ZCode CLI: the entitlement ladder behind the +// `provider/updateAccountConfig` push, and the OAuth project-token exchange that answers +// `interaction/requestProviderRuntimeHeaders` before every account-model request. Verified +// end-to-end against zcode 3.17.2 (PROTOCOL-NOTES.md); the desktop host performs the same +// exchanges. +import * as NodeCrypto from "node:crypto"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import { + zcodeJsonStringify, + zcodeRecordArray, + zcodeRecordField, + zcodeRecordString, + type ZcodeRecord, +} from "./zcodeProtocol.ts"; +import { zcodeDataRoot } from "./zcodeRuntime.ts"; + +const ENCRYPTED_PREFIX = "enc:v1:"; +const CIPHER_ALGORITHM = "aes-256-gcm"; +const ZCODE_JWT_CREDENTIAL_KEY = "zcodejwttoken"; +const ZCODE_API_KEY_NAME = "zcode-api-key"; +const TEAM_PROJECT_TYPE = "2"; +const DEFAULT_ORGANIZATION_NAME = "默认机构"; +const DEFAULT_PROJECT_NAME = "默认项目"; +const REQUEST_TIMEOUT_MS = 15_000; +const TOKEN_TTL_MS = 10 * 60_000; +/** Refresh the cached project token this long before expiry so a queued request never stalls. */ +const TOKEN_REFRESH_MARGIN_MS = 120_000; + +const FAMILY_ORIGIN: Record = { + zai: "https://api.z.ai", + bigmodel: "https://bigmodel.cn", +}; + +const sha256Hex = (value: string): string => + NodeCrypto.createHash("sha256").update(value).digest("hex"); + +/** + * The CLI's machine-bound fallback cipher secret: sha256 of this string keys the store's + * AES-256-GCM values. ZCODE_CREDENTIAL_SECRET overrides it for custom installs. + */ +export const resolveZcodeCredentialSecret = ( + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): string => { + const configured = environment.ZCODE_CREDENTIAL_SECRET?.trim(); + if (configured !== undefined && configured.length > 0) return configured; + // The CLI binds the fallback to os.userInfo().username — env USER/USERNAME may be absent in + // server processes and must not silently derive a different key. + let username = "unknown"; + try { + username = NodeOS.userInfo().username; + } catch { + username = environment.USER ?? environment.USERNAME ?? "unknown"; + } + const home = environment.HOME ?? NodeOS.homedir(); + return `zcode-credential-fallback:${platform}:${home}:${username}`; +}; + +export const decryptZcodeCredential = (value: string, secret: string): string => { + if (!value.startsWith(ENCRYPTED_PREFIX)) return value; + const [ivRaw, tagRaw, cipherRaw] = value.slice(ENCRYPTED_PREFIX.length).split("."); + if (ivRaw === undefined || tagRaw === undefined || cipherRaw === undefined) { + throw new Error("Encrypted credential is not ..."); + } + const decipher = NodeCrypto.createDecipheriv( + CIPHER_ALGORITHM, + NodeCrypto.createHash("sha256").update(secret).digest(), + Buffer.from(ivRaw, "base64url"), + ); + decipher.setAuthTag(Buffer.from(tagRaw, "base64url")); + return Buffer.concat([ + decipher.update(Buffer.from(cipherRaw, "base64url")), + decipher.final(), + ]).toString("utf8"); +}; + +const decodeJsonText = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +interface CredentialStoreCache { + digest: string; + store: Record; +} + +const readDecryptedCredentialStore = Effect.fn("ZcodeRequestAuth.readCredentialStore")(function* ( + fileSystem: FileSystem.FileSystem, + credentialsPath: string, + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, + cache?: CredentialStoreCache, +): Effect.fn.Return> { + const raw = yield* fileSystem.readFileString(credentialsPath).pipe(Effect.option); + if (Option.isNone(raw)) return {}; + // Content, not mtime: a rewrite can land in the same timestamp tick, and a stale login token + // would mint project tokens against the previous account. + const digest = sha256Hex(raw.value); + if (cache !== undefined && cache.digest === digest) return cache.store; + const parsed = Option.getOrUndefined(decodeJsonText(raw.value)); + if (!Predicate.isObject(parsed)) return {}; + const secret = resolveZcodeCredentialSecret(environment, platform); + const entries: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== "string") continue; + const decrypted = yield* Effect.try({ + try: () => decryptZcodeCredential(value, secret), + catch: () => undefined, + }).pipe(Effect.option); + if (Option.isSome(decrypted)) entries[key] = decrypted.value; + } + if (cache !== undefined) { + cache.digest = digest; + cache.store = entries; + } + return entries; +}); + +/** + * Families the provisioning ladder treats as signed-in OAuth individual coding plans: setting.json + * selects the plan, no apiKey override, and the credential store holds a login token for the + * family. The provider status probe derives its auth display from this same predicate so the + * probe and provisioning cannot drift. + */ +export const zcodeOAuthSelectedFamilies = ( + setting: unknown, + store: Record, +): ReadonlySet => { + if (!Predicate.isObject(setting)) return new Set(); + const selections = setting["providerFamilyConnectionSelections"]; + const modes = setting["modelProviderFamilyModes"]; + const families = new Set(); + if (!Predicate.isObject(selections)) return families; + for (const [family, selection] of Object.entries(selections)) { + const kind = Predicate.isObject(selection) ? selection["kind"] : undefined; + if (kind !== "individual-coding-plan") continue; + if (Predicate.isObject(modes) && modes[family] === "apiKey") continue; + const token = store[`oauth:${family}:access_token`]; + if (typeof token !== "string" || token.length === 0) continue; + families.add(family); + } + return families; +}; + +/** Families whose 3.15+ connection is an active OAuth individual coding plan. */ +const readOAuthSelectedFamilies = Effect.fn("ZcodeRequestAuth.readOAuthFamilies")(function* ( + fileSystem: FileSystem.FileSystem, + settingPath: string, + store: Record, +): Effect.fn.Return> { + const raw = yield* fileSystem.readFileString(settingPath).pipe(Effect.option); + const parsed = Option.isNone(raw) ? undefined : Option.getOrUndefined(decodeJsonText(raw.value)); + return zcodeOAuthSelectedFamilies(parsed, store); +}); + +export type ZcodeAccountProviderPush = { + readonly builtinModelIds: ReadonlyArray; + readonly access: { readonly type: "zhipu-account"; readonly entitled: boolean }; +}; + +export type ZcodeAccountStatePush = { + readonly availability: "available" | "unavailable"; + readonly entitled: boolean; + readonly current: boolean; +}; + +export type ZcodeAccountConfigPush = { + readonly revision: string; + readonly basedOnZCodeBuiltinRevision: string; + readonly providers: Record; + readonly states: Record; +}; + +const zcodeRuleAccess = (rule: unknown): ZcodeRecord | undefined => { + const access = zcodeRecordField(zcodeRecordField(rule, "config"), "access"); + return Predicate.isObject(access) ? access : undefined; +}; + +const zcodeBuiltinAccountRules = (builtinCatalog: unknown): ReadonlyArray => { + const providerRules = zcodeRecordArray( + zcodeRecordField(zcodeRecordField(builtinCatalog, "config"), "providerConfigRules"), + "providerRules", + ); + return (providerRules ?? []).filter( + (rule): rule is ZcodeRecord => + Predicate.isObject(rule) && + zcodeRecordString(zcodeRuleAccess(rule), "type") === "zhipu-account", + ); +}; + +const zcodeBuiltinModelIds = (rule: ZcodeRecord): ReadonlyArray => { + const ids = zcodeRecordField(zcodeRecordField(rule, "config"), "builtinModelIds"); + return (Array.isArray(ids) ? ids : []).filter( + (id): id is string => typeof id === "string" && id.length > 0, + ); +}; + +/** The registry keys the snapshot to `zcode-builtin::` of the catalog + * file the CLI booted from; any other value is accepted and silently ignored. */ +const zcodeBuiltinBasedOnRevision = (builtinPath: string, builtinCatalog: unknown): string => { + const revision = zcodeRecordField(builtinCatalog, "revision"); + const revisionText = + typeof revision === "number" && Number.isFinite(revision) + ? String(Math.trunc(revision)) + : typeof revision === "string" && revision.length > 0 + ? revision + : "0"; + return `zcode-builtin:${revisionText}:${sha256Hex(NodePath.resolve(builtinPath))}`; +}; + +/** + * Builds the `provider/updateAccountConfig` push from the builtin catalog's zhipu-account rules + * and the entitlement ladder: a family is entitled only when the user picked an OAuth + * individual coding plan for it and the credential store decrypts to its login token. + */ +export const zcodeAccountConfigPushFromLadder = Effect.fn("ZcodeRequestAuth.buildAccountPush")( + function* ( + fileSystem: FileSystem.FileSystem, + input: { + readonly builtinPath: string; + readonly builtinCatalog: unknown; + readonly environment: NodeJS.ProcessEnv; + }, + ): Effect.fn.Return { + const rules = zcodeBuiltinAccountRules(input.builtinCatalog); + if (rules.length === 0) return undefined; + const root = zcodeDataRoot(input.environment); + if (root === undefined) return undefined; + const store = yield* readDecryptedCredentialStore( + fileSystem, + `${root}/.zcode/v2/credentials.json`, + input.environment, + yield* HostProcess.Platform, + ); + const families = yield* readOAuthSelectedFamilies( + fileSystem, + `${root}/.zcode/v2/setting.json`, + store, + ); + const providers: Record = {}; + const states: Record = {}; + for (const rule of rules) { + const providerId = zcodeRecordString(rule, "providerId"); + const modelIds = zcodeBuiltinModelIds(rule); + if (providerId === undefined || modelIds.length === 0) continue; + const entitled = + zcodeRecordString(zcodeRuleAccess(rule), "mode") === "individual-coding-plan" && + families.has(zcodeRecordString(zcodeRuleAccess(rule), "accountType") ?? ""); + providers[providerId] = { + builtinModelIds: modelIds, + access: { type: "zhipu-account", entitled }, + }; + states[providerId] = { + availability: entitled ? "available" : "unavailable", + entitled, + current: entitled, + }; + } + if (Object.keys(providers).length === 0) return undefined; + return { + revision: `account:t3code:${DateTime.toEpochMillis(yield* DateTime.now)}`, + basedOnZCodeBuiltinRevision: zcodeBuiltinBasedOnRevision( + input.builtinPath, + input.builtinCatalog, + ), + providers, + states, + }; + }, +); + +/** + * Entitled individual-coding-plan models for the model picker: enumerated from the injected + * catalog plus the entitlement ladder, so a catalog refresh by the CLI adds models without an + * adapter change. Reasoning levels come from the catalog's modelRules (modelMatch regexes, + * matched case-insensitively against the model id). + */ +export interface ZcodeEntitledModel { + readonly providerId: string; + readonly modelId: string; + readonly reasoningLevels: ReadonlyArray; +} + +export const zcodeEntitledModels = Effect.fn("ZcodeRequestAuth.entitledModels")(function* ( + fileSystem: FileSystem.FileSystem, + input: { + readonly builtinPath: string; + readonly builtinCatalog: unknown; + readonly environment: NodeJS.ProcessEnv; + }, +): Effect.fn.Return, never, never> { + const rules = zcodeBuiltinAccountRules(input.builtinCatalog); + const root = zcodeDataRoot(input.environment); + if (rules.length === 0 || root === undefined) return []; + const store = yield* readDecryptedCredentialStore( + fileSystem, + `${root}/.zcode/v2/credentials.json`, + input.environment, + yield* HostProcess.Platform, + ); + const families = yield* readOAuthSelectedFamilies( + fileSystem, + `${root}/.zcode/v2/setting.json`, + store, + ); + const modelRules = zcodeRecordArray( + zcodeRecordField(zcodeRecordField(input.builtinCatalog, "config"), "modelConfigRules"), + "modelRules", + ); + const levelsFor = (modelId: string): ReadonlyArray => { + // Later matching rules compose over earlier ones (the runtime's advertised levels are the + // last match: Flash hits glm-5.* then glm-5\.3, landing on [low, high, max]). + let levels: ReadonlyArray = []; + for (const rule of modelRules ?? []) { + const match = zcodeRecordString(rule, "modelMatch"); + if (match === undefined) continue; + let regex: RegExp; + try { + regex = new RegExp(match, "i"); + } catch { + continue; + } + if (!regex.test(modelId)) continue; + const specs = zcodeRecordField(zcodeRecordField(rule, "config"), "optionSpecs"); + const reasoning = zcodeRecordField(Predicate.isObject(specs) ? specs : {}, "reasoningLevel"); + const values = zcodeRecordArray(reasoning, "values")?.filter( + (value): value is string => typeof value === "string", + ); + if (values !== undefined && values.length > 0) levels = values; + } + return levels; + }; + const out: Array = []; + for (const rule of rules) { + const providerId = zcodeRecordString(rule, "providerId"); + const access = zcodeRuleAccess(rule); + if ( + providerId === undefined || + zcodeRecordString(access, "mode") !== "individual-coding-plan" || + !families.has(zcodeRecordString(access, "accountType") ?? "") + ) { + continue; + } + for (const modelId of zcodeBuiltinModelIds(rule)) { + out.push({ providerId, modelId, reasoningLevels: levelsFor(modelId) }); + } + } + return out; +}); + +/** `account:-individual-coding-plan` → the plan family the auth exchange needs. */ +export const zcodeFamilyFromProviderId = (providerId: string | undefined): string | undefined => { + const family = providerId?.replace(/^account:/, "").replace(/-individual-coding-plan$/, ""); + return family === undefined || family.length === 0 ? undefined : family; +}; + +/** Mirrors the CLI's account-scope digest over the plan identity and login tokens. */ +export const zcodeAccountScope = ( + family: string, + loginToken: string, + jwtToken: string | undefined, +): string => + sha256Hex( + zcodeJsonStringify([family, "individual-coding-plan", null, loginToken, jwtToken ?? null]), + ); + +class ZcodeRequestAuthError extends Schema.TaggedError()( + "ZcodeRequestAuthError", + { message: Schema.String }, +) {} + +const declineMessage = (hint: string): string => `ZCode request auth failed (${hint}).`; + +const failAuth = (hint: string) => new ZcodeRequestAuthError({ message: declineMessage(hint) }); + +interface CachedProjectToken { + readonly token: string; + readonly apiKeyId: string; + readonly accountScope: string; + readonly fingerprint: string; + readonly refreshAtMillis: number; +} + +export interface ZcodeRequestAuth { + /** The `interaction/requestProviderRuntimeHeaders` response for an entitled account model. */ + readonly answerRuntimeHeaders: (input: { + readonly family: string; + readonly rejectedProjectTokenFingerprint?: string; + }) => Effect.Effect; +} + +export interface ZcodeRequestAuthOptions { + readonly fileSystem: FileSystem.FileSystem; + readonly httpClient: HttpClient.HttpClient; + readonly environment: NodeJS.ProcessEnv; + /** The installed CLI's version, as its own host reports it; omitted when unknown. */ + readonly clientVersion?: string | undefined; +} + +/** + * Serves OAuth project tokens for entitled individual coding plans, mirroring the desktop + * host's exchange: customer info → personal default project → the account's `zcode-api-key` → + * a short-lived access token. Read-only towards the account; a missing key surfaces as a + * decline the turn reports honestly. + */ +export const makeZcodeRequestAuth = (options: ZcodeRequestAuthOptions): ZcodeRequestAuth => { + const root = zcodeDataRoot(options.environment) ?? ""; + const credentialsPath = `${root}/.zcode/v2/credentials.json`; + const cache = new Map(); + const credentialCache: CredentialStoreCache = { digest: "", store: {} }; + + /** The family's current login with the credential scope digesting it; undefined once gone. */ + const currentLogin = Effect.fn("ZcodeRequestAuth.currentLogin")(function* ( + family: string, + ): Effect.fn.Return<{ loginToken: string; accountScope: string } | undefined> { + const store = yield* readDecryptedCredentialStore( + options.fileSystem, + credentialsPath, + options.environment, + yield* HostProcess.Platform, + credentialCache, + ); + const loginToken = store[`oauth:${family}:access_token`]; + if (loginToken === undefined || loginToken.length === 0) return undefined; + return { + loginToken, + accountScope: zcodeAccountScope(family, loginToken, store[ZCODE_JWT_CREDENTIAL_KEY]), + }; + }); + + const requestJson = (request: HttpClientRequest.HttpClientRequest) => + options.httpClient.execute(request).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.json), + Effect.timeoutOption(REQUEST_TIMEOUT_MS), + Effect.map((answered) => (Option.isSome(answered) ? answered.value : undefined)), + Effect.orElseSucceed(() => undefined), + ); + + const envelopeData = Effect.fnUntraced(function* ( + raw: unknown, + exchange: string, + ): Effect.fn.Return { + const code = Predicate.isObject(raw) ? raw["code"] : undefined; + if (!(code === 0 || code === 200 || code === "0" || code === "200")) { + return yield* failAuth(`${exchange} code=${String(code)}`); + } + return Predicate.isObject(raw) ? raw["data"] : undefined; + }); + + const mint = Effect.fn("ZcodeRequestAuth.mint")(function* ( + family: string, + ): Effect.fn.Return { + const login = yield* currentLogin(family); + if (login === undefined) { + return yield* failAuth("no OAuth login for family"); + } + const origin = FAMILY_ORIGIN[family]; + if (origin === undefined) { + return yield* failAuth(`unknown family ${family}`); + } + const customer = yield* envelopeData( + yield* requestJson( + HttpClientRequest.get(`${origin}/api/biz/customer/getCustomerInfo`, { + headers: { Authorization: login.loginToken }, + }), + ), + "getCustomerInfo", + ); + const organizations: Array<{ + organizationId: string; + organizationName?: string; + projects: Array<{ projectId: string; projectName?: string }>; + }> = []; + const rawOrganizations = Predicate.isObject(customer) ? customer["organizations"] : undefined; + for (const rawOrg of Array.isArray(rawOrganizations) ? rawOrganizations : []) { + if (!Predicate.isObject(rawOrg)) continue; + const organizationId = zcodeRecordString(rawOrg, "organizationId"); + if (organizationId === undefined || organizationId.length === 0) continue; + const projects: Array<{ projectId: string; projectName?: string }> = []; + const rawProjects = zcodeRecordField(rawOrg, "projects"); + for (const rawProject of Array.isArray(rawProjects) ? rawProjects : []) { + if (!Predicate.isObject(rawProject)) continue; + const projectId = zcodeRecordString(rawProject, "projectId"); + if (projectId === undefined || projectId.length === 0) continue; + if (String(zcodeRecordField(rawProject, "projectType") ?? "").trim() === TEAM_PROJECT_TYPE) + continue; + projects.push({ + projectId, + ...(typeof rawProject["projectName"] === "string" + ? { projectName: rawProject["projectName"] } + : {}), + }); + } + if (projects.length === 0) continue; + organizations.push({ + organizationId, + ...(typeof rawOrg["organizationName"] === "string" + ? { organizationName: rawOrg["organizationName"] } + : {}), + projects, + }); + } + const org = + organizations.find((entry) => entry.organizationName?.includes(DEFAULT_ORGANIZATION_NAME)) ?? + organizations[0]; + const project = + org?.projects.find((entry) => entry.projectName?.includes(DEFAULT_PROJECT_NAME)) ?? + org?.projects[0]; + if (org === undefined || project === undefined) { + return yield* failAuth("no personal project"); + } + const keysPath = `${origin}/api/biz/v1/organization/${encodeURIComponent(org.organizationId)}/projects/${encodeURIComponent(project.projectId)}/api_keys`; + const keys = yield* envelopeData( + yield* requestJson( + HttpClientRequest.get(keysPath, { + headers: { + Authorization: family === "zai" ? `Bearer ${login.loginToken}` : login.loginToken, + }, + }), + ), + "api_keys", + ); + const keyList = Array.isArray(keys) ? keys : []; + const key = keyList.find( + (entry) => Predicate.isObject(entry) && entry["name"] === ZCODE_API_KEY_NAME, + ); + const apiKeyId = + Predicate.isObject(key) && typeof key["apiKey"] === "string" ? key["apiKey"] : undefined; + if (apiKeyId === undefined || apiKeyId.length === 0) { + return yield* failAuth("account has no zcode-api-key; open the ZCode app once"); + } + const exchangeRequest = HttpClientRequest.post( + `${keysPath}/${encodeURIComponent(apiKeyId)}/access_tokens`, + { headers: { Authorization: `Bearer ${login.loginToken}` } }, + ).pipe( + HttpClientRequest.bodyText( + zcodeJsonStringify({ + clientType: "zcode", + ...(options.clientVersion === undefined ? {} : { clientVersion: options.clientVersion }), + }), + "application/json", + ), + ); + const exchange = yield* envelopeData(yield* requestJson(exchangeRequest), "access_tokens"); + const token = + Predicate.isObject(exchange) && typeof exchange["accessToken"] === "string" + ? exchange["accessToken"] + : undefined; + if (token === undefined || token.length === 0) { + return yield* failAuth("token exchange returned no token"); + } + const cachedToken: CachedProjectToken = { + token, + apiKeyId, + accountScope: login.accountScope, + fingerprint: sha256Hex(token), + refreshAtMillis: + DateTime.toEpochMillis(yield* DateTime.now) + TOKEN_TTL_MS - TOKEN_REFRESH_MARGIN_MS, + }; + cache.set(family, cachedToken); + return cachedToken; + }); + + const answer = Effect.fn("ZcodeRequestAuth.answer")(function* (input: { + readonly family: string; + readonly rejectedProjectTokenFingerprint?: string; + }): Effect.fn.Return { + const hit = cache.get(input.family); + // A CLI login can swap accounts or log out under the same family; the cached scope digests + // the credentials, so reread them and drop the hit when they no longer match. + const currentScope = + hit === undefined ? undefined : (yield* currentLogin(input.family))?.accountScope; + const cached = + hit !== undefined && + currentScope === hit.accountScope && + DateTime.toEpochMillis(yield* DateTime.now) < hit.refreshAtMillis && + (input.rejectedProjectTokenFingerprint === undefined || + input.rejectedProjectTokenFingerprint !== hit.fingerprint) + ? hit + : undefined; + if (cached !== undefined) { + return { + headersApplied: true, + requestAuth: { + apiKey: cached.token, + apiKeyId: cached.apiKeyId, + accountScope: cached.accountScope, + }, + }; + } + const minted = yield* Effect.result(mint(input.family)); + return Result.isFailure(minted) + ? { headersApplied: false, errorMessage: minted.failure.message } + : { + headersApplied: true, + requestAuth: { + apiKey: minted.success.token, + apiKeyId: minted.success.apiKeyId, + accountScope: minted.success.accountScope, + }, + }; + }); + + return { + answerRuntimeHeaders: (input) => + answer(input).pipe( + // Any defect here would strand the CLI's request; decline instead. + Effect.catchCause((cause) => + Effect.succeed({ + headersApplied: false, + errorMessage: declineMessage(causeErrorTag(cause)), + } satisfies ZcodeRecord), + ), + ), + }; +}; diff --git a/packages/provider-zcode/src/server/zcodeRuntime.test.ts b/packages/provider-zcode/src/server/zcodeRuntime.test.ts new file mode 100644 index 0000000000000..b01f3c69e982e --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeRuntime.test.ts @@ -0,0 +1,595 @@ +// @effect-diagnostics nodeBuiltinImport:off -- tests craft the CLI's own AES-GCM store values with node crypto +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { assert, describe, it } from "@effect/vitest"; +import * as NodeCrypto from "node:crypto"; +import * as NodeOS from "node:os"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import { + resolveZcodeCredentialSecret, + zcodeAccountConfigPushFromLadder, +} from "./zcodeRequestAuth.ts"; +import { + resolveZcodeCatalogFiles, + readZcodeAppVersion, + resolveZcodeLaunchCommand, + zcodeBuiltinCatalogPath, + zcodeBundledCatalogPath, + zcodeBaseUrl, + zcodeDataRoot, + zcodeModelSelectionFromSlug, + zcodePersonalProviderConfigPath, + zcodePlatformDir, + zcodeRuntimeEnvironment, + zcodeSpawnArgs, +} from "./zcodeRuntime.ts"; + +// Verified against the on-disk zcode 3.17.2 runtime catalog directory for https://zcode.z.ai. +const ZCODE_ENDPOINT_HASH = "78d7c3bef4024722642626fe3669a799"; + +const inlineCatalog = { + schemaVersion: 1, + revision: 43, + config: { + providerConfigRules: { + templateRules: [ + { + templateId: "zai-api", + config: { access: { type: "api-key" }, builtinModelIds: ["GLM-5.3"] }, + }, + ], + providerRules: [ + { + providerId: "account:zai-individual-coding-plan", + config: { + access: { type: "zhipu-account", mode: "individual-coding-plan", accountType: "zai" }, + builtinModelIds: ["GLM-5.3", "GLM-5.3-Flash"], + }, + }, + { + providerId: "account:zai-team-coding-plan", + config: { + access: { type: "zhipu-account", mode: "team-coding-plan", accountType: "zai" }, + builtinModelIds: ["GLM-5.3"], + }, + }, + { + providerId: "account:bigmodel-individual-coding-plan", + config: { + access: { + type: "zhipu-account", + mode: "individual-coding-plan", + accountType: "bigmodel", + }, + builtinModelIds: ["GLM-5.3"], + }, + }, + { + providerId: "account:start-individual-coding-plan", + config: { + access: { type: "zhipu-account", mode: "individual-coding-plan", accountType: "start" }, + builtinModelIds: ["GLM-5.3-Flash"], + }, + }, + { + providerId: "kimi-api", + config: { access: { type: "api-key" }, builtinModelIds: ["kimi-k3"] }, + }, + { + providerId: "broken-empty-models", + config: { access: { type: "zhipu-account" }, builtinModelIds: [] }, + }, + ], + }, + }, +}; + +/** Produces store values the CLI's fallback cipher would hold, keyed the same way. */ +const encryptZcodeCredential = (value: string, secret: string): string => { + const iv = NodeCrypto.randomBytes(12); + const cipher = NodeCrypto.createCipheriv( + "aes-256-gcm", + NodeCrypto.createHash("sha256").update(secret).digest(), + iv, + ); + const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]); + return `enc:v1:${iv.toString("base64url")}.${cipher.getAuthTag().toString("base64url")}.${ciphertext.toString("base64url")}`; +}; + +describe("zcodePlatformDir", () => { + it("maps zcode's own platform spellings", () => { + assert.equal(zcodePlatformDir("linux", "x64"), "linux-x86_64"); + assert.equal(zcodePlatformDir("darwin", "x64"), "darwin-x64"); + assert.equal(zcodePlatformDir("darwin", "arm64"), "darwin-arm64"); + assert.equal(zcodePlatformDir("win32", "x64"), "win32-x64"); + }); + + it("returns undefined where zcode has no runtime catalog", () => { + assert.equal(zcodePlatformDir("linux", "arm64"), undefined); + assert.equal(zcodePlatformDir("win32", "arm64"), undefined); + assert.equal(zcodePlatformDir("freebsd", "x64"), undefined); + }); +}); + +describe("zcodeBuiltinCatalogPath", () => { + it("keys the CDN-refreshed catalog on the endpoint-origin sha256 prefix", () => { + assert.equal( + zcodeBuiltinCatalogPath({ + root: "/home/dev", + appVersion: "3.17.2", + platform: "linux", + arch: "x64", + }), + `/home/dev/.zcode/v2/runtime/provider/linux-x86_64/3.17.2/endpoint-${ZCODE_ENDPOINT_HASH}/zcode-builtin.json`, + ); + assert.equal( + zcodeBuiltinCatalogPath({ + root: "C:\\d", + appVersion: "3.17.2", + platform: "win32", + arch: "x64", + }), + `C:\\d/.zcode/v2/runtime/provider/win32-x64/3.17.2/endpoint-${ZCODE_ENDPOINT_HASH}/zcode-builtin.json`, + ); + }); + + it("keys an overridden endpoint's catalog on its normalized origin", () => { + const originHash = NodeCrypto.createHash("sha256") + .update("https://proxy.example") + .digest("hex") + .slice(0, 32); + assert.equal( + zcodeBuiltinCatalogPath({ + root: "/home/dev", + appVersion: "3.17.2", + platform: "linux", + arch: "x64", + baseUrl: "https://proxy.example/team/", + }), + `/home/dev/.zcode/v2/runtime/provider/linux-x86_64/3.17.2/endpoint-${originHash}/zcode-builtin.json`, + ); + }); + + it("derives the bundled and personal paths from the data root", () => { + assert.equal( + zcodeBundledCatalogPath("/home/dev"), + "/home/dev/.zcode/v2/runtime/provider/bundled/zcode-builtin.json", + ); + assert.equal( + zcodePersonalProviderConfigPath("/home/dev"), + "/home/dev/.zcode/v2/provider_config.json", + ); + assert.equal(zcodeDataRoot({ HOME: "/home/dev" }), "/home/dev"); + assert.equal(zcodeDataRoot({ ZCODE_DATA_BASE_DIR: " /tmp/z ", HOME: "/home/dev" }), "/tmp/z"); + // Windows runs without HOME or ZCODE_DATA_BASE_DIR; the OS home keeps catalog discovery alive. + assert.equal(zcodeDataRoot({}), NodeOS.homedir()); + assert.equal(zcodeDataRoot({ ZCODE_DATA_BASE_DIR: " ", HOME: "" }), NodeOS.homedir()); + }); +}); + +describe("zcodeSpawnArgs", () => { + it("launches app-server --stdio with launch args split on whitespace", () => { + assert.deepEqual(zcodeSpawnArgs(""), ["app-server", "--stdio"]); + assert.deepEqual(zcodeSpawnArgs("--verbose --trace file "), [ + "app-server", + "--stdio", + "--verbose", + "--trace", + "file", + ]); + }); +}); + +describe("resolveZcodeLaunchCommand", () => { + it.effect("explicit binaryPath wins over every fallback", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const command = yield* resolveZcodeLaunchCommand( + { binaryPath: "/opt/bin/zcode" }, + { HOME: "/home/dev" }, + fileSystem, + ); + assert.equal(command, "/opt/bin/zcode"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("falls back to the desktop shim when zcode is off PATH", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectory(); + yield* fileSystem.makeDirectory(`${home}/.zcode/server/bin`, { recursive: true }); + yield* fileSystem.writeFileString(`${home}/.zcode/server/bin/zcode`, "#!/bin/sh\n"); + // binaryPath decodes to the bare fallback when the user leaves the field empty. + const command = yield* resolveZcodeLaunchCommand( + { binaryPath: "zcode" }, + { HOME: home, PATH: "/nonexistent" }, + fileSystem, + ); + assert.equal(command, `${home}/.zcode/server/bin/zcode`); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("probes the Windows desktop shim as zcode.cmd when zcode is off PATH", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectory(); + yield* fileSystem.makeDirectory(`${home}/.zcode/server/bin`, { recursive: true }); + yield* fileSystem.writeFileString(`${home}/.zcode/server/bin/zcode.cmd`, "@echo off\n"); + const command = yield* resolveZcodeLaunchCommand( + { binaryPath: "zcode" }, + { HOME: home, PATH: "C:\\nonexistent" }, + fileSystem, + ); + assert.equal(command, `${home}/.zcode/server/bin/zcode.cmd`); + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect( + "keeps the plain binary name on Windows when the shim dir only holds a bare zcode", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectory(); + yield* fileSystem.makeDirectory(`${home}/.zcode/server/bin`, { recursive: true }); + yield* fileSystem.writeFileString(`${home}/.zcode/server/bin/zcode`, "#!/bin/sh\n"); + const command = yield* resolveZcodeLaunchCommand( + { binaryPath: "" }, + { HOME: home, PATH: "C:\\nonexistent" }, + fileSystem, + ); + assert.equal(command, "zcode"); + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("keeps the plain binary name when no fallback exists", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const command = yield* resolveZcodeLaunchCommand( + { binaryPath: "" }, + { HOME: "/nonexistent-home", PATH: "/nonexistent" }, + fileSystem, + ); + assert.equal(command, "zcode"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("prefers a colon-separated PATH hit over the desktop shim on POSIX", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectory(); + yield* fileSystem.makeDirectory(`${home}/.zcode/server/bin`, { recursive: true }); + yield* fileSystem.writeFileString(`${home}/.zcode/server/bin/zcode`, "#!/bin/sh\n"); + const onPath = yield* fileSystem.makeTempDirectory(); + yield* fileSystem.writeFileString(`${onPath}/zcode`, "#!/bin/sh\n"); + const command = yield* resolveZcodeLaunchCommand( + { binaryPath: "zcode" }, + { HOME: home, PATH: `${onPath}:/nonexistent` }, + fileSystem, + ); + assert.equal(command, "zcode"); + }).pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("splits PATH on ; on Windows so a PATH install beats the desktop shim", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectory(); + yield* fileSystem.makeDirectory(`${home}/.zcode/server/bin`, { recursive: true }); + yield* fileSystem.writeFileString(`${home}/.zcode/server/bin/zcode`, "#!/bin/sh\n"); + const onPath = yield* fileSystem.makeTempDirectory(); + yield* fileSystem.writeFileString(`${onPath}/zcode.exe`, "binary"); + const command = yield* resolveZcodeLaunchCommand( + { binaryPath: "zcode" }, + { HOME: home, PATH: `C:\\nonexistent;${onPath}` }, + fileSystem, + ); + assert.equal(command, "zcode"); + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provide(NodeServices.layer), + ), + ); +}); + +describe("zcodeRuntimeEnvironment", () => { + it("always points the CLI at the endpoint origin", () => { + const env = zcodeRuntimeEnvironment({ HOME: "/home/dev", KEEP: "1" }, {}); + assert.equal(env.ZCODE_BASE_URL, "https://zcode.z.ai"); + assert.equal(env.KEEP, "1"); + assert.equal(env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE, undefined); + assert.equal(env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE, undefined); + }); + + it("honors a configured endpoint and normalizes it to an origin", () => { + const env = zcodeRuntimeEnvironment( + { HOME: "/home/dev", ZCODE_BASE_URL: " https://proxy.example/team/ " }, + {}, + ); + assert.equal(env.ZCODE_BASE_URL, "https://proxy.example"); + // Unparseable values keep their text so the catalog lookup fails honestly. + assert.equal(zcodeBaseUrl({ ZCODE_BASE_URL: "not a url" }), "not a url"); + }); + + it("emits catalog env vars only for files that exist", () => { + const env = zcodeRuntimeEnvironment( + {}, + { + builtinPath: "/data/builtin.json", + bundledPath: "/data/bundled.json", + personalPath: "/data/personal.json", + }, + ); + assert.equal(env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE, "/data/builtin.json"); + assert.equal(env.ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE, "/data/bundled.json"); + assert.equal(env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE, "/data/personal.json"); + }); + + it("drops the bundled var and the paired personal var when those files are missing", () => { + const env = zcodeRuntimeEnvironment({}, { builtinPath: "/data/builtin.json" }); + assert.equal(env.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE, "/data/builtin.json"); + assert.equal(env.ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE, undefined); + assert.equal(env.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE, undefined); + }); +}); + +describe("zcodeAccountConfigPushFromLadder", () => { + it.effect( + "keys the push to the catalog path hash and maps the entitlement ladder onto providers and states", + () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-ladder-test-" }); + const platform = yield* HostProcess.Platform; + const environment = { ZCODE_DATA_BASE_DIR: root, HOME: "/home/dev", USER: "dev" }; + yield* fs.makeDirectory(`${root}/.zcode/v2`, { recursive: true }); + yield* fs.writeFileString( + `${root}/.zcode/v2/setting.json`, + JSON.stringify({ + providerFamilyConnectionSelections: { + zai: { kind: "individual-coding-plan" }, + bigmodel: { kind: "individual-coding-plan" }, + start: { kind: "individual-coding-plan" }, + }, + modelProviderFamilyModes: { bigmodel: "apiKey" }, + }), + ); + yield* fs.writeFileString( + `${root}/.zcode/v2/credentials.json`, + JSON.stringify({ + "oauth:zai:access_token": encryptZcodeCredential( + "zai-login-token", + resolveZcodeCredentialSecret(environment, platform), + ), + }), + ); + const builtinPath = `${root}/catalog/zcode-builtin.json`; + const push = yield* zcodeAccountConfigPushFromLadder(fs, { + builtinPath, + builtinCatalog: inlineCatalog, + environment, + }); + assert.isDefined(push); + // builtinPath is absolute, so the CLI-side path.resolve is a no-op here. + assert.equal( + push!.basedOnZCodeBuiltinRevision, + `zcode-builtin:43:${NodeCrypto.createHash("sha256").update(builtinPath).digest("hex")}`, + ); + assert.equal(push!.revision.startsWith("account:t3code:"), true); + assert.deepEqual(Object.keys(push!.providers), [ + "account:zai-individual-coding-plan", + "account:zai-team-coding-plan", + "account:bigmodel-individual-coding-plan", + "account:start-individual-coding-plan", + ]); + // OAuth selection + decrypted login token + plan-mode rule → entitled. + assert.deepEqual(push!.providers["account:zai-individual-coding-plan"], { + builtinModelIds: ["GLM-5.3", "GLM-5.3-Flash"], + access: { type: "zhipu-account", entitled: true }, + }); + assert.deepEqual(push!.states["account:zai-individual-coding-plan"], { + availability: "available", + entitled: true, + current: true, + }); + // Team-plan rules never entitle. + assert.equal(push!.states["account:zai-team-coding-plan"]!.availability, "unavailable"); + assert.equal(push!.states["account:zai-team-coding-plan"]!.entitled, false); + assert.equal(push!.states["account:zai-team-coding-plan"]!.current, false); + // A family switched to apiKey mode is excluded even with a selection. + assert.equal( + push!.states["account:bigmodel-individual-coding-plan"]!.availability, + "unavailable", + ); + // A selected family without a decryptable login token stays unentitled. + assert.equal(push!.states["account:start-individual-coding-plan"]!.entitled, false); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("returns undefined without a usable catalog", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const noRules = yield* zcodeAccountConfigPushFromLadder(fs, { + builtinPath: "/tmp/builtin.json", + builtinCatalog: { config: { providerConfigRules: { providerRules: [] } } }, + environment: { HOME: "/home/dev" }, + }); + assert.equal(noRules, undefined); + const apiOnly = yield* zcodeAccountConfigPushFromLadder(fs, { + builtinPath: "/tmp/builtin.json", + builtinCatalog: { + config: { + providerConfigRules: { + providerRules: [ + { providerId: "kimi-api", config: { access: { type: "api-key" } } }, + ], + }, + }, + }, + environment: { HOME: "/home/dev" }, + }); + assert.equal(apiOnly, undefined); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); +}); + +describe("zcodeModelSelectionFromSlug", () => { + it("defers default slugs to the CLI's own default", () => { + assert.equal(zcodeModelSelectionFromSlug("default"), undefined); + assert.equal(zcodeModelSelectionFromSlug("default$high"), undefined); + assert.equal(zcodeModelSelectionFromSlug(""), undefined); + }); + + it("maps account provider slugs to zcode model selections", () => { + assert.deepEqual(zcodeModelSelectionFromSlug("account:zai-individual-coding-plan/GLM-5.3"), { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3", + }); + assert.deepEqual( + zcodeModelSelectionFromSlug("account:zai-individual-coding-plan/GLM-5.3-Flash$high"), + { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3-Flash", + options: { reasoningLevel: "high" }, + }, + ); + }); + + it("rejects slugs outside the account: provider namespace", () => { + assert.equal(zcodeModelSelectionFromSlug("openai/gpt-5"), undefined); + assert.equal(zcodeModelSelectionFromSlug("/GLM-5.3"), undefined); + assert.equal(zcodeModelSelectionFromSlug("account:zai-individual-coding-plan/"), undefined); + assert.equal(zcodeModelSelectionFromSlug("no-slash"), undefined); + }); +}); + +it.layer(NodeServices.layer)("ZcodeRuntime catalog resolution", (it) => { + it.effect("reads the app version and emits only existing catalog files", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-runtime-test-" }); + const platformDir = zcodePlatformDir( + yield* HostProcess.Platform, + yield* HostProcess.Architecture, + ); + const currentPath = `${root}/.zcode/server/current.json`; + const personalPath = zcodePersonalProviderConfigPath(root); + + // No server/current.json yet: only the personal file resolves. + yield* fs.makeDirectory(`${root}/.zcode/v2`, { recursive: true }); + yield* fs.makeDirectory(`${root}/.zcode/server`, { recursive: true }); + yield* fs.writeFileString(personalPath, "{}"); + const withoutVersion = yield* resolveZcodeCatalogFiles(fs, { + ZCODE_DATA_BASE_DIR: root, + }); + assert.equal(withoutVersion.builtinPath, undefined); + assert.equal(withoutVersion.personalPath, personalPath); + + if (platformDir === undefined) return; + const builtinDir = `${root}/.zcode/v2/runtime/provider/${platformDir}/3.17.1/endpoint-${ZCODE_ENDPOINT_HASH}`; + yield* fs.makeDirectory(builtinDir, { recursive: true }); + yield* fs.writeFileString(currentPath, '{"version":"3.17.1"}'); + yield* fs.writeFileString( + `${builtinDir}/zcode-builtin.json`, + JSON.stringify(inlineCatalog), + ); + const resolved = yield* resolveZcodeCatalogFiles(fs, { ZCODE_DATA_BASE_DIR: root }); + assert.equal(resolved.builtinPath, `${builtinDir}/zcode-builtin.json`); + assert.equal(resolved.bundledPath, undefined); + assert.equal(resolved.personalPath, personalPath); + // No credentials or setting selection in this root: every rule pushes unentitled. + const push = yield* zcodeAccountConfigPushFromLadder(fs, { + builtinPath: resolved.builtinPath!, + builtinCatalog: resolved.builtinCatalog, + environment: { ZCODE_DATA_BASE_DIR: root }, + }); + assert.deepEqual(Object.keys(push?.providers ?? {}), [ + "account:zai-individual-coding-plan", + "account:zai-team-coding-plan", + "account:bigmodel-individual-coding-plan", + "account:start-individual-coding-plan", + ]); + assert.equal(push?.states["account:zai-individual-coding-plan"]?.entitled, false); + }), + ), + ); + + it.effect("resolves the catalog under the overridden endpoint directory", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-runtime-test-" }); + const platformDir = zcodePlatformDir( + yield* HostProcess.Platform, + yield* HostProcess.Architecture, + ); + if (platformDir === undefined) return; + const origin = "https://proxy.example"; + const endpointHash = NodeCrypto.createHash("sha256") + .update(origin) + .digest("hex") + .slice(0, 32); + yield* fs.makeDirectory(`${root}/.zcode/server`, { recursive: true }); + yield* fs.writeFileString(`${root}/.zcode/server/current.json`, '{"version":"3.17.1"}'); + const builtinDir = `${root}/.zcode/v2/runtime/provider/${platformDir}/3.17.1/endpoint-${endpointHash}`; + yield* fs.makeDirectory(builtinDir, { recursive: true }); + yield* fs.writeFileString( + `${builtinDir}/zcode-builtin.json`, + JSON.stringify(inlineCatalog), + ); + const resolved = yield* resolveZcodeCatalogFiles(fs, { + ZCODE_DATA_BASE_DIR: root, + ZCODE_BASE_URL: origin, + }); + assert.equal(resolved.builtinPath, `${builtinDir}/zcode-builtin.json`); + }), + ), + ); + + it.effect("keeps an unreadable current.json from producing catalog env vars", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-runtime-test-" }); + yield* fs.makeDirectory(`${root}/.zcode/server`, { recursive: true }); + yield* fs.writeFileString(`${root}/.zcode/server/current.json`, "not-json"); + const resolved = yield* resolveZcodeCatalogFiles(fs, { ZCODE_DATA_BASE_DIR: root }); + assert.equal(resolved.builtinPath, undefined); + assert.equal(resolved.personalPath, undefined); + }), + ), + ); + + it.effect("readZcodeAppVersion returns the pinned server version", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-runtime-test-" }); + yield* fs.makeDirectory(`${root}/.zcode/server`, { recursive: true }); + yield* fs.writeFileString( + `${root}/.zcode/server/current.json`, + '{"version":"3.17.2","releaseDir":"/x"}', + ); + const version = yield* readZcodeAppVersion(fs, `${root}/.zcode/server/current.json`); + assert.equal(version, "3.17.2"); + const missing = yield* readZcodeAppVersion(fs, `${root}/missing.json`); + assert.equal(missing, undefined); + }), + ), + ); +}); diff --git a/packages/provider-zcode/src/server/zcodeRuntime.ts b/packages/provider-zcode/src/server/zcodeRuntime.ts new file mode 100644 index 0000000000000..e4418499e21af --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeRuntime.ts @@ -0,0 +1,253 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Catalog paths key on sha256 of the endpoint origin; Effect's crypto service adds a dependency the driver does not carry. +// Provisioning for `zcode app-server --stdio`: spawn args, catalog-backed environment, the +// account config push that makes catalog models selectable, and T3 model slug mapping. +// Everything protocol-shaped here mirrors the Phase 0 probe notes (PROTOCOL-NOTES.md). +import * as NodeCrypto from "node:crypto"; +import * as NodeOS from "node:os"; + +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const ZCODE_BASE_URL = "https://zcode.z.ai"; + +const DEFAULT_ZCODE_BINARY = "zcode"; + +/** The configured endpoint, normalized to an origin exactly like the CLI's cache key. */ +const normalizeZcodeEndpoint = (raw: string | undefined): string => { + const trimmed = raw?.trim(); + if (trimmed === undefined || trimmed.length === 0) return ZCODE_BASE_URL; + try { + return new URL(trimmed).origin; + } catch { + return trimmed; + } +}; + +export const zcodeBaseUrl = (environment: NodeJS.ProcessEnv): string => + normalizeZcodeEndpoint(environment.ZCODE_BASE_URL); + +/** Wire shape of `model` in session/create and `modelSelection` in session/send. */ +export interface ZcodeModelSelection { + readonly providerId: string; + readonly modelId: string; + readonly options?: { readonly reasoningLevel?: string }; +} + +export interface ZcodeCatalogFileSet { + readonly builtinPath?: string; + readonly bundledPath?: string; + readonly personalPath?: string; + /** Parsed contents of `builtinPath`; the account config push is built from it. */ + readonly builtinCatalog?: unknown; +} + +const ZCODE_ENDPOINT_HASH_LENGTH = 32; + +/** The CLI keys its endpoint-scoped catalog directory on sha256 of the endpoint origin. */ +const zcodeEndpointHash = (baseUrl: string): string => + NodeCrypto.createHash("sha256") + .update(baseUrl) + .digest("hex") + .slice(0, ZCODE_ENDPOINT_HASH_LENGTH); + +/** zcode stores CDN-refreshed catalogs under its own platform spelling, not process.platform. */ +export function zcodePlatformDir(platform: NodeJS.Platform, arch: string): string | undefined { + switch (platform) { + case "darwin": + return arch === "arm64" ? "darwin-arm64" : "darwin-x64"; + case "linux": + return arch === "x64" ? "linux-x86_64" : undefined; + case "win32": + return arch === "x64" ? "win32-x64" : undefined; + default: + return undefined; + } +} + +export function zcodeDataRoot(environment: NodeJS.ProcessEnv): string | undefined { + // Windows often runs without HOME, so os.homedir() (USERPROFILE there) keeps catalog + // discovery and the shim lookup alive. + const root = environment.ZCODE_DATA_BASE_DIR?.trim() || environment.HOME || NodeOS.homedir(); + return root.length === 0 ? undefined : root; +} + +export function zcodeBuiltinCatalogPath(input: { + readonly root: string; + readonly appVersion: string; + readonly platform: NodeJS.Platform; + readonly arch: string; + /** Endpoint origin the CLI would normalize; defaults to the production endpoint. */ + readonly baseUrl?: string; +}): string | undefined { + const platformDir = zcodePlatformDir(input.platform, input.arch); + if (platformDir === undefined) return undefined; + return `${input.root}/.zcode/v2/runtime/provider/${platformDir}/${input.appVersion}/endpoint-${zcodeEndpointHash(normalizeZcodeEndpoint(input.baseUrl))}/zcode-builtin.json`; +} + +export function zcodeBundledCatalogPath(root: string): string { + return `${root}/.zcode/v2/runtime/provider/bundled/zcode-builtin.json`; +} + +export function zcodePersonalProviderConfigPath(root: string): string { + return `${root}/.zcode/v2/provider_config.json`; +} + +const decodeServerCurrent = Schema.decodeUnknownOption( + Schema.Struct({ version: Schema.optional(Schema.String) }), +); + +const decodeVersionField = (entry: { readonly version?: string | undefined }) => + entry.version === undefined ? Option.none() : Option.some(entry.version); + +const decodeJsonText = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +/** Reads the appVersion the CLI pins its runtime catalog to from `/.zcode/server/current.json`. */ +export const readZcodeAppVersion = Effect.fn("ZcodeRuntime.readAppVersion")(function* ( + fileSystem: FileSystem.FileSystem, + path: string, +) { + const raw = yield* fileSystem.readFileString(path).pipe(Effect.option); + if (Option.isNone(raw)) return undefined; + const current = Option.flatMap(decodeJsonText(raw.value), decodeServerCurrent); + return Option.getOrUndefined(Option.flatMap(current, decodeVersionField)); +}); + +const catalogFileExists = Effect.fn("ZcodeRuntime.catalogFileExists")(function* ( + fileSystem: FileSystem.FileSystem, + path: string, +) { + return yield* fileSystem.exists(path).pipe(Effect.orElseSucceed(() => false)); +}); + +/** + * Resolves the catalog files the CLI needs through env vars. Each var is set only when its file + * exists: the CLI fails closed on dangling paths, and with none set the adapter reports models + * honestly unavailable instead of pretending success. + */ +export const resolveZcodeCatalogFiles = Effect.fn("ZcodeRuntime.resolveCatalogFiles")(function* ( + fileSystem: FileSystem.FileSystem, + environment: NodeJS.ProcessEnv, +) { + const root = zcodeDataRoot(environment); + if (root === undefined) return {}; + const result: { + builtinPath?: string; + bundledPath?: string; + personalPath?: string; + builtinCatalog?: unknown; + } = {}; + const appVersion = yield* readZcodeAppVersion(fileSystem, `${root}/.zcode/server/current.json`); + if (appVersion !== undefined) { + const builtinPath = zcodeBuiltinCatalogPath({ + root, + appVersion, + platform: yield* HostProcess.Platform, + arch: yield* HostProcess.Architecture, + baseUrl: zcodeBaseUrl(environment), + }); + if (builtinPath !== undefined && (yield* catalogFileExists(fileSystem, builtinPath))) { + result.builtinPath = builtinPath; + const bundledPath = zcodeBundledCatalogPath(root); + if (yield* catalogFileExists(fileSystem, bundledPath)) result.bundledPath = bundledPath; + const raw = yield* fileSystem.readFileString(builtinPath).pipe(Effect.option); + if (Option.isSome(raw)) { + result.builtinCatalog = Option.getOrUndefined(decodeJsonText(raw.value)); + } + } + } + const personalPath = zcodePersonalProviderConfigPath(root); + if (yield* catalogFileExists(fileSystem, personalPath)) result.personalPath = personalPath; + return result; +}); + +/** + * The CLI requires ZCODE_BUILTIN_PROVIDER_CONFIG_FILE and ZCODE_PERSONAL_PROVIDER_CONFIG_FILE + * together; a builtin var without the personal one fails session startup, so the personal var + * is dropped when its file is missing. ZCODE_BASE_URL stays overridable: its normalized origin + * also keys the catalog path, so a proxy endpoint resolves the matching catalog directory. + */ +export function zcodeRuntimeEnvironment( + environment: NodeJS.ProcessEnv, + files: ZcodeCatalogFileSet, +): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = { ...environment, ZCODE_BASE_URL: zcodeBaseUrl(environment) }; + if (files.builtinPath !== undefined) { + next.ZCODE_BUILTIN_PROVIDER_CONFIG_FILE = files.builtinPath; + if (files.bundledPath !== undefined) { + next.ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE = files.bundledPath; + } + if (files.personalPath !== undefined) { + next.ZCODE_PERSONAL_PROVIDER_CONFIG_FILE = files.personalPath; + } + } + return next; +} + +/** + * T3 model slug → zcode ModelSelection. "default" and "default$" defer to the CLI's own + * default (undefined means omit `model`), and "/$" maps to the + * account provider ids the catalog carries. + */ +export function zcodeModelSelectionFromSlug(slug: string): ZcodeModelSelection | undefined { + const trimmed = slug.trim(); + if (trimmed.length === 0 || trimmed === "default") return undefined; + const separator = trimmed.indexOf("/"); + if (separator <= 0) return undefined; + const providerId = trimmed.slice(0, separator); + if (!providerId.startsWith("account:")) return undefined; + const rest = trimmed.slice(separator + 1); + if (rest.length === 0) return undefined; + const dollar = rest.indexOf("$"); + if (dollar < 0) return { providerId, modelId: rest }; + const modelId = rest.slice(0, dollar); + const reasoningLevel = rest.slice(dollar + 1); + if (modelId.length === 0 || reasoningLevel.length === 0) return undefined; + return { providerId, modelId, options: { reasoningLevel } }; +} + +export function zcodeSpawnArgs(launchArgs: string): ReadonlyArray { + return ["app-server", "--stdio", ...launchArgs.split(/\s+/).filter((arg) => arg.length > 0)]; +} + +/** + * Falls back to the desktop shim (`~/.zcode/server/bin/zcode`, `zcode.cmd` on Windows) when PATH + * lacks `zcode`; the bare "zcode" is the schema's decoded default, not an explicit choice. PATH is + * probed manually: resolveSpawnCommand is a no-op on POSIX and defers to exec. + */ +export const resolveZcodeLaunchCommand = Effect.fn("ZcodeRuntime.resolveLaunchCommand")(function* ( + settings: { readonly binaryPath: string }, + environment: NodeJS.ProcessEnv, + fileSystem: FileSystem.FileSystem, +) { + const explicit = settings.binaryPath.trim(); + if (explicit.length > 0 && explicit !== DEFAULT_ZCODE_BINARY) return explicit; + const onPath = yield* zcodeOnPath(environment, fileSystem); + if (onPath) return DEFAULT_ZCODE_BINARY; + const home = zcodeDataRoot(environment); + if (home === undefined) return DEFAULT_ZCODE_BINARY; + const desktopShim = `${home}/.zcode/server/bin/${(yield* HostProcess.Platform) === "win32" ? "zcode.cmd" : "zcode"}`; + const shimExists = yield* fileSystem.exists(desktopShim).pipe(Effect.orElseSucceed(() => false)); + return shimExists ? desktopShim : DEFAULT_ZCODE_BINARY; +}); + +const zcodeOnPath = Effect.fn("ZcodeRuntime.zcodeOnPath")(function* ( + environment: NodeJS.ProcessEnv, + fileSystem: FileSystem.FileSystem, +) { + const pathValue = environment.PATH ?? ""; + const platform = yield* HostProcess.Platform; + const suffixes = platform === "win32" ? ["zcode.exe", "zcode.cmd"] : ["zcode"]; + for (const dir of pathValue.split(platform === "win32" ? ";" : ":")) { + if (dir === "") continue; + for (const suffix of suffixes) { + const found = yield* fileSystem + .exists(`${dir}/${suffix}`) + .pipe(Effect.orElseSucceed(() => false)); + if (found) return true; + } + } + return false; +}); diff --git a/packages/provider-zcode/src/settings.ts b/packages/provider-zcode/src/settings.ts new file mode 100644 index 0000000000000..824acf95f7c13 --- /dev/null +++ b/packages/provider-zcode/src/settings.ts @@ -0,0 +1,41 @@ +// Shared with the client settings form; keep schema code browser-safe. +import { + CustomModelSetting, + makeBinaryPathSetting, + makeProviderSettingsSchema, + TrimmedString, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const ZcodeSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("zcode").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the ZCode CLI binary.", + providerSettingsForm: { placeholder: "zcode", clearWhenEmpty: "omit" }, + }), + ), + launchArgs: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Launch arguments", + description: "Additional CLI arguments passed to zcode app-server on session start.", + providerSettingsForm: { clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "launchArgs"], + }, +); +export type ZcodeSettings = typeof ZcodeSettings.Type; diff --git a/packages/provider-zcode/src/testing.ts b/packages/provider-zcode/src/testing.ts new file mode 100644 index 0000000000000..9f8c2d5e1d8a5 --- /dev/null +++ b/packages/provider-zcode/src/testing.ts @@ -0,0 +1,13 @@ +export { + ZCODE_PROVIDER, + makeZcodeAdapterV2, + type ZcodeAdapterV2Options, +} from "./server/adapter.ts"; +// Wire-shape types for frame-driven testkits; knip tracks types separately from consumed exports. +export type { + ZcodeConnection, + ZcodeProtocolError, + ZcodeProtocolTimeoutError, + ZcodeRecord, + ZcodeSpawnOptions, +} from "./server/zcodeProtocol.ts"; diff --git a/packages/provider-zcode/tsconfig.json b/packages/provider-zcode/tsconfig.json new file mode 100644 index 0000000000000..374bac55202dd --- /dev/null +++ b/packages/provider-zcode/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6451f3f97cb29..7aac9d29d61fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -869,6 +869,9 @@ importers: '@t3tools/provider-pi': specifier: workspace:* version: link:../../packages/provider-pi + '@t3tools/provider-zcode': + specifier: workspace:* + version: link:../../packages/provider-zcode '@t3tools/source-control-azure-devops': specifier: workspace:* version: link:../../packages/source-control-azure-devops @@ -1502,6 +1505,9 @@ importers: '@t3tools/provider-pi': specifier: workspace:* version: link:../provider-pi + '@t3tools/provider-zcode': + specifier: workspace:* + version: link:../provider-zcode '@t3tools/secret-provider-definitions': specifier: workspace:* version: link:../secret-provider-definitions @@ -1962,6 +1968,37 @@ importers: specifier: 24.12.4 version: 24.12.4 + packages/provider-zcode: + dependencies: + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.1.2 + version: 4.1.2 + devDependencies: + '@effect/platform-node': + specifier: 4.1.2 + version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.1.2 + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/provider-testing': + specifier: workspace:* + version: link:../provider-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.4.0 + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/secret-provider-core: dependencies: '@t3tools/contracts': diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 9a97563e29de0..3d2435dc4ab74 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -40,6 +40,7 @@ const workspaceFiles = [ "packages/provider-kiro/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json", + "packages/provider-zcode/package.json", "packages/secret-provider-core/package.json", "packages/secret-provider-definitions/package.json", "packages/secret-provider-onepassword/package.json",