667 lines · 22.2 KB · TypeScript
RawDownload
import { pathToFileURL } from "node:url"; import type { ChatAttachment, ProviderApprovalDecision, RuntimeMode } from "@t3tools/contracts";import {  createOpencodeClient,  type Agent,  type FilePartInput,  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";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 = 5_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>;} 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;} 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: 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 });} function commandBasename(commandPath: string): string {  return commandPath.split(/[\\/]/).at(-1) ?? commandPath;} function isMatchingOpenCodeServeCommand(input: {  readonly command: string;  readonly binaryPath: string;  readonly hostname: string;  readonly port: number;}): boolean {  const executable = commandBasename(input.binaryPath);  const command = input.command;  return (    command.includes(executable) &&    /\bserve\b/.test(command) &&    command.includes(`--hostname=${input.hostname}`) &&    command.includes(`--port=${input.port}`)  );} const makeOpenCodeRuntime = Effect.gen(function* () {  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;  const netService = yield* NetService.NetService;   const listProcessCommands: Effect.Effect<    ReadonlyArray<{ readonly pid: number; readonly command: string }>  > = Effect.gen(function* () {    const child = yield* spawner.spawn(ChildProcess.make("ps", ["-axo", "pid=,command="]));    const [stdout, code] = yield* Effect.all(      [collectStreamAsString(child.stdout), child.exitCode],      {        concurrency: "unbounded",      },    );    if (Number(code) !== 0) {      return [];    }    return stdout      .split("\n")      .flatMap((line) => {        const match = line.trim().match(/^(\d+)\s+(.+)$/);        const command = match?.[2];        if (!match || command === undefined) {          return [];        }        return [{ pid: Number(match[1]), command }];      })      .filter((entry) => Number.isInteger(entry.pid) && entry.pid > 0);  }).pipe(    Effect.scoped,    Effect.catch(() => Effect.succeed([])),  );   const terminateMatchingOpenCodeServeProcesses = (input: {    readonly binaryPath: string;    readonly hostname: string;    readonly port: number;    readonly signal: NodeJS.Signals;  }): Effect.Effect<void> => {    if (process.platform === "win32") {      return Effect.void;    }     return listProcessCommands.pipe(      Effect.flatMap((processes) =>        Effect.forEach(          processes,          (entry) => {            if (              entry.pid === process.pid ||              !isMatchingOpenCodeServeCommand({                command: entry.command,                binaryPath: input.binaryPath,                hostname: input.hostname,                port: input.port,              })            ) {              return Effect.void;            }            return Effect.sync(() => {              try {                process.kill(entry.pid, input.signal);              } catch {                // The process may have exited between `ps` and `kill`.              }            });          },          { discard: true },        ),      ),      Effect.ignore,    );  };   const runOpenCodeCommand: OpenCodeRuntimeShape["runOpenCodeCommand"] = (input) =>    Effect.gen(function* () {      const child = yield* spawner.spawn(        ChildProcess.make(input.binaryPath, [...input.args], {          shell: process.platform === "win32",          env: input.environment ?? process.env,        }),      );      const [stdout, stderr, code] = yield* Effect.all(        [collectStreamAsString(child.stdout), collectStreamAsString(child.stderr), child.exitCode],        { concurrency: "unbounded" },      );      const exitCode = Number(code);      if (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 child = yield* spawner        .spawn(          ChildProcess.make(input.binaryPath, args, {            detached: process.platform !== "win32",            shell: process.platform === "win32",            env: {              ...(input.environment ?? process.env),              OPENCODE_CONFIG_CONTENT: OPENCODE_EMPTY_CONFIG_CONTENT,            },          }),        )        .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) =>        process.platform === "win32"          ? child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.ignore)          : 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 killDirectChild = (signal: NodeJS.Signals) =>        child.kill({ killSignal: signal, forceKillAfter: "1 second" }).pipe(Effect.ignore);      const killMatchingServeProcesses = (signal: NodeJS.Signals) =>        terminateMatchingOpenCodeServeProcesses({          binaryPath: input.binaryPath,          hostname,          port,          signal,        });      const terminateChild = Effect.all(        [          killOpenCodeProcessGroup("SIGTERM"),          killDirectChild("SIGTERM"),          killMatchingServeProcesses("SIGTERM"),        ],        { concurrency: "unbounded", discard: true },      ).pipe(        Effect.andThen(Effect.sleep("1 second")),        Effect.andThen(          Effect.all(            [              killOpenCodeProcessGroup("SIGKILL"),              killDirectChild("SIGKILL"),              killMatchingServeProcesses("SIGKILL"),            ],            { concurrency: "unbounded", discard: true },          ),        ),        Effect.ignore,      );      yield* Scope.addFinalizer(runtimeScope, terminateChild);       const stdoutRef = yield* Ref.make("");      const stderrRef = yield* Ref.make("");      const readyDeferred = yield* Deferred.make<string, OpenCodeRuntimeError>();       const setReadyFromStdoutChunk = (chunk: string) =>        Ref.updateAndGet(stdoutRef, (stdout) => `${stdout}${chunk}`).pipe(

Lines 1–500 of 667.