1076 lines · 39.0 KB · TypeScript
RawDownload
import * as NodeURL from "node:url"; import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts";import {  createOpencodeClient,  type Agent,  type FilePartInput,  type Model,  type OpencodeClient,  type PermissionRuleset,  type ProviderListResponse,  type QuestionAnswer,  type QuestionRequest,} from "@opencode-ai/sdk/v2";import * as Cause from "effect/Cause";import * as Context from "effect/Context";import * as Data from "effect/Data";import * as Deferred from "effect/Deferred";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as Fiber from "effect/Fiber";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as P from "effect/Predicate";import * as Ref from "effect/Ref";import * as Result from "effect/Result";import * as Scope from "effect/Scope";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { isWindowsCommandNotFound } from "../processRunner.ts";import { collectStreamAsString } from "./providerSnapshot.ts";import * as NetService from "@t3tools/shared/Net";import { HostProcessPlatform } from "@t3tools/shared/hostProcess";import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";import { resolveSpawnCommand } from "@t3tools/shared/shell";const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown));const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; export const MINIMUM_OPENCODE_VERSION = "1.14.19";const OPENCODE_HEALTH_TIMEOUT = "5 seconds"; const OpenCodeHealthSchema = Schema.Struct({  healthy: Schema.Literal(true),  version: Schema.String,});const decodeOpenCodeHealth = Schema.decodeUnknownEffect(OpenCodeHealthSchema); export function resolveOpenCodeConfigContent(  inputEnvironment: Readonly<Record<string, string | undefined>> | undefined,  inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env,): string {  return (    inputEnvironment?.OPENCODE_CONFIG_CONTENT ??    inheritedEnvironment.OPENCODE_CONFIG_CONTENT ??    OPENCODE_EMPTY_CONFIG_CONTENT  );} export function resolveOpenCodeServerPassword(  input: {    readonly external: boolean;    readonly serverPassword?: string;    readonly environment?: Readonly<Record<string, string | undefined>>;  },  inheritedEnvironment: Readonly<Record<string, string | undefined>> = process.env,): string | undefined {  if (input.serverPassword !== undefined) {    return input.serverPassword;  }  if (input.external) {    return undefined;  }  return input.environment === undefined    ? inheritedEnvironment.OPENCODE_SERVER_PASSWORD    : input.environment.OPENCODE_SERVER_PASSWORD;} const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000;const DEFAULT_HOSTNAME = "127.0.0.1";const OPENCODE_SERVER_STARTUP_MAX_OUTPUT_CHARS = 64 * 1024;const OPENCODE_SKILL_DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;export interface OpenCodeServerProcess {  readonly url: string;  readonly serverPassword?: string;  readonly version: string;  readonly isRunning: Effect.Effect<boolean>;  readonly exitCode: Effect.Effect<number, never>;} export interface OpenCodeServerConnection {  readonly url: string;  readonly serverPassword?: string;  readonly version: string;  readonly exitCode: Effect.Effect<number, never> | null;  readonly external: boolean;} const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError";export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{  readonly operation: string;  readonly cause?: unknown;  readonly detail: string;}> {  static readonly is = (u: unknown): u is OpenCodeRuntimeError =>    P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG);} function encodeJsonStringForDiagnostics(input: unknown): string | undefined {  const result = encodeUnknownJsonStringExit(input);  return Exit.isSuccess(result) ? result.value : undefined;} export function openCodeRuntimeErrorDetail(cause: unknown): string {  if (OpenCodeRuntimeError.is(cause)) return cause.detail;  if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim();  if (cause && typeof cause === "object") {    // SDK v2 throws { response, request, error? } shapes — extract what's useful    const anyCause = cause as Record<string, unknown>;    const status = (anyCause.response as { status?: number } | undefined)?.status;    const body = anyCause.error ?? anyCause.data ?? anyCause.body;    const encodedBody = encodeJsonStringForDiagnostics(body ?? cause);    if (encodedBody) {      return `status=${status ?? "?"} body=${encodedBody}`;    }  }  return String(cause);} export const runOpenCodeSdk = <A>(  operation: string,  fn: (signal: AbortSignal) => Promise<A>,): Effect.Effect<A, OpenCodeRuntimeError> =>  Effect.tryPromise({    try: fn,    catch: (cause) =>      new OpenCodeRuntimeError({ operation, detail: openCodeRuntimeErrorDetail(cause), cause }),  }).pipe(Effect.withSpan(`opencode.${operation}`)); export const verifyOpenCodeServerVersion = Effect.fn("verifyOpenCodeServerVersion")(function* (  client: OpencodeClient,) {  const healthOption = yield* runOpenCodeSdk("global.health", (signal) =>    client.global.health({ signal }),  ).pipe(Effect.timeoutOption(OPENCODE_HEALTH_TIMEOUT));  if (Option.isNone(healthOption)) {    return yield* new OpenCodeRuntimeError({      operation: "global.health",      detail: "Timed out while checking the OpenCode server version.",    });  }   const health = yield* decodeOpenCodeHealth(healthOption.value.data).pipe(    Effect.mapError(      (cause) =>        new OpenCodeRuntimeError({          operation: "global.health",          detail: `OpenCode server returned an invalid health response. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`,          cause,        }),    ),  );  if (parseSemver(health.version) === null) {    return yield* new OpenCodeRuntimeError({      operation: "global.health",      detail: `OpenCode server returned an invalid version. T3 Code requires OpenCode v${MINIMUM_OPENCODE_VERSION} or newer.`,    });  }  if (compareSemverVersions(health.version, MINIMUM_OPENCODE_VERSION) < 0) {    return yield* new OpenCodeRuntimeError({      operation: "global.health",      detail: `OpenCode v${health.version} is too old. Upgrade to v${MINIMUM_OPENCODE_VERSION} or newer.`,    });  }  return health.version;}); export interface OpenCodeCommandResult {  readonly stdout: string;  readonly stderr: string;  readonly code: number;} export interface OpenCodeInventory {  readonly providerList: ProviderListResponse;  readonly agents: ReadonlyArray<Agent>;  readonly skills: ReadonlyArray<OpenCodeSkill>;} export interface ParsedOpenCodeModelSlug {  readonly providerID: string;  readonly modelID: string;} export interface OpenCodeSkill {  readonly name?: string | null;  readonly description?: string | null;  readonly location?: string | null;} const OpenCodeSkillSchema = Schema.Struct({  name: Schema.optionalKey(Schema.NullOr(Schema.String)),  description: Schema.optionalKey(Schema.NullOr(Schema.String)),  location: Schema.optionalKey(Schema.NullOr(Schema.String)),});const decodeOpenCodeSkillsCliOutputExit = Schema.decodeUnknownExit(  Schema.fromJsonString(Schema.Array(OpenCodeSkillSchema)),); export interface OpenCodeRuntimeShape {  /**   * Spawns a local OpenCode server process. Its lifetime is bound to the caller's   * `Scope.Scope` — the child is killed automatically when that scope closes.   * Consumers that want a long-lived server must create and hold a scope explicitly   * (see {@link Scope.make}) and close it when done.   */  readonly startOpenCodeServerProcess: (input: {    readonly binaryPath: string;    readonly directory: string;    readonly serverPassword?: string;    readonly environment?: NodeJS.ProcessEnv;    readonly port?: number;    readonly hostname?: string;    readonly timeoutMs?: number;  }) => Effect.Effect<OpenCodeServerProcess, OpenCodeRuntimeError, Scope.Scope>;  /**   * Returns a handle to either an externally-managed OpenCode server (when   * `serverUrl` is provided — no lifetime is attached to the caller's scope) or a   * freshly spawned local server whose lifetime is bound to the caller's scope.   */  readonly connectToOpenCodeServer: (input: {    readonly binaryPath: string;    readonly directory: string;    readonly serverUrl?: string | null;    readonly serverPassword?: string;    readonly environment?: NodeJS.ProcessEnv;    readonly port?: number;    readonly hostname?: string;    readonly timeoutMs?: number;  }) => Effect.Effect<OpenCodeServerConnection, OpenCodeRuntimeError, Scope.Scope>;  readonly runOpenCodeCommand: (input: {    readonly binaryPath: string;    readonly args: ReadonlyArray<string>;    readonly environment?: NodeJS.ProcessEnv;    readonly cwd?: string;    readonly maxOutputBytes?: number;  }) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>;  readonly createOpenCodeSdkClient: (input: {    readonly baseUrl: string;    readonly directory: string;    readonly serverPassword?: string;  }) => OpencodeClient;  readonly loadOpenCodeInventory: (    client: OpencodeClient,  ) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;  readonly loadOpenCodeSkills: (    client: OpencodeClient,  ) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>;  readonly loadInventoryFromCli: (input: {    readonly binaryPath: string;    readonly cwd: string;    readonly environment?: NodeJS.ProcessEnv;  }) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;  readonly loadSkillsFromCli: (input: {    readonly binaryPath: string;    readonly cwd: string;    readonly environment?: NodeJS.ProcessEnv;  }) => Effect.Effect<ReadonlyArray<OpenCodeSkill>, OpenCodeRuntimeError>;} function parseServerUrlFromOutput(output: string): string | null {  for (const line of output.split("\n")) {    if (!line.startsWith(OPENCODE_SERVER_READY_PREFIX)) {      continue;    }    const match = line.match(/on\s+(https?:\/\/[^\s]+)/);    return match?.[1] ?? null;  }  return null;} const SLUG_LINE_RE = /^(\S+\/\S+)\s*$/;const AGENT_HEADER_RE = /^(.+)\s+\((\S+)\)\s*$/; // Agents that are always hidden in OpenCode but the CLI "agent list" command// does not expose the hidden flag. Keep in sync with OpenCode agent// definitions (in the OpenCode repo: packages/opencode/src/agent/agent.ts).const KNOWN_HIDDEN_AGENTS = new Set(["compaction", "summary", "title"]); /** @internal */export function parseModelsCliOutput(stdout: string): {  readonly providers: ReadonlyMap<    string,    { readonly id: string; readonly name: string; readonly models: { [key: string]: Model } }  >;  readonly connected: ReadonlyArray<string>;} {  const providers = new Map<    string,    { id: string; name: string; models: { [key: string]: Model } }  >();  const lines = stdout.split("\n");  let currentSlug: string | null = null;  const jsonLines: Array<string> = [];   const flushModel = () => {    if (currentSlug !== null && jsonLines.length > 0) {      const jsonStr = jsonLines.join("\n").trim();      if (jsonStr.length > 0) {        try {          const model = JSON.parse(jsonStr) as Model;          const separator = currentSlug.indexOf("/");          if (separator > 0) {            const providerID = currentSlug.slice(0, separator);            const modelID = currentSlug.slice(separator + 1);            let provider = providers.get(providerID);            if (!provider) {              provider = { id: providerID, name: providerID, models: {} };              providers.set(providerID, provider);            }            provider.models[modelID] = model;          }        } catch {          // Skip unparseable model JSON        }      }    }    currentSlug = null;    jsonLines.length = 0;  };   for (const line of lines) {    // A model's JSON body is a single `JSON.stringify` line starting with `{`,    // while a provider/model slug is a bare `provider/model` header. Only the    // latter can be a slug: without this guard a body line with no interior    // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose    // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against    // an empty body and the model is silently dropped.    const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line);    if (slugMatch) {      flushModel();      currentSlug = slugMatch[1]!;    } else if (currentSlug !== null) {      jsonLines.push(line);    }  }  flushModel();   return { providers, connected: [...providers.keys()] };} /** @internal */export function parseAgentListCliOutput(stdout: string): ReadonlyArray<Agent> {  const agents: Array<Agent> = [];  const lines = stdout.split("\n");  let currentHeader: { name: string; mode: string } | null = null;  const blockLines: Array<string> = [];   const flushAgent = () => {    if (currentHeader !== null) {      const jsonStr = blockLines.join("\n").trim();      if (jsonStr.length > 0) {        try {          const permission = JSON.parse(jsonStr);          agents.push({            name: currentHeader.name,            mode: currentHeader.mode as Agent["mode"],            hidden: KNOWN_HIDDEN_AGENTS.has(currentHeader.name),            permission,            options: {},          });        } catch {          // Skip unparseable agent        }      }    }    currentHeader = null;    blockLines.length = 0;  };   for (const line of lines) {    const match = AGENT_HEADER_RE.exec(line);    if (match) {      flushAgent();      currentHeader = { name: match[1]!, mode: match[2]! };    } else if (currentHeader !== null) {      blockLines.push(line);    }  }  flushAgent();   return agents;} /** @internal */export function parseSkillsCliOutput(stdout: string): ReadonlyArray<OpenCodeSkill> {  const result = decodeOpenCodeSkillsCliOutputExit(stdout);  return Exit.isSuccess(result) ? result.value : [];} export function parseOpenCodeModelSlug(  slug: string | null | undefined,): ParsedOpenCodeModelSlug | null {  if (typeof slug !== "string") {    return null;  }   const trimmed = slug.trim();  const separator = trimmed.indexOf("/");  if (separator <= 0 || separator === trimmed.length - 1) {    return null;  }   return {    providerID: trimmed.slice(0, separator),    modelID: trimmed.slice(separator + 1),  };} export function openCodeQuestionId(  index: number,  question: QuestionRequest["questions"][number],): string {  const header = question.header    .trim()    .toLowerCase()    .replace(/[^a-z0-9_-]+/g, "-");  return header.length > 0 ? `question-${index}-${header}` : `question-${index}`;} /** * Attachments OpenCode can hand to a model as a native file part. Anything * else (ZIP, binaries, image formats like BMP/AVIF/SVG that model APIs * reject, or files over the direct-attachment size limit) would make the turn * fail before it starts, so those ride only as the file path ProviderService * puts in the prompt. */const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);export const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; export function isOpenCodeNativeFilePart(input: {  readonly mimeType: string;  readonly sizeBytes: number;}): boolean {  if (input.sizeBytes > OPENCODE_NATIVE_FILE_PART_MAX_BYTES) {    return false;  }  const normalized = input.mimeType.trim().toLowerCase();  return (    OPENCODE_NATIVE_IMAGE_MIMES.has(normalized) ||    normalized.startsWith("text/") ||    normalized === "application/pdf"  );} export function toOpenCodeFileParts(input: {  readonly attachments: ReadonlyArray<ChatAttachment> | undefined;  readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;}): Array<FilePartInput> {  const parts: Array<FilePartInput> = [];   for (const attachment of input.attachments ?? []) {    if (!isOpenCodeNativeFilePart(attachment)) {      continue;    }    const attachmentPath = input.resolveAttachmentPath(attachment);    if (!attachmentPath) {      continue;    }     parts.push({      type: "file",      mime: attachment.mimeType,      filename: attachment.name,      url: NodeURL.pathToFileURL(attachmentPath).href,    });  }   return parts;} export function toOpenCodePromptParts(input: {  readonly text: string;  readonly attachments: ReadonlyArray<ChatAttachment> | undefined;  readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null;}) {  const nativeFiles = toOpenCodeFileParts(input);  const attachmentPaths = (input.attachments ?? []).flatMap((attachment) => {    const attachmentPath = input.resolveAttachmentPath(attachment);    return attachmentPath === null      ? []      : [`[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`];  });  const text = [input.text.trim(), attachmentPaths.join("\n")].filter(Boolean).join("\n\n");  return [...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...nativeFiles];} export function buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset {

Lines 1–500 of 1076.