750 lines · 25.7 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 { resolveSpawnCommand } from "@t3tools/shared/shell";const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString);const OPENCODE_EMPTY_CONFIG_CONTENT = "{}"; const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 30_000;const DEFAULT_HOSTNAME = "127.0.0.1";export interface OpenCodeServerProcess {  readonly url: string;  readonly exitCode: Effect.Effect<number, never>;} export interface OpenCodeServerConnection {  readonly url: 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: () => 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 interface OpenCodeCommandResult {  readonly stdout: string;  readonly stderr: string;  readonly code: number;} export interface OpenCodeInventory {  readonly providerList: ProviderListResponse;  readonly agents: ReadonlyArray<Agent>;} export interface ParsedOpenCodeModelSlug {  readonly providerID: string;  readonly modelID: string;} 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 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 serverUrl?: string | null;    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;  }) => 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 loadInventoryFromCli: (input: {    readonly binaryPath: string;    readonly environment?: NodeJS.ProcessEnv;  }) => Effect.Effect<OpenCodeInventory, 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) {    const slugMatch = 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;} 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}`;} 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 ?? []) {    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 buildOpenCodePermissionRules(runtimeMode: RuntimeMode): PermissionRuleset {  if (runtimeMode === "full-access") {    return [{ permission: "*", pattern: "*", action: "allow" }];  }   return [    { permission: "*", pattern: "*", action: "ask" },    { permission: "bash", pattern: "*", action: "ask" },    { permission: "edit", pattern: "*", action: "ask" },    { permission: "webfetch", pattern: "*", action: "ask" },    { permission: "websearch", pattern: "*", action: "ask" },    { permission: "codesearch", pattern: "*", action: "ask" },    { permission: "external_directory", pattern: "*", action: "ask" },    { permission: "doom_loop", pattern: "*", action: "ask" },    { permission: "question", pattern: "*", action: "allow" },  ];} export function toOpenCodePermissionReply(  decision: ProviderApprovalDecision,): "once" | "always" | "reject" {  switch (decision) {    case "accept":      return "once";    case "acceptForSession":      return "always";    case "decline":    case "cancel":    default:      return "reject";  }} export function toOpenCodeQuestionAnswers(  request: QuestionRequest,  answers: Record<string, unknown>,): Array<QuestionAnswer> {  return request.questions.map((question, index) => {    const raw =      answers[openCodeQuestionId(index, question)] ??      answers[question.header] ??      answers[question.question];    if (Array.isArray(raw)) {      return raw.filter((value): value is string => typeof value === "string");    }    if (typeof raw === "string") {      return raw.trim().length > 0 ? [raw] : [];    }    return [];  });} function ensureRuntimeError(  operation: OpenCodeRuntimeError["operation"],  detail: string,  cause: unknown,): OpenCodeRuntimeError {  return OpenCodeRuntimeError.is(cause)    ? cause    : new OpenCodeRuntimeError({ operation, detail, cause });} const makeOpenCodeRuntime = Effect.gen(function* () {  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;  const netService = yield* NetService.NetService;  const hostPlatform = yield* HostProcessPlatform;  const resolveCommand = (command: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) =>    resolveSpawnCommand(command, args, env ? { env } : {});   const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) =>    Effect.gen(function* () {      const spawnCommand = yield* resolveCommand(input.binaryPath, input.args, input.environment);      const child = yield* spawner.spawn(        ChildProcess.make(spawnCommand.command, spawnCommand.args, {          shell: spawnCommand.shell,          ...(input.environment ? { env: input.environment } : { extendEnv: true }),        }),      );      const [stdout, stderr, code] = yield* Effect.all(        [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode],        { concurrency: "unbounded" },      );      const exitCode = Number(code);      if (yield* isWindowsCommandNotFound(exitCode, stderr)) {        return yield* new OpenCodeRuntimeError({          operation: "runOpenCodeCommand",          detail: `spawn ${input.binaryPath} ENOENT`,        });      }      return {        stdout,        stderr,        code: exitCode,      } satisfies OpenCodeCommandResult;    }).pipe(      Effect.scoped,      Effect.mapError((cause) =>        ensureRuntimeError(          "runOpenCodeCommand",          `Failed to execute '${input.binaryPath} ${input.args.join(" ")}': ${openCodeRuntimeErrorDetail(cause)}`,          cause,        ),      ),    );   const startOpenCodeServerProcess: OpenCodeRuntimeShape["startOpenCodeServerProcess"] = (input) =>    Effect.gen(function* () {      // Bind this server's lifetime to the caller's scope. When the caller's      // scope closes, the spawned child is killed and all associated fibers      // are interrupted automatically — no `close()` method needed.      const runtimeScope = yield* Scope.Scope;       const hostname = input.hostname ?? DEFAULT_HOSTNAME;      const port =        input.port ??        (yield* netService.findAvailablePort(0).pipe(          Effect.mapError(            (cause) =>              new OpenCodeRuntimeError({                operation: "startOpenCodeServerProcess",                detail: `Failed to find available port: ${openCodeRuntimeErrorDetail(cause)}`,                cause,              }),          ),        ));      const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS;      const args = ["serve", `--hostname=${hostname}`, `--port=${port}`];      const spawnCommand = yield* resolveCommand(input.binaryPath, args, input.environment);       const child = yield* spawner        .spawn(          ChildProcess.make(spawnCommand.command, spawnCommand.args, {            detached: hostPlatform !== "win32",            shell: spawnCommand.shell,            env: {              ...input.environment,              OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT,            },            extendEnv: input.environment === undefined,          }),        )        .pipe(          Effect.provideService(Scope.Scope, runtimeScope),          Effect.mapError(            (cause) =>              new OpenCodeRuntimeError({                operation: "startOpenCodeServerProcess",                detail: `Failed to spawn OpenCode server process: ${openCodeRuntimeErrorDetail(cause)}`,                cause,              }),          ),        );       const killOpenCodeProcessGroup = (signal: NodeJS.Signals) =>        hostPlatform === "win32"          ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.asVoid)          : Effect.sync(() => {              try {                process.kill(-Number(child.pid), signal);              } catch {                // The direct child may already have exited after starting the                // server; the process group kill is best-effort cleanup for                // any serve process left in that group.              }            });      const terminateChild = killOpenCodeProcessGroup("SIGTERM").pipe(        Effect.andThen(Effect.sleep("1 second")),        Effect.andThen(killOpenCodeProcessGroup("SIGKILL")),        Effect.ignore,      );      yield* Scope.addFinalizer(runtimeScope, terminateChild);       const stdoutRef = yield* Ref.make("");

Lines 1–500 of 750.