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..47a0195506d96 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.test.ts @@ -0,0 +1,810 @@ +// 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, + 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); + const resumedThread = 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", + }); + + // A resumed session advertises its models only through session/subscribe; the next turn + // still needs the advertised reasoning level. + yield* resumed.startTurn( + yield* makeTurnInput( + resumedThread, + TURN_TEXT, + "account:zai-individual-coding-plan/GLM-5.3-Flash", + ), + ); + assert.deepEqual(requestFor(resumedScenario, "session/send")?.params["modelSelection"], { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3-Flash", + options: { reasoningLevel: "max" }, + }); + 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", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + // "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" }, + }); + + 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..2f15ec56ac716 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/ZcodeAdapterV2.testkit.ts @@ -0,0 +1,277 @@ +// 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([ + "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 connection 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: [], + snapshot: { + settings: zcodeSessionCreateResult(createdSessionId ?? "sess_1", params).settings, + }, + }; + 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 "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: {}, +}); + +/** 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..88472a201b422 --- /dev/null +++ b/docs/user/providers-zcode.md @@ -0,0 +1,45 @@ +# ZCode + +T3 Code attaches to the ZCode daemon (`zcode serve`) as an agent, using your own Z.AI account, +skills, plugins, and MCP configuration. T3 Code starts the daemon when it is not already running +and leaves it running when the session ends, so other ZCode clients on the machine keep working. + +## 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 or reads your Z.AI sign-in. The ZCode daemon owns the credential exchange: +it reads the sign-in `zcode login` wrote on the same machine and mints the short-lived account +tokens its turns need. T3 Code attaches to that daemon and never handles the tokens. Signing out +or signing in again from a terminal applies to the next turn. + +## 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..41f590b09ccc0 --- /dev/null +++ b/packages/provider-zcode/src/server/adapter.ts @@ -0,0 +1,2350 @@ +import * as HostProcess from "@t3tools/shared/HostProcess"; +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 Scope from "effect/Scope"; +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, + zcodeJsonStringify, + zcodeRecordArray, + zcodeRecordField, + zcodeRecordNumber, + zcodeRecordString, + type ZcodeConnection, + type ZcodeProtocolError, + type ZcodeProtocolTimeoutError, + type ZcodeRecord, +} from "./zcodeProtocol.ts"; +import { zcodeModelSelectionFromSlug } from "./zcodeRuntime.ts"; +import { + makeZcodeServeConnection, + type ZcodeServeConnectionOptions, +} from "./zcodeServeConnection.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 attaches to the local `zcode serve` daemon. */ + readonly makeConnection?: ( + options: ZcodeServeConnectionOptions, + ) => Effect.Effect< + ZcodeConnection, + ZcodeProtocolError | ZcodeProtocolTimeoutError, + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + >; +} + +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; + runtimeRequest: OrchestrationV2RuntimeRequest; + readonly node: OrchestrationV2ExecutionNode; + readonly turnItem: OrchestrationV2TurnItem; + answer: + | { readonly answers: Readonly>> } + | { 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 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 }), + }); + + 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; + const connection: ZcodeConnection = yield* ( + options.makeConnection ?? makeZcodeServeConnection + )({ + environment: options.environment, + settings: options.settings, + cwd, + fileSystem, + httpClient, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: ZCODE_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + // A daemon restart surfaces as a closed channel; the pump below fails with it. + yield* connection.exited.pipe(Effect.ignore, 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; + // The daemon may send the tool input only in a later update; keep the newest one. + const input = zcodeRecordField(payload, "input"); + if (input !== undefined) existing.input = input; + 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, + 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 answers: Readonly>> } + | { 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 views = zcodeRecordArray(payload, "questions") ?? []; + const questions = views.flatMap( + (view, index): Array => { + const prompt = zcodeRecordString(view, "prompt"); + if (prompt === undefined) return []; + const inputType = zcodeRecordString(view, "inputType") ?? "text"; + const choices = (zcodeRecordArray(view, "choices") ?? []).filter( + (choice): choice is string => typeof choice === "string" && choice.length > 0, + ); + return [ + { + id: `${nativeRequestId}:${index}`, + header: zcodeRecordString(view, "header") ?? `Question ${index + 1}`, + 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 } + : {}), + }, + ]; + }, + ); + if (questions.length === 0) return; + yield* emitRuntimeRequestShell( + nativeRequestId, + "user_input", + questions, + 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/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: "The ZCode server connection was lost.", + class: "transport_error", + }); + yield* finalizeTurn(state); + } else { + yield* cancelPendingRuntimeRequests(); + } + yield* updateProviderSession( + interrupted ? "stopped" : "error", + interrupted + ? "The ZCode server connection was closed." + : "The ZCode server connection was lost.", + ); + yield* Queue.fail( + events, + new ProviderAdapter.ProviderAdapterEventStreamError({ + driver: ZCODE_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ); + }), + ), + Effect.forkIn(scope), + ); + + // The daemon owns provider/credential provisioning, so no account config is pushed here. + + 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(zcodeRecordField(model, "available")); + 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; + for (const [respondId, awaiting] of awaitingPermissionRequests) { + if (awaiting.nativeRequestId === approval.nativeRequestId) { + yield* connection.respond(respondId, response); + awaitingPermissionRequests.delete(respondId); + } + } + answeredPermissions.set(approval.nativeRequestId, response); + pendingApprovals.delete(key); + yield* resolveRuntimeRequestRecords(approval, yield* DateTime.now); + return; + } + const pendingQuestion = question!; + const answers = requestInput.answers ?? {}; + const answered: Array<[string, string | ReadonlyArray]> = []; + if (pendingQuestion.turnItem.type === "user_input_request") { + for (const item of pendingQuestion.turnItem.questions) { + const raw = answers[item.id]; + const values = (Array.isArray(raw) ? raw : [raw]).filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); + if (values.length === 0) continue; + answered.push([item.question, values.length === 1 ? values[0]! : values]); + } + } + pendingQuestion.answer = + requestInput.decision === "cancel" || answered.length === 0 + ? { cancelled: true } + : { answers: Object.fromEntries(answered) }; + for (const [respondId, awaiting] of awaitingUserInputRequests) { + if (awaiting.nativeRequestId === pendingQuestion.nativeRequestId) { + yield* connection.respond(respondId, pendingQuestion.answer); + awaitingUserInputRequests.delete(respondId); + } + } + answeredUserInputs.set(pendingQuestion.nativeRequestId, pendingQuestion.answer); + pendingQuestions.delete(key); + 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..e1462d3c6ea66 --- /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 "./zcodeEntitlements.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 model picker 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 the picker, 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/zcodeEntitlements.test.ts b/packages/provider-zcode/src/server/zcodeEntitlements.test.ts new file mode 100644 index 0000000000000..3fa0674840e95 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeEntitlements.test.ts @@ -0,0 +1,191 @@ +// @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 { + decryptZcodeCredential, + resolveZcodeCredentialSecret, + zcodeEntitledModels, +} from "./zcodeEntitlements.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")}`; +}; + +const catalog = { + config: { + providerConfigRules: { + 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: "zai-api", + config: { access: { type: "api-key" }, builtinModelIds: ["GLM-5.3"] }, + }, + ], + }, + modelConfigRules: { + modelRules: [ + { + modelMatch: "^GLM-5\\.3$", + config: { optionSpecs: { reasoningLevel: { values: ["low", "high", "max"] } } }, + }, + { + modelMatch: "^GLM-5\\.3-Flash$", + config: { optionSpecs: { reasoningLevel: { values: ["low", "high"] } } }, + }, + ], + }, + }, +}; + +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("zcodeEntitledModels", () => { + const harness = (input: { + readonly customModels?: unknown; + readonly setting?: unknown; + readonly loginToken?: string; + }) => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "zcode-entitlements-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 }); + if (input.setting !== undefined) { + yield* fs.writeFileString( + `${root}/.zcode/v2/setting.json`, + JSON.stringify(input.setting), + ); + } + if (input.loginToken !== undefined) { + yield* fs.writeFileString( + `${root}/.zcode/v2/credentials.json`, + JSON.stringify({ + "oauth:zai:access_token": encryptZcodeCredential( + input.loginToken, + resolveZcodeCredentialSecret(environment, platform), + ), + }), + ); + } + return yield* zcodeEntitledModels(fs, { + builtinPath: `${root}/zcode-builtin.json`, + builtinCatalog: input.customModels ?? catalog, + environment, + }); + }), + ).pipe(Effect.provide(NodeServices.layer)); + + it.effect("enumerates selected individual-coding-plan models with catalog reasoning levels", () => + Effect.gen(function* () { + const models = yield* harness({ + setting: { + providerFamilyConnectionSelections: { zai: { kind: "individual-coding-plan" } }, + }, + loginToken: "zai-login-token", + }); + assert.deepEqual(models, [ + { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3", + reasoningLevels: ["low", "high", "max"], + }, + { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3-Flash", + reasoningLevels: ["low", "high"], + }, + ]); + }), + ); + + it.effect("drops a family switched to apiKey mode even with a login token", () => + Effect.gen(function* () { + const models = yield* harness({ + setting: { + providerFamilyConnectionSelections: { zai: { kind: "individual-coding-plan" } }, + modelProviderFamilyModes: { zai: "apiKey" }, + }, + loginToken: "zai-login-token", + }); + assert.deepEqual(models, []); + }), + ); + + it.effect("skips catalogs without zhipu-account rules", () => + Effect.gen(function* () { + const models = yield* harness({ + customModels: { config: { providerConfigRules: { providerRules: [] } } }, + }); + assert.deepEqual(models, []); + }), + ); +}); diff --git a/packages/provider-zcode/src/server/zcodeEntitlements.ts b/packages/provider-zcode/src/server/zcodeEntitlements.ts new file mode 100644 index 0000000000000..b987452d0b901 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeEntitlements.ts @@ -0,0 +1,229 @@ +// @effect-diagnostics nodeBuiltinImport:off -- the CLI's credential store is AES-256-GCM under a key only node:crypto can derive +// The entitlement ladder shared by the model picker and the auth probe: which individual-coding-plan +// families the user selected in setting.json and holds a decryptable login token for in +// credentials.json, joined against the CLI catalog's zhipu-account rules. +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 Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +import { + zcodeRecordArray, + zcodeRecordField, + zcodeRecordString, + type ZcodeRecord, +} from "./zcodeProtocol.ts"; +import { zcodeDataRoot } from "./zcodeRuntime.ts"; + +const ENCRYPTED_PREFIX = "enc:v1:"; +const CIPHER_ALGORITHM = "aes-256-gcm"; + +/** + * 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)); + +const readDecryptedCredentialStore = Effect.fn("ZcodeEntitlements.readCredentialStore")(function* ( + fileSystem: FileSystem.FileSystem, + credentialsPath: string, + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform, +): Effect.fn.Return> { + const raw = yield* fileSystem.readFileString(credentialsPath).pipe(Effect.option); + if (Option.isNone(raw)) return {}; + 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; + } + return entries; +}); + +/** + * Families the entitlement 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 the picker 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("ZcodeEntitlements.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); +}); + +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, + ); +}; + +/** + * 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("ZcodeEntitlements.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; +}); diff --git a/packages/provider-zcode/src/server/zcodeProtocol.ts b/packages/provider-zcode/src/server/zcodeProtocol.ts new file mode 100644 index 0000000000000..bb70a651bbb03 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeProtocol.ts @@ -0,0 +1,83 @@ +// Shared wire-shape types and errors for the ZCode provider. The transport itself lives in +// `zcodeServeProtocol.ts` (channel frames over the daemon's WebSocket) and +// `zcodeServeConnection.ts` (daemon requests mapped onto this connection surface). +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; +import type * as Queue from "effect/Queue"; +import type * as Effect from "effect/Effect"; + +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 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 UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); +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 ""; + } +} 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..6367c7b7bf50d --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeRuntime.test.ts @@ -0,0 +1,461 @@ +// @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 { + resolveZcodeCatalogFiles, + readZcodeAppVersion, + resolveZcodeLaunchCommand, + zcodeBuiltinCatalogPath, + zcodeBundledCatalogPath, + zcodeBaseUrl, + zcodeDataRoot, + zcodeModelSelectionFromSlug, + zcodePersonalProviderConfigPath, + zcodePlatformDir, + zcodeRuntimeEnvironment, + zcodeServeArgs, +} 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: [] }, + }, + ], + }, + }, +}; + +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("zcodeServeArgs", () => { + it("launches serve with launch args split on whitespace", () => { + assert.deepEqual(zcodeServeArgs(""), ["serve"]); + assert.deepEqual(zcodeServeArgs("--verbose --trace file "), [ + "serve", + "--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("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); + }), + ), + ); + + 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..a9d0781824f0e --- /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. +// ZCode CLI locations and catalog discovery: the data root, the installed catalog files the +// model picker enumerates, the launch command and `serve` args, 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 zcodeServeArgs(launchArgs: string): ReadonlyArray { + return ["serve", ...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/server/zcodeServeConnection.ts b/packages/provider-zcode/src/server/zcodeServeConnection.ts new file mode 100644 index 0000000000000..15e18ca8b1ddd --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeServeConnection.ts @@ -0,0 +1,424 @@ +// Transport shim: presents the `zcode serve` daemon through the request/event surface the +// session layer already speaks. Requests map to daemon channel calls; events come from the +// row-based v4 conversation projection, folded by `zcodeServeConversation`. +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as HttpClient from "effect/http/HttpClient"; +import * as Predicate from "effect/Predicate"; +import * as Queue from "effect/Queue"; +import * as Clock from "effect/Clock"; +import * as SecureRandom from "effect/crypto/SecureRandom"; + +import { + ZcodeProtocolError, + type ZcodeConnection, + type ZcodeProtocolTimeoutError, + type ZcodeRecord, +} from "./zcodeProtocol.ts"; +import { zcodeChannelUrl, connectZcodeServer } from "./zcodeServeDaemon.ts"; +import { + applyConversationPayload, + initialConversationState, + interactionAnswer, + type ZcodeConversationState, +} from "./zcodeServeConversation.ts"; +import { makeZcodeChannelClient, type ZcodeChannelClient } from "./zcodeServeProtocol.ts"; + +const CLIENT_ID = "t3-code"; +const DEFAULT_TIMEOUT_MS = 30_000; +const CREATE_TIMEOUT_MS = 150_000; + +// Command ids only need to be unique, so draw from the process's own WebCrypto rather than +// threading a service through the adapter's fixed environment requirements. +const random = SecureRandom.make((size) => globalThis.crypto.getRandomValues(new Uint8Array(size))); + +export interface ZcodeServeConnectionOptions { + readonly environment: NodeJS.ProcessEnv; + readonly settings: { readonly binaryPath: string; readonly launchArgs: string }; + readonly cwd: string; + readonly fileSystem: FileSystem.FileSystem; + readonly httpClient: HttpClient.HttpClient; +} + +interface ZcodeWorkspaceParams { + readonly workspacePath: string; + readonly workspaceIdentity: string; + readonly projectId: string; +} + +const recordField = (record: unknown, key: string): unknown => + Predicate.isObject(record) ? record[key] : undefined; + +const recordString = (record: unknown, key: string): string | undefined => { + const value = recordField(record, key); + return Predicate.isString(value) ? value : undefined; +}; + +/** + * The daemon refuses to start a session for a workspace it has no project record for. The host + * channel here is a remote host, so `reuseExistingRoot` is rejected; match the root ourselves so + * repeated attaches do not mint duplicate projects. + */ +const resolveProjectId = Effect.fn("ZcodeServe.resolveProject")(function* ( + channel: ZcodeChannelClient, + workspacePath: string, +) { + const existing = yield* channel.call("project", "readProjects", { includeDeleted: false }); + const match = Array.isArray(existing) + ? existing.find((project) => { + if (!Predicate.isObject(project) || project["deleted"] === true) return false; + const roots = project["roots"]; + return Array.isArray(roots) && recordString(roots[0], "rootPath") === workspacePath; + }) + : undefined; + const existingId = recordString(match, "id"); + if (existingId !== undefined) return existingId; + + const saved = yield* channel.call("project", "saveProject", { + roots: [workspacePath], + primaryRoot: workspacePath, + defaultName: workspacePath.split(/[\\/]/).filter(Boolean).at(-1) ?? "zcode", + }); + const id = recordString(saved, "id"); + if (id === undefined) { + return yield* new ZcodeProtocolError({ + operation: "resolveProject", + detail: "zcode server did not return a project id for the workspace.", + }); + } + return id; +}); + +/** The daemon advertises its session model on the snapshot; the session layer only needs it as a default. */ +const availableModelDescriptors = ( + state: ZcodeConversationState, + settings: unknown, +): ReadonlyArray => { + const model = recordField(recordField(settings, "model"), "available"); + if (Array.isArray(model) && model.length > 0) return model.filter(Predicate.isObject); + const selection = state.modelSelection; + if (selection === undefined) return []; + return [ + { + ref: { providerId: selection.providerId, modelId: selection.modelId }, + ...(state.thoughtLevel === undefined + ? {} + : { reasoning: { defaultLevel: state.thoughtLevel, levels: state.thoughtLevels } }), + }, + ]; +}; + +const readLegacySettings = Effect.fn("ZcodeServe.readSettings")(function* ( + channel: ZcodeChannelClient, + workspace: ZcodeWorkspaceParams, + sessionId: string, +) { + const snapshot = yield* channel.call("zcode-agent", "readSession", { + ...workspace, + sessionId, + deliveryKind: "desktop-continuous", + }); + return recordField(snapshot, "settings"); +}); + +export const makeZcodeServeConnection = Effect.fn("ZcodeServeConnection.make")(function* ( + options: ZcodeServeConnectionOptions, +) { + const scope = yield* Effect.scope; + const server = yield* connectZcodeServer({ + settings: options.settings, + environment: options.environment, + fileSystem: options.fileSystem, + httpClient: options.httpClient, + }); + const channel = yield* makeZcodeChannelClient({ + url: zcodeChannelUrl(server.status, server.token), + headers: { "x-zcode-rpc-host-capability": server.capability }, + }); + const projectId = yield* resolveProjectId(channel, options.cwd); + const workspace: ZcodeWorkspaceParams = { + workspacePath: options.cwd, + workspaceIdentity: projectId, + projectId, + }; + yield* channel.call("zcode-agent", "initialize", workspace).pipe( + Effect.tap((initialized) => + recordField(initialized, "available") === true + ? Effect.void + : Effect.logWarning("ZCode agent reports no usable provider for this workspace.", { + reason: recordField(initialized, "reason") ?? null, + }), + ), + Effect.ignore, + ); + + const events = yield* Queue.unbounded(); + const exitDeferred = yield* Deferred.make(); + let conversation: ZcodeConversationState = initialConversationState(); + let sessionId: string | undefined; + let seeding = false; + + const foldFrame = (frame: unknown): void => { + if (recordField(frame, "kind") !== "complete") return; + if (sessionId === undefined) return; + const topic = recordString(frame, "topic"); + // The listener is workspace-scoped; the topic names the conversation this client subscribed. + if (topic !== undefined && topic !== `conversation/${sessionId}`) return; + const payload = recordField(recordField(frame, "frame"), "payload"); + if (!Predicate.isObject(payload)) return; + const seedRows = seeding && recordString(payload, "kind") === "snapshot"; + if (seedRows) seeding = false; + for (const event of applyConversationPayload(conversation, payload, { seedRows })) { + if (event.type === "server.request") { + const requestParams = recordField(event.payload, "params"); + Queue.offerUnsafe(events, { + id: recordString(requestParams, "requestId") ?? "", + method: recordString(event.payload, "method") ?? "", + params: Predicate.isObject(requestParams) ? requestParams : {}, + }); + continue; + } + Queue.offerUnsafe(events, { method: "session/event", params: event }); + } + }; + + yield* channel.listen("zcode-agent", "onDynamicConversationFrame", workspace, foldFrame); + yield* channel.listen("zcode-agent", "onDynamicSessionLifecycle", workspace, (snapshot) => { + if (!Predicate.isObject(snapshot)) return; + const eventSessionId = recordString(snapshot, "sessionId"); + if (sessionId !== undefined && eventSessionId !== undefined && eventSessionId !== sessionId) { + return; + } + Queue.offerUnsafe(events, { method: "session/lifecycle/changed", params: snapshot }); + }); + + yield* channel.closed.pipe( + Effect.andThen( + Deferred.fail( + exitDeferred, + new ZcodeProtocolError({ + operation: "exit", + detail: "zcode server channel closed", + }), + ), + ), + Effect.andThen( + Queue.fail( + events, + new ZcodeProtocolError({ operation: "exit", detail: "zcode server channel closed" }), + ), + ), + Effect.ignore, + Effect.forkIn(scope), + ); + + const subscribe = Effect.fn("ZcodeServe.subscribe")(function* (nativeSessionId: string) { + conversation = initialConversationState(); + sessionId = nativeSessionId; + seeding = true; + return yield* channel.call("zcode-agent", "subscribeConversationV4", { + ...workspace, + sessionId: nativeSessionId, + visibility: "foreground", + }); + }); + + const commitCommand = Effect.fn("ZcodeServe.commitCommand")(function* ( + type: string, + nativeSessionId: string, + payload: ZcodeRecord, + ) { + const commandId = yield* random.uuidV4(); + const issuedAt = yield* Clock.currentTimeMillis; + return yield* channel.call("zcode-agent", "sendConversationCommandV4", { + ...workspace, + envelope: { + commandId, + clientId: CLIENT_ID, + sessionId: nativeSessionId, + type, + payload, + issuedAt, + }, + }); + }); + + /** Translates a session-layer answer into the daemon's `resolveInteraction` shape. */ + const respond = Effect.fnUntraced(function* (id: string, result: unknown) { + const interaction = conversation.pendingInteractions.get(id); + const answer = interactionAnswer(interaction, result); + if (answer === undefined || sessionId === undefined) return; + yield* commitCommand("resolveInteraction", sessionId, { + interactionId: id, + answer, + }).pipe( + Effect.onError(() => + Effect.sync(() => { + // A resubscribe may surface the still-pending interaction again. + conversation.announcedInteractions.delete(id); + }), + ), + ); + // Only forget the interaction once the daemon accepted the answer; a failed resolve must + // keep it pending so the UI can retry instead of silently dropping the request. + conversation.pendingInteractions.delete(id); + }); + + const request = Effect.fn("ZcodeServe.request")(function* ( + method: string, + params: ZcodeRecord, + timeoutMs = DEFAULT_TIMEOUT_MS, + ): Effect.fn.Return { + switch (method) { + case "session/create": { + const workspaceRef = recordField(params, "workspace"); + const requestedCwd = recordString(workspaceRef, "workspacePath"); + const mode = recordString(params, "mode"); + const mcpServers = recordField(params, "mcpServers"); + const created = yield* channel.call( + "zcode-session", + "createSession", + { + ...workspace, + ...(requestedCwd === undefined ? {} : { workspacePath: requestedCwd }), + ...(mode === undefined ? {} : { mode }), + ...(Array.isArray(mcpServers) ? { mcpServers } : {}), + }, + timeoutMs, + ); + const nativeSessionId = recordString(recordField(created, "session"), "sessionId"); + if (nativeSessionId === undefined) { + return yield* new ZcodeProtocolError({ + operation: method, + detail: "zcode server returned no session id.", + }); + } + const settings = recordField(created, "settings"); + return { + session: { sessionId: nativeSessionId }, + settings: { + model: { available: availableModelDescriptors(conversation, settings) }, + }, + }; + } + case "session/subscribe": { + const nativeSessionId = recordString(params, "sessionId"); + if (nativeSessionId === undefined) return { snapshot: {} }; + yield* subscribe(nativeSessionId); + const settings = yield* readLegacySettings(channel, workspace, nativeSessionId).pipe( + Effect.orElseSucceed(() => undefined), + ); + return { + snapshot: { + settings: { + model: { available: availableModelDescriptors(conversation, settings) }, + }, + }, + }; + } + case "session/resume": { + const nativeSessionId = recordString(params, "sessionId"); + if (nativeSessionId === undefined) { + return yield* new ZcodeProtocolError({ + operation: method, + detail: "session/resume needs a sessionId", + }); + } + const mcpServers = recordField(params, "mcpServers"); + yield* channel.call( + "zcode-agent", + "resumeSession", + { + ...workspace, + sessionId: nativeSessionId, + ...(Array.isArray(mcpServers) ? { mcpServers } : {}), + }, + CREATE_TIMEOUT_MS, + ); + sessionId = nativeSessionId; + return {}; + } + case "session/setMode": { + const nativeSessionId = recordString(params, "sessionId"); + const mode = recordString(params, "mode"); + if (nativeSessionId === undefined || mode === undefined) return {}; + yield* channel.call("zcode-agent", "setMode", { + ...workspace, + sessionId: nativeSessionId, + mode, + }); + return {}; + } + case "session/send": { + const nativeSessionId = recordString(params, "sessionId"); + const content = recordString(params, "content"); + if (nativeSessionId === undefined || content === undefined) { + return yield* new ZcodeProtocolError({ + operation: method, + detail: "session/send needs a sessionId and content.", + }); + } + const modelSelection = recordField(params, "modelSelection"); + const inputId = yield* random.uuidV4(); + return yield* channel.call("zcode-agent", "sendPrompt", { + ...workspace, + sessionId: nativeSessionId, + inputId, + content, + ...(Predicate.isObject(modelSelection) ? { modelSelection } : {}), + }); + } + case "session/compact": { + const nativeSessionId = recordString(params, "sessionId"); + if (nativeSessionId === undefined) return {}; + const instructions = recordString(params, "instructions"); + const inputId = yield* random.uuidV4(); + return yield* channel.call( + "zcode-agent", + "compactSession", + { + ...workspace, + sessionId: nativeSessionId, + inputId, + ...(instructions === undefined ? {} : { instructions }), + }, + CREATE_TIMEOUT_MS, + ); + } + case "session/stop": { + const nativeSessionId = recordString(params, "sessionId") ?? sessionId; + if (nativeSessionId === undefined) return {}; + const executionId = conversation.foregroundExecutionId; + yield* commitCommand( + "stop", + nativeSessionId, + executionId === undefined ? {} : { expectedForegroundExecutionId: executionId }, + ); + return {}; + } + case "session/read": { + const nativeSessionId = recordString(params, "sessionId"); + if (nativeSessionId === undefined) return {}; + return yield* channel.call("zcode-agent", "readSession", { + ...workspace, + sessionId: nativeSessionId, + deliveryKind: "desktop-continuous", + }); + } + default: + return yield* new ZcodeProtocolError({ + operation: method, + detail: "unsupported zcode server request", + }); + } + }); + + return { + request, + respond, + events, + exited: Deferred.await(exitDeferred), + terminate: Effect.void, + } satisfies ZcodeConnection; +}); diff --git a/packages/provider-zcode/src/server/zcodeServeConversation.test.ts b/packages/provider-zcode/src/server/zcodeServeConversation.test.ts new file mode 100644 index 0000000000000..f48e1677f3966 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeServeConversation.test.ts @@ -0,0 +1,531 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + applyConversationPayload, + initialConversationState, + interactionAnswer, + type ZcodeConversationState, +} from "./zcodeServeConversation.ts"; + +const deltas = (state: ZcodeConversationState, entries: ReadonlyArray) => + applyConversationPayload(state, { kind: "deltas", deltas: entries }); + +describe("applyConversationPayload", () => { + it("seeds config, rows, and streamed text from a snapshot", () => { + const state = initialConversationState(); + const events = applyConversationPayload(state, { + kind: "snapshot", + snapshot: { + revision: 3, + config: { + modelSelection: { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3", + }, + thought: "max", + thoughtLevels: ["low", "high", "max"], + }, + control: { + phase: "draft", + activeWorks: [{ kind: "primaryTurn", foregroundExecutionId: "runtime_command_1" }], + }, + rows: { + window: [ + { rowId: 1, kind: "turnHeader", state: "running" }, + { + rowId: 2, + kind: "assistantText", + assistantResponseId: "msg_a", + text: "earlier", + state: "complete", + }, + ], + totalCount: 2, + firstRowId: 1, + }, + pendingInteractions: [], + }, + }); + + assert.equal(state.revision, 3); + assert.deepEqual(state.modelSelection, { + providerId: "account:zai-individual-coding-plan", + modelId: "GLM-5.3", + }); + assert.deepEqual(state.thoughtLevels, ["low", "high", "max"]); + assert.equal(state.thoughtLevel, "max"); + assert.equal(state.phase, "draft"); + assert.equal(state.foregroundExecutionId, "runtime_command_1"); + assert.equal(state.lastAssistantText, "earlier"); + assert.deepEqual(events, [ + { + type: "model.streaming", + payload: { + assistantMessageId: "msg_a", + delta: "earlier", + kind: "text_delta", + done: true, + }, + }, + ]); + }); + + it("replays snapshot rows silently when subscribed for history", () => { + const state = initialConversationState(); + const events = applyConversationPayload( + state, + { + kind: "snapshot", + snapshot: { + revision: 1, + rows: { + window: [ + { rowId: 1, kind: "assistantText", assistantResponseId: "msg_a", text: "old" }, + ], + }, + }, + }, + { seedRows: true }, + ); + assert.deepEqual(events, []); + assert.equal(state.lastAssistantText, "old"); + }); + + it("streams text deltas and completes the turn with the last assistant text", () => { + const state = initialConversationState(); + + assert.deepEqual( + deltas(state, [ + { + op: "state.updated", + patch: { + revision: 1, + control: { + phase: "running", + activeWorks: [{ kind: "primaryTurn", foregroundExecutionId: "runtime_command_4" }], + }, + }, + }, + ]), + [{ type: "turn.started", payload: {} }], + ); + assert.equal(state.foregroundExecutionId, "runtime_command_4"); + + assert.deepEqual( + deltas(state, [ + { + op: "row.appended", + row: { + rowId: 6, + kind: "assistantText", + assistantResponseId: "msg_b", + text: "", + state: "streaming", + }, + }, + ]), + [], + ); + + assert.deepEqual(deltas(state, [{ op: "row.delta", rowId: 6, append: "do" }]), [ + { + type: "model.streaming", + payload: { assistantMessageId: "msg_b", delta: "do", kind: "text_delta" }, + }, + ]); + + // The finishing upsert carries the whole text; only the suffix is new. + assert.deepEqual( + deltas(state, [ + { + op: "row.upserted", + row: { + rowId: 6, + kind: "assistantText", + assistantResponseId: "msg_b", + text: "done", + state: "complete", + }, + }, + { op: "row.upserted", row: { rowId: 1, kind: "turnHeader", outputTokens: 31 } }, + ]), + [ + { + type: "model.streaming", + payload: { + assistantMessageId: "msg_b", + delta: "ne", + kind: "text_delta", + done: true, + }, + }, + ], + ); + + assert.deepEqual( + deltas(state, [{ op: "state.updated", patch: { control: { phase: "completedSuccess" } } }]), + [ + { + type: "turn.completed", + payload: { resultType: "success", response: "done", tokenCount: 31 }, + }, + ], + ); + assert.equal(state.lastAssistantText, "done"); + }); + + it("reports interruption and failure as their own terminal kinds", () => { + const interrupted = initialConversationState(); + deltas(interrupted, [{ op: "state.updated", patch: { control: { phase: "running" } } }]); + assert.deepEqual( + deltas(interrupted, [ + { op: "state.updated", patch: { control: { phase: "completedInterrupted" } } }, + ]), + [{ type: "turn.completed", payload: { resultType: "cancelled" } }], + ); + + const failed = initialConversationState(); + deltas(failed, [{ op: "state.updated", patch: { control: { phase: "running" } } }]); + assert.deepEqual( + deltas(failed, [ + { + op: "state.updated", + patch: { control: { phase: "error", lastError: { message: "model exploded" } } }, + }, + ]), + [{ type: "turn.failed", payload: { error: { message: "model exploded" } } }], + ); + }); + + it("follows a tool call from started through streamed output to its result", () => { + const state = initialConversationState(); + const toolRow = { + rowId: 4, + kind: "toolCall", + toolCallId: "call_1", + toolName: "Bash", + status: "inputStreaming", + input: { command: "echo hello-t3" }, + }; + + assert.deepEqual(deltas(state, [{ op: "row.appended", row: toolRow }]), [ + { + type: "tool.updated", + payload: { + toolCallId: "call_1", + toolName: "Bash", + input: { command: "echo hello-t3" }, + kind: "started", + }, + }, + ]); + + assert.deepEqual( + deltas(state, [ + { op: "row.upserted", row: { ...toolRow, status: "running" } }, + { op: "row.delta", rowId: 4, path: "output.text", append: "hel" }, + ]), + [ + { + type: "tool.updated", + payload: { + toolCallId: "call_1", + toolName: "Bash", + input: { command: "echo hello-t3" }, + kind: "started", + }, + }, + { + type: "tool.updated", + payload: { + toolCallId: "call_1", + toolName: "Bash", + input: { command: "echo hello-t3" }, + kind: "progress", + outputPreview: { text: "hel" }, + }, + }, + ], + ); + + assert.deepEqual( + deltas(state, [ + { + op: "row.upserted", + row: { ...toolRow, status: "success", output: { text: "hello-t3" } }, + }, + ]), + [ + { + type: "tool.updated", + payload: { + toolCallId: "call_1", + toolName: "Bash", + input: { command: "echo hello-t3" }, + kind: "result", + result: { output: "hello-t3" }, + }, + }, + ], + ); + + assert.deepEqual( + deltas(state, [ + { + op: "row.appended", + row: { + rowId: 5, + kind: "toolCall", + toolCallId: "call_2", + toolName: "Bash", + status: "error", + error: { message: "permission denied" }, + }, + }, + ]), + [ + { + type: "tool.updated", + payload: { + toolCallId: "call_2", + toolName: "Bash", + input: undefined, + kind: "error", + error: { message: "permission denied" }, + }, + }, + ], + ); + }); + + it("announces a permission interaction once and resolves it when the daemon drops it", () => { + const state = initialConversationState(); + const interaction = { + interactionId: "int_1", + kind: "permission", + payload: { + toolCallId: "call_1", + toolName: "Bash", + summary: "Needs approval", + options: [ + { optionId: "o1", kind: "allowOnce", label: "Allow once" }, + { optionId: "o2", kind: "deny", label: "Deny" }, + ], + fullAccessOption: { optionId: "o3", kind: "custom", label: "Full access" }, + }, + }; + const patch = { op: "state.updated", patch: { pendingInteractions: [interaction] } }; + + assert.deepEqual(deltas(state, [patch]), [ + { + type: "permission.requested", + payload: { + requestId: "int_1", + toolCallId: "call_1", + toolName: "Bash", + reason: "Needs approval", + options: [ + { + optionId: "o1", + kind: "allow_once", + name: "Allow once", + response: { optionId: "o1", decision: "allow" }, + }, + { + optionId: "o2", + kind: "deny", + name: "Deny", + response: { optionId: "o2", decision: "deny" }, + }, + { + optionId: "o3", + kind: "custom", + name: "Full access", + response: { optionId: "o3", decision: "allow" }, + }, + ], + }, + }, + { + type: "server.request", + payload: { + method: "interaction/requestPermission", + params: { requestId: "int_1", toolCallId: "call_1", toolName: "Bash" }, + }, + }, + ]); + + // A resubscribe replays the same pending interaction; it must not ask twice. + assert.deepEqual(deltas(state, [patch]), []); + + assert.deepEqual(deltas(state, [{ op: "state.updated", patch: { pendingInteractions: [] } }]), [ + { type: "permission.resolved", payload: { requestId: "int_1" } }, + ]); + assert.equal(state.pendingInteractions.size, 0); + }); + + it("surfaces an AskUserQuestion as choices and as free text", () => { + const state = initialConversationState(); + assert.deepEqual( + deltas(state, [ + { + op: "state.updated", + patch: { + pendingInteractions: [ + { + interactionId: "int_2", + kind: "userInput", + payload: { + questions: [ + { + question: "Proceed with the migration?", + header: "Confirm", + options: [ + { value: "yes", label: "Yes" }, + { value: "no", label: "No" }, + ], + }, + ], + }, + }, + ], + }, + }, + ]), + [ + { + type: "userInput.requested", + payload: { + requestId: "int_2", + questions: [ + { + header: "Confirm", + prompt: "Proceed with the migration?", + inputType: "choice", + choices: ["Yes", "No"], + }, + ], + }, + }, + { + type: "server.request", + payload: { + method: "interaction/requestUserInput", + params: { requestId: "int_2" }, + }, + }, + ], + ); + + const freeText = initialConversationState(); + assert.deepEqual( + deltas(freeText, [ + { + op: "state.updated", + patch: { + pendingInteractions: [ + { + interactionId: "int_3", + kind: "userInput", + payload: { prompt: "Explain the change", freeText: true }, + }, + ], + }, + }, + ]), + [ + { + type: "userInput.requested", + payload: { + requestId: "int_3", + questions: [ + { + header: "Question 1", + prompt: "Explain the change", + inputType: "text", + choices: [], + }, + ], + }, + }, + { + type: "server.request", + payload: { + method: "interaction/requestUserInput", + params: { requestId: "int_3" }, + }, + }, + ], + ); + }); + + it("keeps every question in a multi-question user input request", () => { + const state = initialConversationState(); + const events = deltas(state, [ + { + op: "state.updated", + patch: { + pendingInteractions: [ + { + interactionId: "int_4", + kind: "userInput", + payload: { + questions: [ + { + header: "Confirm", + question: "Proceed with the migration?", + options: [{ label: "Yes" }, { label: "No" }], + }, + { + header: "Details", + question: "Which database?", + options: [{ label: "SQLite" }, { label: "Postgres" }], + }, + ], + }, + }, + ], + }, + }, + ]); + + assert.deepEqual(events[0], { + type: "userInput.requested", + payload: { + requestId: "int_4", + questions: [ + { + header: "Confirm", + prompt: "Proceed with the migration?", + inputType: "choice", + choices: ["Yes", "No"], + }, + { + header: "Details", + prompt: "Which database?", + inputType: "choice", + choices: ["SQLite", "Postgres"], + }, + ], + }, + }); + assert.deepEqual(interactionAnswer(undefined, { answers: { "Which database?": "SQLite" } }), { + action: "accept", + content: { answers: { "Which database?": "SQLite" } }, + }); + }); + + it("does not reuse an older turn header's token count", () => { + const state = initialConversationState(); + deltas(state, [{ op: "state.updated", patch: { control: { phase: "running" } } }]); + deltas(state, [ + { op: "row.upserted", row: { rowId: 1, kind: "turnHeader", outputTokens: 31 } }, + { op: "row.upserted", row: { rowId: 4, kind: "turnHeader" } }, + ]); + + assert.deepEqual( + deltas(state, [{ op: "state.updated", patch: { control: { phase: "completedSuccess" } } }]), + [{ type: "turn.completed", payload: { resultType: "success" } }], + ); + }); +}); diff --git a/packages/provider-zcode/src/server/zcodeServeConversation.ts b/packages/provider-zcode/src/server/zcodeServeConversation.ts new file mode 100644 index 0000000000000..bf4cfe13ccaa3 --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeServeConversation.ts @@ -0,0 +1,564 @@ +// Adapter between the `zcode serve` daemon's row-based conversation projection and the event +// vocabulary this provider's session layer already speaks. The daemon streams row deltas, +// state patches and pending interactions; folding them belongs here so the transport stays +// thin and the mapping stays testable. +import * as Predicate from "effect/Predicate"; + +export type ZcodeRecord = Record; + +export interface ZcodeConversationEvent { + readonly type: string; + readonly payload: ZcodeRecord; +} + +export interface ZcodeModelSelection { + readonly providerId: string; + readonly modelId: string; +} + +export interface ZcodeConversationState { + readonly rows: Map; + revision: number; + phase: string | undefined; + /** The daemon rejects a stale `stop` without this id, so track it off the live control patch. */ + foregroundExecutionId: string | undefined; + readonly pendingInteractions: Map; + /** Interactions already surfaced as requests; resubscribes replay the snapshot. */ + readonly announcedInteractions: Set; + /** Streamed `output.text` per tool row; tool rows only carry the full text at the end. */ + readonly toolOutput: Map; + modelSelection: ZcodeModelSelection | undefined; + thoughtLevels: ReadonlyArray; + thoughtLevel: string | undefined; + /** Latest finished assistant text, used as the turn result summary. */ + lastAssistantText: string | undefined; +} + +export const initialConversationState = (): ZcodeConversationState => ({ + rows: new Map(), + revision: 0, + phase: undefined, + foregroundExecutionId: undefined, + pendingInteractions: new Map(), + announcedInteractions: new Set(), + toolOutput: new Map(), + modelSelection: undefined, + thoughtLevels: [], + thoughtLevel: undefined, + lastAssistantText: undefined, +}); + +const field = (record: unknown, key: string): unknown => + Predicate.isObject(record) ? record[key] : undefined; + +const text = (record: unknown, key: string): string | undefined => { + const value = field(record, key); + return Predicate.isString(value) ? value : undefined; +}; + +const number = (record: unknown, key: string): number | undefined => { + const value = field(record, key); + return Predicate.isNumber(value) && Number.isFinite(value) ? value : undefined; +}; + +const readConfig = (state: ZcodeConversationState, config: unknown): void => { + const selection = field(config, "modelSelection"); + const providerId = text(selection, "providerId"); + const modelId = text(selection, "modelId"); + if (providerId !== undefined && modelId !== undefined) { + state.modelSelection = { providerId, modelId }; + } + const levels = field(config, "thoughtLevels"); + if (Array.isArray(levels)) { + state.thoughtLevels = levels.filter(Predicate.isString); + } + const thought = text(config, "thought"); + if (thought !== undefined) state.thoughtLevel = thought; +}; + +const readControl = (state: ZcodeConversationState, control: unknown): void => { + const phase = text(control, "phase"); + if (phase !== undefined) state.phase = phase; + const activeWorks = field(control, "activeWorks"); + if (Array.isArray(activeWorks)) { + const primary = + activeWorks.find((work) => text(work, "kind") === "primaryTurn") ?? activeWorks[0]; + state.foregroundExecutionId = text(primary, "foregroundExecutionId"); + } +}; + +/** Deltas carry the suffix; full-row upserts carry the whole text, so diff against the last row. */ +const appendedText = (previous: string, next: string): string => { + if (previous.length === 0) return next; + if (next.startsWith(previous)) return next.slice(previous.length); + let shared = 0; + while (shared < previous.length && shared < next.length && previous[shared] === next[shared]) { + shared += 1; + } + return next.slice(shared); +}; + +const streamTextEvent = ( + messageId: string, + delta: string, + kind: "text_delta" | "reasoning_delta", + done: boolean, +): ZcodeConversationEvent => ({ + type: "model.streaming", + payload: { + assistantMessageId: messageId, + delta, + kind, + ...(done ? { done: true } : {}), + }, +}); + +const rowEvents = ( + state: ZcodeConversationState, + row: ZcodeRecord, + previous: ZcodeRecord | undefined, + append: string | undefined, +): ReadonlyArray => { + const kind = text(row, "kind"); + + if (kind === "assistantText" || kind === "reasoning") { + const messageId = text(row, "assistantResponseId") ?? text(row, "entityId"); + if (messageId === undefined) return []; + const streamKind = kind === "assistantText" ? "text_delta" : "reasoning_delta"; + const nextText = text(row, "text") ?? ""; + const delta = + append ?? + (previous === undefined ? nextText : appendedText(text(previous, "text") ?? "", nextText)); + const rowState = text(row, "state"); + const finishing = + rowState !== undefined && + rowState !== "streaming" && + text(previous ?? {}, "state") !== rowState; + const events: ZcodeConversationEvent[] = []; + if (delta.length > 0 || finishing) { + events.push(streamTextEvent(messageId, delta, streamKind, finishing)); + } + if (kind === "assistantText" && nextText.length > 0 && rowState !== "streaming") { + state.lastAssistantText = nextText; + } + return events; + } + + if (kind === "toolCall") { + const toolCallId = text(row, "toolCallId") ?? `row-${number(row, "rowId") ?? 0}`; + const status = text(row, "status"); + const output = text(field(row, "output"), "text"); + const input = field(row, "input"); + const payload: ZcodeRecord = { + toolCallId, + toolName: text(row, "toolName"), + input, + }; + const previousStatus = text(previous ?? {}, "status"); + if (status === previousStatus) { + const preview = + text(field(row, "outputPreview"), "text") ?? text(field(row, "progress"), "previewLine"); + const previousPreview = + text(field(previous ?? {}, "outputPreview"), "text") ?? + text(field(previous ?? {}, "progress"), "previewLine"); + if (preview !== undefined && preview.length > 0 && preview !== previousPreview) { + return [ + { + type: "tool.updated", + payload: { ...payload, kind: "progress", outputPreview: { text: preview } }, + }, + ]; + } + if (input !== undefined && field(previous ?? {}, "input") === undefined) { + return [{ type: "tool.updated", payload: { ...payload, kind: "started" } }]; + } + return []; + } + if (status === "error" || status === "cancelled") { + const message = text(field(row, "error"), "message") ?? output ?? "Tool failed."; + return [{ type: "tool.updated", payload: { ...payload, kind: "error", error: { message } } }]; + } + if (status === "success") { + return [ + { + type: "tool.updated", + payload: { + ...payload, + kind: "result", + result: output === undefined ? {} : { output }, + }, + }, + ]; + } + return [{ type: "tool.updated", payload: { ...payload, kind: "started" } }]; + } + + return []; +}; + +const INTERACTION_OPTION_KINDS: Record = { + allowOnce: { kind: "allow_once", decision: "allow" }, + allowAlways: { kind: "allow_always", decision: "allow" }, + deny: { kind: "deny", decision: "deny" }, + custom: { kind: "custom", decision: "allow" }, +}; + +interface PermissionOption { + readonly optionId: string; + readonly kind: string; + readonly name: string; + readonly response: ZcodeRecord; +} + +const permissionOptions = (payload: unknown): ReadonlyArray => { + const raw = field(payload, "options"); + const options = Array.isArray(raw) ? [...raw] : []; + const fullAccess = field(payload, "fullAccessOption"); + if (Predicate.isObject(fullAccess)) options.push(fullAccess); + return options.flatMap((option): Array => { + const optionId = text(option, "optionId"); + if (optionId === undefined) return []; + const mapped = INTERACTION_OPTION_KINDS[text(option, "kind") ?? ""] ?? { + kind: "custom", + decision: "allow", + }; + return [ + { + optionId, + kind: mapped.kind, + name: text(option, "label") ?? optionId, + // The session layer echoes this object back untouched; the transport turns it into + // the daemon's `resolveInteraction` answer. + response: { optionId, decision: mapped.decision }, + }, + ]; + }); +}; + +interface UserInputQuestionView { + readonly header: string; + readonly prompt: string; + readonly inputType: "text" | "choice"; + readonly choices: ReadonlyArray; +} + +const questionLabels = (candidates: unknown): ReadonlyArray => + Array.isArray(candidates) + ? candidates.flatMap((option): Array => { + const label = text(option, "label"); + return label === undefined ? [] : [label]; + }) + : []; + +/** Every question in the daemon's AskUserQuestion payload, preserving order. */ +const userInputQuestions = (payload: unknown): ReadonlyArray => { + const raw = field(payload, "questions"); + const entries = Array.isArray(raw) && raw.length > 0 ? raw : [undefined]; + const flatOptions = field(payload, "options"); + return entries.map((entry, index) => { + const choices = questionLabels(field(entry, "options") ?? flatOptions); + const freeText = + field(payload, "freeText") === true || field(entry, "multiSelect") !== undefined; + return { + header: text(entry, "header") ?? `Question ${index + 1}`, + prompt: text(entry, "question") ?? text(payload, "prompt") ?? "The agent asked a question", + inputType: freeText || choices.length === 0 ? "text" : "choice", + choices, + }; + }); +}; + +/** + * Interaction events in this provider's vocabulary plus the server request that carries the + * user's answer back to the daemon. Both key on the daemon interaction id. + */ +const interactionEvents = ( + state: ZcodeConversationState, + interaction: ZcodeRecord, +): ReadonlyArray => { + const interactionId = text(interaction, "interactionId"); + if (interactionId === undefined || state.announcedInteractions.has(interactionId)) return []; + const payload = field(interaction, "payload"); + const kind = text(interaction, "kind") ?? text(payload, "kind"); + const events: ZcodeConversationEvent[] = []; + if (kind === "permission") { + events.push({ + type: "permission.requested", + payload: { + requestId: interactionId, + toolCallId: text(payload, "toolCallId"), + toolName: text(payload, "toolName"), + reason: text(payload, "summary"), + options: permissionOptions(payload), + }, + }); + events.push({ + type: "server.request", + payload: { + method: "interaction/requestPermission", + params: { + requestId: interactionId, + toolCallId: text(payload, "toolCallId"), + toolName: text(payload, "toolName"), + }, + }, + }); + } else if (kind === "userInput") { + events.push({ + type: "userInput.requested", + payload: { + requestId: interactionId, + questions: userInputQuestions(payload), + }, + }); + events.push({ + type: "server.request", + payload: { + method: "interaction/requestUserInput", + params: { requestId: interactionId }, + }, + }); + } + state.announcedInteractions.add(interactionId); + return events; +}; + +/** + * Translates a session-layer answer into the daemon's `resolveInteraction` shape. + * Questions are answered by their prompt text in the daemon's answers map. + */ +export const interactionAnswer = ( + interaction: ZcodeRecord | undefined, + result: unknown, +): ZcodeRecord | undefined => { + const wanted = Predicate.isObject(result) ? result : {}; + const optionId = text(wanted, "optionId"); + if (optionId !== undefined) return { optionId }; + if (field(wanted, "cancelled") === true) return { action: "cancel" }; + const answers = field(wanted, "answers"); + if (Predicate.isObject(answers)) return { action: "accept", content: { answers } }; + const value = text(wanted, "value"); + if (value !== undefined) { + const payload = field(interaction ?? {}, "payload"); + const questions = field(payload, "questions"); + const question = Array.isArray(questions) ? questions[0] : undefined; + // AskUserQuestion interactions resolve through the elicitation path: the daemon expects + // the tool's answers map, not a permission option id. + const questionText = text(question, "question"); + if (questionText !== undefined) { + return { action: "accept", content: { answers: { [questionText]: value } } }; + } + const candidates = field(payload, "options"); + const match = Array.isArray(candidates) + ? candidates.find((candidate) => text(candidate, "label") === value) + : undefined; + const matchedId = text(match, "optionId") ?? text(match, "value"); + return matchedId === undefined ? { freeText: value } : { optionId: matchedId }; + } + if (text(wanted, "decision") === "deny") return { action: "decline" }; + return undefined; +}; + +const interactionResolvedEvents = ( + interaction: ZcodeRecord, +): ReadonlyArray => { + const interactionId = text(interaction, "interactionId"); + if (interactionId === undefined) return []; + const kind = text(interaction, "kind") ?? text(field(interaction, "payload"), "kind"); + return [ + { + type: kind === "userInput" ? "userInput.resolved" : "permission.resolved", + payload: { requestId: interactionId }, + }, + ]; +}; + +const turnOutputTokens = (state: ZcodeConversationState): number | undefined => { + let latest: { rowId: number; row: ZcodeRecord } | undefined; + for (const [rowId, row] of state.rows) { + if (text(row, "kind") !== "turnHeader") continue; + if (latest === undefined || rowId > latest.rowId) latest = { rowId, row }; + } + return latest === undefined ? undefined : number(latest.row, "outputTokens"); +}; + +const controlEvents = ( + state: ZcodeConversationState, + control: unknown, +): ReadonlyArray => { + const phase = text(control, "phase"); + if (phase === undefined) return []; + const previousPhase = state.phase; + readControl(state, control); + if (phase === previousPhase) return []; + if (phase === "running" || phase === "prewarming") { + state.lastAssistantText = undefined; + return [{ type: "turn.started", payload: {} }]; + } + if (previousPhase !== "running" && previousPhase !== "prewarming") return []; + const tokenCount = turnOutputTokens(state); + switch (phase) { + case "completedSuccess": + return [ + { + type: "turn.completed", + payload: { + resultType: "success", + ...(state.lastAssistantText === undefined ? {} : { response: state.lastAssistantText }), + ...(tokenCount === undefined ? {} : { tokenCount }), + }, + }, + ]; + case "completedInterrupted": + return [{ type: "turn.completed", payload: { resultType: "cancelled" } }]; + case "error": + return [ + { + type: "turn.failed", + payload: { + error: { + message: text(field(control, "lastError"), "message") ?? "ZCode turn failed.", + }, + }, + }, + ]; + default: + return []; + } +}; + +/** + * Folds one v4 conversation payload into `state` and returns the session events it produces. + * Mutates `state`; callers keep exactly one state per native session. + */ +export function applyConversationPayload( + state: ZcodeConversationState, + payload: unknown, + options: { readonly seedRows?: boolean } = {}, +): ReadonlyArray { + const kind = text(payload, "kind"); + if (kind === "snapshot") { + const snapshot = field(payload, "snapshot"); + const revision = number(snapshot, "revision"); + if (revision !== undefined) state.revision = revision; + readConfig(state, field(snapshot, "config")); + readControl(state, field(snapshot, "control")); + const events: ZcodeConversationEvent[] = []; + const rows = field(field(snapshot, "rows"), "window"); + if (Array.isArray(rows)) { + for (const entry of rows) { + const row = entry as ZcodeRecord; + const rowId = number(row, "rowId"); + if (rowId === undefined) continue; + state.rows.set(rowId, row); + if (options.seedRows !== true) { + events.push(...rowEvents(state, row, undefined, undefined)); + } else if (text(row, "kind") === "assistantText") { + const rowText = text(row, "text"); + if (rowText !== undefined && rowText.length > 0) state.lastAssistantText = rowText; + } + } + } + const pending = field(snapshot, "pendingInteractions"); + if (Array.isArray(pending)) { + for (const interaction of pending) { + if (!Predicate.isObject(interaction)) continue; + const id = text(interaction, "interactionId"); + if (id === undefined) continue; + state.pendingInteractions.set(id, interaction); + events.push(...interactionEvents(state, interaction)); + } + } + return events; + } + + if (kind !== "deltas") return []; + const deltas = field(payload, "deltas"); + if (!Array.isArray(deltas)) return []; + const events: ZcodeConversationEvent[] = []; + + for (const entry of deltas) { + const op = text(entry, "op"); + if (op === undefined) continue; + if (op === "state.updated") { + const patch = field(entry, "patch"); + const revision = number(patch, "revision"); + if (revision !== undefined) state.revision = revision; + readConfig(state, field(patch, "config")); + const control = field(patch, "control"); + if (control !== undefined) events.push(...controlEvents(state, control)); + const pending = field(patch, "pendingInteractions"); + if (Array.isArray(pending)) { + const next = new Map(); + for (const interaction of pending) { + if (!Predicate.isObject(interaction)) continue; + const id = text(interaction, "interactionId"); + if (id === undefined) continue; + next.set(id, interaction); + const known = state.pendingInteractions.get(id); + if (known === undefined) events.push(...interactionEvents(state, interaction)); + } + for (const [id, previous] of state.pendingInteractions) { + if (next.has(id)) continue; + events.push(...interactionResolvedEvents(previous)); + } + state.pendingInteractions.clear(); + for (const [id, interaction] of next) state.pendingInteractions.set(id, interaction); + } + continue; + } + if (op === "row.delta") { + const rowId = number(entry, "rowId"); + const append = text(entry, "append"); + const path = text(entry, "path") ?? "text"; + if (rowId === undefined || append === undefined) continue; + const row = state.rows.get(rowId); + if (row === undefined) continue; + const rowKind = text(row, "kind"); + if (rowKind === "toolCall") { + if (path !== "output.text") continue; + const accumulated = (state.toolOutput.get(rowId) ?? "") + append; + state.toolOutput.set(rowId, accumulated); + events.push({ + type: "tool.updated", + payload: { + toolCallId: text(row, "toolCallId") ?? `row-${rowId}`, + toolName: text(row, "toolName"), + input: field(row, "input"), + kind: "progress", + outputPreview: { text: accumulated }, + }, + }); + continue; + } + if (path !== "text") continue; + const streamed = { ...row, text: (text(row, "text") ?? "") + append }; + state.rows.set(rowId, streamed); + events.push(...rowEvents(state, streamed, row, append)); + continue; + } + if (op === "row.appended" || op === "row.upserted") { + const row = field(entry, "row"); + if (!Predicate.isObject(row)) continue; + const rowId = number(row, "rowId"); + if (rowId === undefined) continue; + const previous = state.rows.get(rowId); + state.rows.set(rowId, row); + events.push( + ...rowEvents(state, row, op === "row.appended" ? undefined : previous, undefined), + ); + continue; + } + if (op === "row.removed") { + const fromRowId = number(entry, "fromRowId"); + if (fromRowId === undefined) continue; + const removed: number[] = []; + for (const rowId of state.rows.keys()) { + if (rowId >= fromRowId) removed.push(rowId); + } + for (const rowId of removed) state.rows.delete(rowId); + } + } + + return events; +} diff --git a/packages/provider-zcode/src/server/zcodeServeDaemon.ts b/packages/provider-zcode/src/server/zcodeServeDaemon.ts new file mode 100644 index 0000000000000..b4154cd859c1c --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeServeDaemon.ts @@ -0,0 +1,234 @@ +// `zcode serve` is a user-level daemon: it owns CLI credentials, runs the agent runtime and +// exposes the channel WebSocket T3 attaches to. T3 never spawns a per-session agent and never +// reads the credential store; it only makes sure the daemon is up and asks for a host grant. +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +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 HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import { ZcodeProtocolError } from "./zcodeProtocol.ts"; +import { resolveZcodeLaunchCommand, zcodeDataRoot, zcodeServeArgs } from "./zcodeRuntime.ts"; + +const STATUS_FILE = "status.json"; +const TOKEN_FILE = "ingress-token"; +const STATUS_READY = "ready"; +const STARTUP_TIMEOUT = Duration.seconds(30); +const STARTUP_POLL = Duration.millis(250); +const PROBE_TIMEOUT = Duration.seconds(2); + +const ZcodeServerStatus = Schema.Struct({ + state: Schema.String, + host: Schema.String, + port: Schema.Int, + version: Schema.optional(Schema.String), + pid: Schema.optional(Schema.Int), +}); +export type ZcodeServerStatus = typeof ZcodeServerStatus.Type; +const decodeStatus = Schema.decodeUnknownOption(Schema.fromJsonString(ZcodeServerStatus)); + +const ZcodeHostCapability = Schema.Struct({ + capability: Schema.String, + expiresAt: Schema.optional(Schema.Number), +}); + +function zcodeServerRoot(environment: NodeJS.ProcessEnv): string | undefined { + const root = zcodeDataRoot(environment); + return root === undefined ? undefined : `${root}/.zcode/server`; +} + +const readZcodeServerStatus = Effect.fn("ZcodeServe.readStatus")(function* ( + fileSystem: FileSystem.FileSystem, + serverRoot: string, +) { + const raw = yield* fileSystem + .readFileString(`${serverRoot}/run/${STATUS_FILE}`) + .pipe(Effect.option); + if (Option.isNone(raw)) return Option.none(); + const status = decodeStatus(raw.value); + if (Option.isNone(status)) return Option.none(); + return status.value.state === STATUS_READY ? Option.some(status.value) : Option.none(); +}); + +const readIngressToken = Effect.fn("ZcodeServe.readIngressToken")(function* ( + fileSystem: FileSystem.FileSystem, + serverRoot: string, +) { + const raw = yield* fileSystem + .readFileString(`${serverRoot}/run/${TOKEN_FILE}`) + .pipe(Effect.option); + if (Option.isNone(raw)) return undefined; + const token = raw.value.trim(); + return token.length === 0 ? undefined : token; +}); + +export const zcodeChannelUrl = (status: ZcodeServerStatus, token: string): string => + `ws://${status.host}:${status.port}/ws/host?token=${encodeURIComponent(token)}`; + +/** A saved `ready` status outlives a crashed daemon, so probe its HTTP endpoint before trusting it. */ +const zcodeServerResponds = Effect.fn("ZcodeServe.probe")(function* ( + httpClient: HttpClient.HttpClient, + status: ZcodeServerStatus, +) { + const request = HttpClientRequest.get(`http://${status.host}:${status.port}/api/server-info`); + return yield* httpClient.execute(request).pipe( + Effect.timeoutOption(PROBE_TIMEOUT), + Effect.map( + (response) => + Option.isSome(response) && response.value.status >= 200 && response.value.status < 300, + ), + Effect.orElseSucceed(() => false), + ); +}); + +const issueZcodeHostCapability = Effect.fn("ZcodeServe.issueHostCapability")(function* ( + httpClient: HttpClient.HttpClient, + status: ZcodeServerStatus, + token: string, +) { + const request = HttpClientRequest.post( + `http://${status.host}:${status.port}/api/rpc-host-capability`, + { headers: { authorization: `Bearer ${token}` } }, + ); + const response = yield* httpClient + .execute(request) + .pipe( + Effect.mapError((cause) => new ZcodeProtocolError({ operation: "hostCapability", cause })), + ); + if (response.status < 200 || response.status >= 300) { + return yield* new ZcodeProtocolError({ + operation: "hostCapability", + detail: `zcode server refused the host grant (HTTP ${response.status})`, + }); + } + const body = yield* HttpClientResponse.schemaBodyJson(ZcodeHostCapability)(response).pipe( + Effect.mapError((cause) => new ZcodeProtocolError({ operation: "hostCapability", cause })), + ); + return body.capability; +}); + +const spawnZcodeServer = Effect.fn("ZcodeServe.spawn")(function* (input: { + readonly launchCommand: string; + readonly launchArgs: string; + readonly environment: NodeJS.ProcessEnv; +}) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const platform = yield* HostProcess.Platform; + const command = yield* resolveSpawnCommand( + input.launchCommand, + zcodeServeArgs(input.launchArgs), + { + env: input.environment, + }, + ).pipe(Effect.mapError((cause) => new ZcodeProtocolError({ operation: "serve", cause }))); + // `serve` forks the supervisor and exits, so the handle belongs to a detached fiber: the + // daemon outlives this agent scope and must never be reaped by it. + yield* Effect.forkDetach( + Effect.scoped( + Effect.gen(function* () { + const child = yield* spawner + .spawn( + ChildProcess.make(command.command, command.args, { + env: input.environment, + extendEnv: false, + shell: command.shell, + detached: platform !== "win32", + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }), + ) + .pipe(Effect.mapError((cause) => new ZcodeProtocolError({ operation: "serve", cause }))); + yield* child.exitCode.pipe(Effect.ignore); + }), + ), + ); +}); + +/** Attaches to the running daemon, starting it when this machine has none. */ +const ensureZcodeServer = Effect.fn("ZcodeServe.ensure")(function* (input: { + readonly settings: { readonly binaryPath: string; readonly launchArgs: string }; + readonly environment: NodeJS.ProcessEnv; + readonly fileSystem: FileSystem.FileSystem; + readonly httpClient: HttpClient.HttpClient; +}) { + const serverRoot = zcodeServerRoot(input.environment); + if (serverRoot === undefined) { + return yield* new ZcodeProtocolError({ + operation: "serve", + detail: "ZCode has no data directory to host a server.", + }); + } + const running = yield* readZcodeServerStatus(input.fileSystem, serverRoot); + if (Option.isSome(running)) { + if (yield* zcodeServerResponds(input.httpClient, running.value)) { + return { serverRoot, status: running.value }; + } + yield* Effect.logDebug("Ignoring a stale zcode server status.", { serverRoot }); + } + + const launchCommand = yield* resolveZcodeLaunchCommand( + input.settings, + input.environment, + input.fileSystem, + ).pipe(Effect.mapError((cause) => new ZcodeProtocolError({ operation: "serve", cause }))); + yield* spawnZcodeServer({ + launchCommand, + launchArgs: input.settings.launchArgs, + environment: input.environment, + }).pipe( + Effect.catch((error) => + Effect.logWarning("ZCode server did not start.", { operation: error.operation }), + ), + ); + + const started = yield* Effect.gen(function* () { + for (;;) { + const status = yield* readZcodeServerStatus(input.fileSystem, serverRoot); + if (Option.isSome(status)) { + if (yield* zcodeServerResponds(input.httpClient, status.value)) return status.value; + } + yield* Effect.sleep(STARTUP_POLL); + } + }).pipe(Effect.timeoutOption(STARTUP_TIMEOUT)); + + if (Option.isNone(started)) { + return yield* new ZcodeProtocolError({ + operation: "serve", + detail: "zcode serve did not report a ready daemon.", + }); + } + return { serverRoot, status: started.value }; +}); + +export interface ZcodeServerConnectionInfo { + readonly serverRoot: string; + readonly status: ZcodeServerStatus; + readonly token: string; + readonly capability: string; +} + +export const connectZcodeServer = Effect.fn("ZcodeServe.connect")(function* (input: { + readonly settings: { readonly binaryPath: string; readonly launchArgs: string }; + readonly environment: NodeJS.ProcessEnv; + readonly fileSystem: FileSystem.FileSystem; + readonly httpClient: HttpClient.HttpClient; +}) { + const { serverRoot, status } = yield* ensureZcodeServer(input); + const token = yield* readIngressToken(input.fileSystem, serverRoot); + if (token === undefined) { + return yield* new ZcodeProtocolError({ + operation: "serve", + detail: "zcode server has no ingress token.", + }); + } + const capability = yield* issueZcodeHostCapability(input.httpClient, status, token); + return { serverRoot, status, token, capability } satisfies ZcodeServerConnectionInfo; +}); diff --git a/packages/provider-zcode/src/server/zcodeServeProtocol.test.ts b/packages/provider-zcode/src/server/zcodeServeProtocol.test.ts new file mode 100644 index 0000000000000..52c01551281dd --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeServeProtocol.test.ts @@ -0,0 +1,126 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + decodeChannelFrameBody, + encodeChannelFrame, + makeChannelFramer, +} from "./zcodeServeProtocol.ts"; + +const HEADER_BYTES = 13; + +/** A server→client frame the client would receive, built the same way the daemon builds it. */ +const incomingFrame = (kind: number, body: Uint8Array): Uint8Array => { + const frame = new Uint8Array(HEADER_BYTES + body.byteLength); + frame[0] = kind; + new DataView(frame.buffer).setUint32(9, body.byteLength); + frame.set(body, HEADER_BYTES); + return frame; +}; + +const messageBody = (header: ReadonlyArray, arg: unknown): Uint8Array => { + const frame = encodeChannelFrame(header, arg); + return frame.slice(HEADER_BYTES); +}; + +describe("channel frame codec", () => { + it("round-trips a call's header and argument", () => { + const arg = [{ workspacePath: "/w", includeDeleted: false }]; + const frame = encodeChannelFrame([100, 3, "zcode-session", "createSession"], arg); + assert.equal(frame[0], 1); + assert.equal(new DataView(frame.buffer).getUint32(9), frame.byteLength - HEADER_BYTES); + assert.deepEqual(decodeChannelFrameBody(frame.slice(HEADER_BYTES)), { + type: 100, + id: 3, + data: arg, + }); + }); + + it("round-trips every serialized value type", () => { + const arg = { + text: "héllo", + count: 300, + list: [1, "two", null], + nested: { deep: [null, { ok: true }] }, + }; + const decoded = decodeChannelFrameBody(messageBody([204, 7], arg)); + assert.deepEqual(decoded, { type: 204, id: 7, data: arg }); + + const buffer = new Uint8Array([1, 2, 3]); + const withBuffer = decodeChannelFrameBody(messageBody([201, 1], buffer)); + assert.ok(withBuffer !== undefined); + assert.deepEqual([...(withBuffer.data as Uint8Array)], [1, 2, 3]); + + const withUndefined = decodeChannelFrameBody(messageBody([201, 2], undefined)); + assert.deepEqual(withUndefined, { type: 201, id: 2, data: undefined }); + }); + + it("reads an init frame without an argument", () => { + assert.deepEqual(decodeChannelFrameBody(messageBody([200, 0], undefined)), { + type: 200, + id: 0, + data: undefined, + }); + }); + + it("rejects an unknown tag or a truncated body", () => { + assert.throws(() => decodeChannelFrameBody(Uint8Array.of(9)), /unknown tag 9/); + assert.throws(() => decodeChannelFrameBody(Uint8Array.of(1, 5, 65)), /truncated/); + }); +}); + +describe("channel framer", () => { + it("reassembles frames split across arbitrary chunks", () => { + const first = encodeChannelFrame([201, 1], ["first"]); + const second = encodeChannelFrame([204, 2], { delta: "second" }); + const joined = new Uint8Array(first.byteLength + second.byteLength); + joined.set(first, 0); + joined.set(second, first.byteLength); + + const framer = makeChannelFramer(); + const bodies = [ + ...framer(joined.slice(0, 5)), + ...framer(joined.slice(5, HEADER_BYTES + 2)), + ...framer(joined.slice(HEADER_BYTES + 2, joined.byteLength - 1)), + ...framer(joined.slice(joined.byteLength - 1)), + ]; + assert.equal(bodies.length, 2); + assert.deepEqual(decodeChannelFrameBody(bodies[0]!), { type: 201, id: 1, data: ["first"] }); + assert.deepEqual(decodeChannelFrameBody(bodies[1]!), { + type: 204, + id: 2, + data: { delta: "second" }, + }); + }); + + it("emits every complete frame carried by one chunk", () => { + const first = encodeChannelFrame([204, 1], "a"); + const second = encodeChannelFrame([204, 2], "b"); + const joined = new Uint8Array(first.byteLength + second.byteLength); + joined.set(first, 0); + joined.set(second, first.byteLength); + assert.equal(makeChannelFramer()(joined).length, 2); + }); + + it("drops frames whose kind is not a message and resumes at the next header", () => { + const other = incomingFrame(7, new Uint8Array([0])); + const message = encodeChannelFrame([204, 4], "payload"); + const joined = new Uint8Array(other.byteLength + message.byteLength); + joined.set(other, 0); + joined.set(message, other.byteLength); + + const bodies = makeChannelFramer()(joined); + assert.equal(bodies.length, 1); + assert.deepEqual(decodeChannelFrameBody(bodies[0]!), { + type: 204, + id: 4, + data: "payload", + }); + }); + + it("keeps a partial frame buffered", () => { + const framer = makeChannelFramer(); + const frame = encodeChannelFrame([204, 5], "later"); + assert.deepEqual(framer(frame.slice(0, HEADER_BYTES - 1)), []); + assert.equal(framer(frame.slice(HEADER_BYTES - 1)).length, 1); + }); +}); diff --git a/packages/provider-zcode/src/server/zcodeServeProtocol.ts b/packages/provider-zcode/src/server/zcodeServeProtocol.ts new file mode 100644 index 0000000000000..625884141998c --- /dev/null +++ b/packages/provider-zcode/src/server/zcodeServeProtocol.ts @@ -0,0 +1,460 @@ +// `zcode serve` hosts a daemon that owns CLI credentials and multiplexes sessions over a +// binary WebSocket channel protocol. This module is the wire half: frame codec plus a +// request/event client. The frame layout was reverse-engineered from the shipped desktop +// client (runtime 3.17.2); it is undocumented and pinned to that runtime. +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 Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; + +import { ZcodeProtocolError, ZcodeProtocolTimeoutError } from "./zcodeProtocol.ts"; + +/** 1 byte frame kind, u32 request id, u32 ack, u32 body length. */ +const FRAME_HEADER_BYTES = 13; +const FRAME_KIND_MESSAGE = 1; + +const CALL = 100; +const LISTEN = 102; +const INITIALIZED = 200; +const RESULT = 201; +const ERROR = 202; +const REJECTED = 203; +const EVENT = 204; + +const TAG_UNDEFINED = 0; +const TAG_STRING = 1; +const TAG_BUFFER = 2; +const TAG_ARRAY = 4; +const TAG_OBJECT = 5; +const TAG_INT = 6; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const writeVarint = (bytes: number[], value: number): void => { + if (value === 0) { + bytes.push(0); + return; + } + for (let rest = value; rest !== 0; rest = Math.floor(rest / 128)) { + const byte = rest & 0x7f; + bytes.push(rest > 0x7f ? byte | 0x80 : byte); + } +}; + +const appendBytes = (bytes: number[], chunk: Uint8Array): void => { + for (const byte of chunk) bytes.push(byte); +}; + +const serializeInto = (bytes: number[], value: unknown): void => { + if (value === undefined) { + bytes.push(TAG_UNDEFINED); + return; + } + if (typeof value === "string") { + const encoded = textEncoder.encode(value); + bytes.push(TAG_STRING); + writeVarint(bytes, encoded.byteLength); + appendBytes(bytes, encoded); + return; + } + if (value instanceof Uint8Array) { + bytes.push(TAG_BUFFER); + writeVarint(bytes, value.byteLength); + appendBytes(bytes, value); + return; + } + if (Array.isArray(value)) { + bytes.push(TAG_ARRAY); + writeVarint(bytes, value.length); + for (const element of value) serializeInto(bytes, element); + return; + } + if (typeof value === "number" && Number.isInteger(value)) { + bytes.push(TAG_INT); + writeVarint(bytes, value); + return; + } + const encoded = textEncoder.encode(JSON.stringify(value) ?? "null"); + bytes.push(TAG_OBJECT); + writeVarint(bytes, encoded.byteLength); + appendBytes(bytes, encoded); +}; + +class Reader { + private offset = 0; + private readonly bytes: Uint8Array; + + constructor(bytes: Uint8Array) { + this.bytes = bytes; + } + + read(count: number): Uint8Array { + const out = this.bytes.subarray(this.offset, this.offset + count); + if (out.byteLength !== count) throw new Error("zcode channel frame truncated"); + this.offset += count; + return out; + } +} + +const readVarint = (reader: Reader): number => { + let value = 0; + for (let shift = 0; ; shift += 7) { + const [byte = 0] = reader.read(1); + value += (byte & 0x7f) * 2 ** shift; + if ((byte & 0x80) === 0) return value; + } +}; + +const deserializeFrom = (reader: Reader): unknown => { + const [tag = -1] = reader.read(1); + switch (tag) { + case TAG_UNDEFINED: + return undefined; + case TAG_STRING: + return textDecoder.decode(reader.read(readVarint(reader))); + case TAG_BUFFER: + return reader.read(readVarint(reader)); + case TAG_ARRAY: { + const length = readVarint(reader); + return Array.from({ length }, () => deserializeFrom(reader)); + } + case TAG_OBJECT: + return JSON.parse(textDecoder.decode(reader.read(readVarint(reader)))); + case TAG_INT: + return readVarint(reader); + default: + throw new Error(`zcode channel frame has unknown tag ${tag}`); + } +}; + +/** One outbound message frame: `` then the serialized body. */ +export function encodeChannelFrame( + header: ReadonlyArray, + arg: unknown, +): Uint8Array { + const body: number[] = []; + serializeInto(body, header); + serializeInto(body, arg); + const buffer = new ArrayBuffer(FRAME_HEADER_BYTES + body.length); + const frame = new Uint8Array(buffer); + frame[0] = FRAME_KIND_MESSAGE; + const view = new DataView(buffer); + view.setUint32(1, 0); + view.setUint32(5, 0); + view.setUint32(9, body.length); + frame.set(Uint8Array.from(body), FRAME_HEADER_BYTES); + return frame; +} + +export interface ZcodeChannelFrame { + readonly type: number; + readonly id: number; + readonly data: unknown; +} + +/** Parses a message body; frames other than messages carry nothing we consume. */ +export function decodeChannelFrameBody(body: Uint8Array): ZcodeChannelFrame | undefined { + const reader = new Reader(body); + const header = deserializeFrom(reader); + if (!Array.isArray(header)) return undefined; + const [type, id] = header; + if (typeof type !== "number") return undefined; + if (type === INITIALIZED) return { type, id: 0, data: undefined }; + return { type, id: typeof id === "number" ? id : 0, data: deserializeFrom(reader) }; +} + +/** Reassembles WebSocket frames into channel message bodies. */ +export function makeChannelFramer(): (chunk: Uint8Array) => ReadonlyArray { + const chunks: Uint8Array[] = []; + let length = 0; + + const take = (count: number): Uint8Array => { + const out = new Uint8Array(count); + let offset = 0; + for (const chunk of chunks) { + const slice = chunk.subarray(0, Math.min(chunk.byteLength, count - offset)); + out.set(slice, offset); + offset += slice.byteLength; + if (offset === count) break; + } + let remaining = count; + while (remaining > 0) { + const head = chunks[0]; + if (head === undefined) break; + if (head.byteLength <= remaining) { + remaining -= head.byteLength; + chunks.shift(); + } else { + chunks[0] = head.subarray(remaining); + remaining = 0; + } + } + length -= count; + return out; + }; + + const peekHeader = (): Uint8Array => { + const out = new Uint8Array(FRAME_HEADER_BYTES); + let offset = 0; + for (const chunk of chunks) { + const slice = chunk.subarray(0, Math.min(chunk.byteLength, FRAME_HEADER_BYTES - offset)); + out.set(slice, offset); + offset += slice.byteLength; + if (offset === FRAME_HEADER_BYTES) break; + } + return out; + }; + + return (chunk) => { + if (chunk.byteLength > 0) { + chunks.push(chunk); + length += chunk.byteLength; + } + const bodies: Uint8Array[] = []; + for (;;) { + if (length < FRAME_HEADER_BYTES) break; + const header = peekHeader(); + const bodyLength = + ((header[9] ?? 0) * 0x1000000 + + ((header[10] ?? 0) << 16) + + ((header[11] ?? 0) << 8) + + (header[12] ?? 0)) >>> + 0; + if (length < FRAME_HEADER_BYTES + bodyLength) break; + take(FRAME_HEADER_BYTES); + const body = bodyLength === 0 ? new Uint8Array(0) : take(bodyLength); + if (header[0] === FRAME_KIND_MESSAGE) bodies.push(body); + } + return bodies; + }; +} + +export interface ZcodeChannelClient { + readonly call: ( + channel: string, + method: string, + arg: unknown, + timeoutMs?: number, + ) => Effect.Effect; + readonly listen: ( + channel: string, + event: string, + arg: unknown, + onEvent: (payload: unknown) => void, + ) => Effect.Effect; + /** Resolves when the socket closes; never fails, so finalizers stay quiet. */ + readonly closed: Effect.Effect; +} + +export interface ZcodeChannelClientOptions { + readonly url: string; + readonly headers?: Readonly>; +} + +interface PendingCall { + readonly deferred: Deferred.Deferred; + readonly method: string; +} + +const DEFAULT_CALL_TIMEOUT_MS = 60_000; +const INITIALIZE_TIMEOUT_MS = 15_000; + +/** + * Node's global WebSocket (undici) accepts request headers; the DOM lib typing does not. + * `/ws/host` rejects the handshake without the capability header, so the cast is required. + */ +const openSocket = (url: string, headers: Readonly>): WebSocket => + new (WebSocket as unknown as new ( + url: string, + init: { headers: Record }, + ) => WebSocket)(url, { headers: { ...headers } }); + +export const makeZcodeChannelClient = Effect.fnUntraced(function* ( + options: ZcodeChannelClientOptions, +) { + const scope = yield* Effect.scope; + const pending = new Map(); + const listeners = new Map void>(); + const initialized = yield* Deferred.make(); + const closed = yield* Deferred.make(); + const initQueue = yield* Queue.unbounded(); + const results = yield* Queue.unbounded<{ + readonly call: PendingCall; + readonly data: unknown; + readonly failed: boolean; + }>(); + const failures = yield* Queue.unbounded(); + const socketClosures = yield* Queue.unbounded(); + const frame = makeChannelFramer(); + let nextId = 0; + + const transportError = (operation: string, cause: unknown) => + new ZcodeProtocolError({ operation, cause }); + + const failAll = Effect.fn("ZcodeChannel.failAll")(function* (error: ZcodeProtocolError) { + yield* Deferred.fail(initialized, error).pipe(Effect.ignore); + for (const [id, call] of pending) { + pending.delete(id); + yield* Deferred.fail(call.deferred, error).pipe(Effect.ignore); + } + yield* Deferred.succeed(closed, undefined).pipe(Effect.ignore); + }); + + const socket = openSocket(options.url, options.headers ?? {}); + socket.binaryType = "arraybuffer"; + + const route = (parsed: ZcodeChannelFrame): void => { + if (parsed.type === INITIALIZED) { + Queue.offerUnsafe(initQueue, undefined); + return; + } + if (parsed.type === EVENT) { + listeners.get(parsed.id)?.(parsed.data); + return; + } + const call = pending.get(parsed.id); + if (call === undefined) return; + pending.delete(parsed.id); + if (parsed.type === RESULT) { + Queue.offerUnsafe(results, { call, data: parsed.data, failed: false }); + return; + } + if (parsed.type === ERROR || parsed.type === REJECTED) { + Queue.offerUnsafe(results, { call, data: parsed.data, failed: true }); + } + }; + + const onMessage = (event: MessageEvent) => { + const data: unknown = event.data; + const bytes = + data instanceof ArrayBuffer + ? new Uint8Array(data) + : ArrayBuffer.isView(data) + ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + : undefined; + if (bytes === undefined) return; + for (const body of frame(bytes)) { + try { + const parsed = decodeChannelFrameBody(body); + if (parsed !== undefined) route(parsed); + } catch (cause) { + Queue.offerUnsafe(failures, cause); + } + } + }; + + socket.addEventListener("message", onMessage); + socket.addEventListener("close", (event) => + Queue.offerUnsafe(socketClosures, `code ${event.code}`), + ); + socket.addEventListener("error", () => Queue.offerUnsafe(socketClosures, "socket error")); + + yield* Scope.addFinalizer( + scope, + Effect.sync(() => { + socket.removeEventListener("message", onMessage); + socket.close(); + }), + ); + + // Settlement and closure handling run off the callback path so socket callbacks stay cheap. + yield* Effect.gen(function* () { + for (;;) { + const settled = yield* Queue.take(results); + yield* settled.failed + ? Deferred.fail( + settled.call.deferred, + transportError(settled.call.method, settled.data), + ).pipe(Effect.ignore) + : Deferred.succeed(settled.call.deferred, settled.data).pipe(Effect.ignore); + } + }).pipe(Effect.forkIn(scope), Effect.asVoid); + + yield* Effect.gen(function* () { + for (;;) { + const reason = yield* Queue.take(socketClosures); + yield* failAll(transportError("connect", `zcode server channel closed (${reason})`)); + } + }).pipe(Effect.forkIn(scope), Effect.asVoid); + + yield* Effect.gen(function* () { + for (;;) { + const cause = yield* Queue.take(failures); + yield* failAll(transportError("decode", cause)); + } + }).pipe(Effect.forkIn(scope), Effect.asVoid); + + yield* Effect.raceFirst( + Queue.take(initQueue).pipe( + Effect.timeoutOption(Duration.millis(INITIALIZE_TIMEOUT_MS)), + Effect.flatMap((ready) => + Option.isSome(ready) + ? Deferred.succeed(initialized, true).pipe(Effect.ignore) + : failAll(transportError("initialize", "zcode server channel handshake timed out")), + ), + ), + Deferred.await(closed), + ).pipe(Effect.ignore, Effect.forkIn(scope), Effect.asVoid); + + const awaitInitialized = Deferred.await(initialized); + + const send = (header: ReadonlyArray, arg: unknown) => + Effect.suspend(() => { + if (socket.readyState !== WebSocket.OPEN) { + return Effect.fail(transportError("send", "zcode server channel is not open")); + } + socket.send(encodeChannelFrame(header, arg)); + return Effect.void; + }); + + const call = Effect.fn("ZcodeChannel.call")(function* ( + channel: string, + method: string, + arg: unknown, + timeoutMs = DEFAULT_CALL_TIMEOUT_MS, + ): Effect.fn.Return { + yield* awaitInitialized; + const id = nextId++; + const deferred = yield* Deferred.make(); + pending.set(id, { deferred, method: `${channel}.${method}` }); + yield* send([CALL, id, channel, method], [arg]).pipe( + Effect.onError(() => Effect.sync(() => pending.delete(id))), + ); + return yield* Deferred.await(deferred).pipe( + Effect.timeoutOrElse({ + duration: Duration.millis(timeoutMs), + orElse: () => + Effect.fail( + new ZcodeProtocolTimeoutError({ operation: `${channel}.${method}`, timeoutMs }), + ), + }), + Effect.onInterrupt(() => Effect.sync(() => pending.delete(id))), + Effect.onError(() => Effect.sync(() => pending.delete(id))), + ); + }); + + const listen = Effect.fn("ZcodeChannel.listen")(function* ( + channel: string, + event: string, + arg: unknown, + onEvent: (payload: unknown) => void, + ) { + yield* awaitInitialized; + const id = nextId++; + listeners.set(id, onEvent); + yield* Scope.addFinalizer( + scope, + Effect.sync(() => void listeners.delete(id)), + ); + yield* send([LISTEN, id, channel, event], arg); + }); + + return { + call, + listen, + closed: Deferred.await(closed), + } satisfies ZcodeChannelClient; +}); diff --git a/packages/provider-zcode/src/settings.ts b/packages/provider-zcode/src/settings.ts new file mode 100644 index 0000000000000..3b3be92cbd386 --- /dev/null +++ b/packages/provider-zcode/src/settings.ts @@ -0,0 +1,42 @@ +// 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 serve` when T3 Code starts the ZCode daemon.", + 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..7112dc6486e2d --- /dev/null +++ b/packages/provider-zcode/src/testing.ts @@ -0,0 +1,12 @@ +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, +} 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",