Official

opencode

OpenCode agent provider: runs opencode serve and talks to it over HTTP and server-sent events.

The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/opencode@0.2.0

Permissions in 0.2.0

  • Read files fs.readMediumReads files in the listed places.Read, once, the servers the previous OpenCode provider keptPlaces: its own data folder
  • Provide agents agents.provideMediumAdds agents to the app.Provide the OpenCode agent, and serve plugin tools to it through the host's loopback MCP server
  • Run named programs processMediumStarts the listed programs.Run the OpenCode server (`opencode serve`, or the binary you choose in Settings), read its catalog from the command line when the server cannot answer, upgrade it (`opencode upgrade`), and ask or tell the npm installation that owns it about a newer versionPrograms: opencodenpm${settings.binaryPath}
  • Network access netMediumConnects to the listed hosts.Talk to the OpenCode server it starts on this computer, at the port it picks for each launch, and to the external server you choose in SettingsHosts: localhost:*${settings.serverUrl}
  • Environment variables envMediumReads the listed environment variables.Expand ~ in a configured binary, and read the OpenCode settings you set in the environment: the binary to run, an external server's address, and the server's user name and passwordVariables: HOMEOPENCODE_PATHOPENCODE_SERVER_URLOPENCODE_SERVER_USERNAMEOPENCODE_SERVER_PASSWORDCONVERGENCE_OPENCODE_SERVER_PASSWORD

Files

client.ts25.1 KB
// The `opencode serve` child and the HTTP calls made against a server
// (the Rust `client.rs`). The child runs through the `Process` service,
// the calls go through `Net` (the `localhost:*` grant), every one with the
// server's Basic credentials.
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Result from "effect/Result";
import * as Scope from "effect/Scope";
import * as z from "zod";
import { Net, Process } from "convergence/effect";
import type { ChildProcess, Response } from "convergence";
import { lines } from "../sdk/jsonrpc.ts";
import { runProcess } from "../sdk/effect.ts";
import { errorMessage } from "../sdk/errors.ts";
import { obj } from "./map.ts";

/// How long the server may take to print its listen address.
export const START_TIMEOUT = 30_000;
/// How long `opencode --version` may take. It prints and exits.
const VERSION_TIMEOUT = 15_000;
/// How long `GET /global/health` may take before the server counts as dead.
export const HEALTH_TIMEOUT = 5_000;
/// How long the process group may take to exit on `SIGTERM`.
const STOP_GRACE = 1_000;
/// Lines of child output kept for the failure message.
const LOG_TAIL = 20;
/// Longest line accepted while looking for the listen address.
const MAX_LINE = 8 * 1024;
/// Ports tried before starting gives up: a port chosen at random can be
/// taken between the choice and the bind.
const START_ATTEMPTS = 3;

export interface Auth {
  username: string;
  password: string;
}

/// The oldest server this plugin speaks to. Older builds miss the
/// `question` family and the `variant` prompt field.
export const MINIMUM_VERSION = "1.14.19";

/// The default Basic-auth user of an OpenCode server, which is what the
/// server itself assumes when `OPENCODE_SERVER_USERNAME` is unset.
export const DEFAULT_USERNAME = "opencode";

export type TroubleKind = "not_installed" | "too_old" | "unsupported" | "auth_rejected";

/// A failure the UI describes in its own words, because the fix differs:
/// install the program, upgrade it, point at a 1.x binary, correct the
/// password. `kind`: `not_installed`, `too_old`, `unsupported`,
/// `auth_rejected`.
export class Trouble extends Error {
  kind: TroubleKind;
  details: Record<string, unknown>;
  constructor(kind: TroubleKind, details: Record<string, unknown> = {}) {
    super(troubleMessage(kind, details));
    this.name = "Trouble";
    this.kind = kind;
    this.details = details;
  }
}

function troubleMessage(kind: string, { found, binary, version }: Record<string, unknown> = {}): string {
  switch (kind) {
    case "not_installed":
      return "the `opencode` binary was not found on PATH; install opencode to use this agent";
    case "too_old":
      return `opencode ${found} is too old; Divergence needs ${MINIMUM_VERSION} or newer. Upgrade with \`opencode upgrade\`.`;
    case "unsupported":
      return (
        `${binary} is OpenCode ${version}, which serves a different API from the 1.x one Divergence speaks. ` +
        "Put a 1.x `opencode` first on your login PATH (for example ~/.opencode/bin, where the install script puts it)."
      );
    case "auth_rejected":
      return (
        "the opencode server rejected the password; set CONVERGENCE_OPENCODE_SERVER_PASSWORD to the password " +
        "that server was started with"
      );
    default:
      return kind;
  }
}

const remoteSchema = z.looseObject({
  _tag: z.unknown().optional(),
  name: z.unknown().optional(),
  trouble: z.unknown().optional(),
});

/// Whether an error is the host refusing a grant (Effect's tagged form or
/// the prelude's `name` form): such a failure must travel unchanged, so the
/// caller can tell it apart from a server failure.
export function isRefusal(error: unknown): boolean {
  const parsed = remoteSchema.safeParse(error);
  if (!parsed.success) return false;
  return parsed.data._tag === "PermissionNotGranted" || parsed.data.name === "PermissionNotGranted";
}

/// The trouble behind an error, when it is one of the four.
export function troubleOf(error: unknown): Trouble | null {
  if (error instanceof Trouble) return error;
  const parsed = remoteSchema.safeParse(error);
  const trouble = parsed.success ? parsed.data.trouble : undefined;
  return trouble instanceof Trouble ? trouble : null;
}

// --- credentials ---------------------------------------------------------------

/// The password rule for a server: `CONVERGENCE_OPENCODE_SERVER_PASSWORD`
/// is a deliberate choice and applies wherever the server runs;
/// `OPENCODE_SERVER_PASSWORD` is the ambient value the local server is
/// started with, and sending it to a remote host would hand that host a
/// local secret. `null` for a local server means "none configured" (the
/// caller makes one up), never "no password".
export function passwordFor(external: boolean, configured: string | null, ambient: string | null): string | null {
  if (configured) return configured;
  if (external) return null;
  return ambient || null;
}

/// A password nobody chose, for a server this plugin starts: OpenCode no
/// longer accepts anonymous requests, so a server started without
/// `OPENCODE_SERVER_PASSWORD` would refuse us. It never touches disk or a
/// command line (it goes to the child's environment), and it is new on
/// every launch, from the runtime's `crypto`.
export function generatedPassword(): string {
  return Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) => byte.toString(16).padStart(2, "0")).join("");
}

/// The `Authorization` header for Basic credentials.
export function basicAuth(auth: Auth | null): string | null {
  if (!auth) return null;
  const raw = `${auth.username}:${auth.password}`;
  // Latin-1 only for btoa; a password with other characters goes as UTF-8.
  const bytes = new TextEncoder().encode(raw);
  let binary = "";
  for (const byte of bytes) binary += String.fromCharCode(byte);
  return `Basic ${btoa(binary)}`;
}

// --- versions ---------------------------------------------------------------------

/// The leading `major.minor.patch` of a version, ignoring a pre-release or
/// build suffix; `null` when it does not parse.
export function semver(value: unknown): [number, number, number] | null {
  if (typeof value !== "string") return null;
  const core = value.trim().replace(/^v/, "").split(/[-+]/)[0] ?? "";
  const parts = core.split(".");
  const number = (part: string | undefined): number | null =>
    part !== undefined && /^\d+$/.test(part) ? Number(part) : null;
  const major = number(parts[0]);
  const minor = parts.length > 1 ? number(parts[1]) : 0;
  const patch = parts.length > 2 ? number(parts[2]) : 0;
  if (major === null || minor === null || patch === null) return null;
  return [major, minor, patch];
}

function compare(a: [number, number, number], b: [number, number, number]): number {
  if (a[0] !== b[0]) return a[0] - b[0];
  if (a[1] !== b[1]) return a[1] - b[1];
  return a[2] - b[2];
}

/// Refuses a server older than `MINIMUM_VERSION`. A version that does not
/// parse is trusted: refusing an unusual build is worse than running it.
export function checkVersion(reported: unknown): void {
  const found = semver(reported);
  const minimum = semver(MINIMUM_VERSION);
  if (found && minimum && compare(found, minimum) < 0) throw new Trouble("too_old", { found: reported });
}

/// The version in a `--version` line: `1.18.29` or `opencode v2.0.1`.
export function versionIn(line: unknown): string | null {
  const words = String(line ?? "")
    .split(/\s+/)
    .filter(Boolean)
    .reverse();
  for (const word of words) if (semver(word)) return word.replace(/^v/, "");
  return null;
}

/// Whether a version belongs to the 1.x line this plugin speaks: 2.0 moved
/// the whole HTTP API under `/api` with new shapes.
export function speaksOurApi(version: unknown): boolean {
  const parsed = semver(version);
  return parsed === null || parsed[0] < 2;
}

// --- the listen address -------------------------------------------------------------

/// The listen address from a startup line, which reads `opencode server
/// listening on http://127.0.0.1:1234`: anchored on the scheme and ended
/// at the first whitespace, so a line that merely mentions a URL later is
/// not mistaken for it.
export function parseListenUrl(line: unknown): string | null {
  const text = String(line ?? "");
  let start = text.indexOf("http://");
  if (start < 0) start = text.indexOf("https://");
  if (start < 0) return null;
  const rest = text.slice(start);
  const end = rest.search(/\s/);
  const url = (end < 0 ? rest : rest.slice(0, end)).replace(/[/.,]+$/, "");
  const authority = url.split("//")[1] ?? "";
  return authority ? url : null;
}

/// Whether the address a server reported is the one it was asked for.
/// OpenCode 1.18 answers `--port 0` with its default 4096, which is why
/// the plugin picks the port itself.
export function listensOn(url: unknown, port: number): boolean {
  const at = String(url).lastIndexOf(":");
  if (at < 0) return false;
  const tail = String(url)
    .slice(at + 1)
    .replace(/\/+$/, "");
  return /^\d+$/.test(tail) && Number(tail) === port;
}

/// A port to ask for: the plugin cannot bind one to test it, so it picks
/// at random outside the ranges servers usually claim, and a start that
/// finds it taken tries another.
export function randomPort(): number {
  const bytes = crypto.getRandomValues(new Uint8Array(2));
  const high = bytes[0] ?? 0;
  const low = bytes[1] ?? 0;
  return 20_000 + (((high << 8) | low) % 30_000);
}

/// Whether a loopback host: the tools server the host serves is reachable
/// from a server on this machine only.
export function isLoopback(base: unknown): boolean {
  const match = /^https?:\/\/(\[[^\]]*\]|[^/:]+)/i.exec(String(base ?? ""));
  if (!match) return false;
  const host = (match[1] ?? "").toLowerCase();
  return host === "localhost" || host === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(host);
}

// --- HTTP -----------------------------------------------------------------------------

export interface RequestOptions {
  query?: Record<string, unknown>;
  body?: unknown;
  empty?: boolean;
  timeout?: number;
}

/// The query string for `query` (`{ directory: "/w" }`), entries whose
/// value is `null` or `undefined` left out.
export function queryString(query: Record<string, unknown> = {}): string {
  const parts: string[] = [];
  for (const [key, value] of Object.entries(query)) {
    if (value === null || value === undefined) continue;
    parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
  }
  return parts.length ? `?${parts.join("&")}` : "";
}

export interface Refused extends Error {
  trouble?: Trouble;
  status?: number;
}

/// The error for a response the server refused. A rejected password is
/// told apart here, because every later caller sees the same 401 and only
/// this layer knows it came from the credentials.
export function failure(status: number, method: string, path: string, body: unknown): Refused {
  const error: Refused = new Error(`opencode ${method} ${path} failed with ${status}: ${String(body ?? "").trim()}`);
  if (status === 401 || status === 403) error.trouble = new Trouble("auth_rejected");
  error.status = status;
  return error;
}

/// One request to the server at `base`. The answer is parsed as JSON;
/// `empty: true` ignores the body (many endpoints answer 204). `timeout`
/// (ms) gives up waiting: the runtime cannot cancel a request before its
/// answer arrives, so the request itself may still finish on the server.
export const request = Effect.fn("OpenCode.request")(function* (
  base: string,
  auth: Auth | null,
  method: string,
  path: string,
  { query, body, empty = false, timeout = 0 }: RequestOptions = {},
) {
  const net = yield* Net;
  const headers: Record<string, string> = { accept: "application/json" };
  const authorization = basicAuth(auth);
  if (authorization) headers["authorization"] = authorization;
  const init: { method: string; headers: Record<string, string>; body?: string } = { method, headers };
  if (body !== undefined) {
    headers["content-type"] = "application/json";
    init.body = JSON.stringify(body);
  }
  const url = `${base}${path}${queryString(query)}`;
  const exchange = Effect.gen(function* () {
    const response = yield* net.fetch(url, init).pipe(
      Effect.mapError((error) => {
        // A refused grant travels unchanged, so the caller can tell it
        // apart from a server failure.
        if (isRefusal(error)) return error;
        const failed: Refused = new Error(`${method} ${path}: ${errorMessage(error)}`);
        return failed;
      }),
    );
    const text = yield* Effect.tryPromise({
      try: () => response.text(),
      catch: (error) => {
        const failed: Refused = new Error(`${method} ${path}: ${errorMessage(error)}`);
        return failed;
      },
    });
    if (!response.ok) return yield* Effect.fail(failure(response.status, method, path, text));
    if (empty) return null;
    try {
      const parsed: unknown = JSON.parse(text);
      return parsed;
    } catch {
      // The status and the start of the body go into the error: "not JSON"
      // alone says nothing about what the server sent.
      const type = response.headers?.get?.("content-type") ?? "";
      const failed: Refused = new Error(
        `decoding the response of ${method} ${path} (status ${response.status}, ${type}, ${text.length} bytes: ${JSON.stringify(text.slice(0, 120))})`,
      );
      return yield* Effect.fail(failed);
    }
  });
  if (!timeout) return yield* exchange;
  return yield* exchange.pipe(
    Effect.timeoutOrElse({
      duration: timeout,
      orElse: () => {
        const failed: Refused = new Error(
          `opencode ${method} ${path} did not answer within ${Math.round(timeout / 1000)}s`,
        );
        return Effect.fail(failed);
      },
    }),
  );
});

/// Opens the server's event stream for `directory`: the response, whose
/// `body` the caller reads as server-sent events. Carries the credentials
/// like every other request: it once went out bare, which a server with a
/// password answered with 401 on every reconnect, for ever.
export const openEvents = Effect.fn("OpenCode.openEvents")(function* (
  base: string,
  auth: Auth | null,
  directory: string,
) {
  const net = yield* Net;
  const headers: Record<string, string> = { accept: "text/event-stream" };
  const authorization = basicAuth(auth);
  if (authorization) headers["authorization"] = authorization;
  const response: Response = yield* net.fetch(`${base}/event${queryString({ directory })}`, { headers }).pipe(
    Effect.mapError((error) => {
      if (isRefusal(error)) return error;
      const failed: Refused = new Error(`GET /event: ${errorMessage(error)}`);
      return failed;
    }),
  );
  if (!response.ok) {
    const text = yield* Effect.tryPromise({ try: () => response.text(), catch: () => new Error("text") }).pipe(
      Effect.orElseSucceed(() => ""),
    );
    return yield* Effect.fail(failure(response.status, "GET", "/event", text));
  }
  return response;
});

// --- the local server ------------------------------------------------------------------

/// What `opencode --version` says, when it answers in time.
export const reportedVersion = Effect.fn("OpenCode.reportedVersion")(function* (binary: string) {
  const result = yield* runProcess(binary, ["--version"], { timeout: VERSION_TIMEOUT }).pipe(
    Effect.scoped,
    Effect.catchTag("TransportFailed", () => Effect.succeed(null)),
    Effect.catchTag(["HostCallFailed", "PermissionNotGranted", "NeedsReview", "ParseFailed"], (error) => {
      if (isRefusal(error)) return Effect.fail(notGranted(binary, error));
      return Effect.succeed(null);
    }),
  );
  if (!result) return null;
  return versionIn(result.stdout);
});

/// A binary the plugin may not start: a path the user chose, which a
/// `process` grant (bare program names only) does not cover.
function notGranted(binary: string, error: unknown): Error {
  return new Error(
    `Divergence can start OpenCode only as \`opencode\` from the login PATH; starting ${binary} needs a grant ` +
      `this plugin does not ask for (${errorMessage(error)}). Remove the binary override, or put that binary first on the PATH.`,
  );
}

/// Drains a pipe line by line, recording each one, until it ends.
function drain(pipe: ChildProcess["stdout"] | ChildProcess["stderr"], record: (line: string) => void) {
  return (async () => {
    try {
      for await (const line of lines(pipe)) record(line);
    } catch {
      // A broken pipe only loses the explanation.
    }
  })();
}

/// A `opencode serve` this plugin started. `start` waits until it answers
/// `GET /global/health` with a version it supports. The server owns a
/// scope: spawning, draining and stopping all end with it.
export class LocalServer {
  child: ChildProcess;
  scope: Scope.Closeable;
  port: number;
  base = "";
  version = "";
  log: string[] = [];
  exitStatus: { code: number | null; signal: number | string | null } | null = null;

  constructor(child: ChildProcess, scope: Scope.Closeable, port: number) {
    this.child = child;
    this.scope = scope;
    this.port = port;
    void Promise.resolve(child.exited).then((status) => {
      this.exitStatus = status ?? { code: null, signal: null };
      return this.exitStatus;
    });
  }

  /// Starts a server from `binary` (the `opencode` on the login PATH when
  /// `null`) with the password in `auth`. `describe` names the binary in a
  /// message about a 2.x install.
  static start(
    auth: Auth | null,
    {
      binary = null,
      describe = null,
    }: {
      binary?: string | null;
      describe?: (() => Effect.Effect<string | null, unknown, Net | Process>) | null;
    } = {},
  ) {
    return Effect.fn("OpenCode.start")(function* () {
      const program = binary ?? "opencode";
      // Asked before the server starts: a 2.x binary starts fine and then
      // serves its web page where the 1.x API used to be, which no health
      // check can explain. Its own version line can.
      const version = yield* reportedVersion(program);
      if (version && !speaksOurApi(version)) {
        let where: string | null = binary;
        if (!where && describe) {
          const settled = yield* Effect.result(describe());
          if (Result.isSuccess(settled)) where = settled.success;
        }
        where ??= "the opencode on the login PATH";
        return yield* Effect.fail(new Trouble("unsupported", { binary: where, version }));
      }
      let last: unknown = null;
      for (let attempt = 0; attempt < START_ATTEMPTS; attempt += 1) {
        const port = randomPort();
        const launched = yield* LocalServer.launch(program, port, auth, binary).pipe(Effect.result);
        if (Result.isSuccess(launched)) return launched.success;
        last = launched.failure;
        // Only a taken port is worth another try.
        if (!/EADDRINUSE|address already in use|in use|another server may own/i.test(errorMessage(last)))
          return yield* Effect.fail(last);
      }
      return yield* Effect.fail(last);
    })();
  }

  static launch(program: string, port: number, auth: Auth | null, binary: string | null) {
    return Effect.fn("OpenCode.launch")(function* () {
      const process = yield* Process;
      // The server owns a scope: its child, drains and watches all end
      // with it.
      const scope = Scope.makeUnsafe();
      const child = yield* process
        .spawn(program, ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
          // The server reads its own password from the environment; passing
          // it here secures the server we start.
          env: auth ? { OPENCODE_SERVER_PASSWORD: auth.password } : undefined,
        })
        .pipe(
          Effect.provideService(Scope.Scope, scope),
          Effect.mapError((error) => {
            if (isRefusal(error) && binary) return notGranted(binary, error);
            if (/not on the PATH|No such file|not found/i.test(errorMessage(error)))
              return new Trouble("not_installed");
            return new Error(`failed to start ${program}: ${errorMessage(error)}`);
          }),
        );
      try {
        yield* Effect.tryPromise({ try: () => child.stdin.close(), catch: () => new Error("close") }).pipe(
          Effect.ignore,
        );
      } catch {
        // The server does not read its input.
      }
      const server = new LocalServer(child, scope, port);
      const url = yield* server.listenUrl();
      if (!listensOn(url, port)) {
        yield* server.stop();
        return yield* Effect.fail(
          new Error(
            `opencode serve was asked for port ${port} but reported ${url}; another server may own that address`,
          ),
        );
      }
      server.base = url;
      const checked = yield* request(server.base, auth, "GET", "/global/health", { timeout: HEALTH_TIMEOUT }).pipe(
        Effect.result,
      );
      if (Result.isFailure(checked)) {
        yield* server.stop();
        return yield* Effect.fail(
          new Error(`opencode server health check failed: ${errorMessage(checked.failure)}${server.logTail()}`),
        );
      }
      const health: unknown = checked.success;
      const supported = yield* Effect.try({
        try: () => checkVersion(obj(health)["version"]),
        catch: (error) => error,
      }).pipe(Effect.result);
      if (Result.isFailure(supported)) {
        yield* server.stop();
        return yield* Effect.fail(supported.failure);
      }
      server.version = String(obj(health)["version"] ?? "");
      return server;
    })();
  }

  record(line: string): void {
    if (this.log.length === LOG_TAIL) this.log.shift();
    this.log.push(line.slice(0, MAX_LINE));
  }

  /// Reads stdout until the listen address appears, and keeps draining
  /// both pipes afterwards so the child never blocks on a full one.
  listenUrl() {
    return Effect.fn("OpenCode.listenUrl")(
      function* (this: LocalServer) {
        const server = this;
        // Both pipes are drained for as long as the child runs, so it never
        // blocks on a full one.
        yield* Effect.forkIn(server.scope)(
          Effect.promise(() => drain(server.child.stderr, (line) => server.record(line))),
        );
        const found = yield* Deferred.make<string, Error>();
        const watch = Effect.promise(async () => {
          try {
            for await (const line of lines(server.child.stdout)) {
              server.record(line);
              if (line.length > MAX_LINE) continue;
              const url = parseListenUrl(line);
              if (url) {
                Deferred.doneUnsafe(found, Effect.succeed(url));
                // Keep draining: the child runs on.
              }
            }
            // The child ended its output before naming an address: wait for
            // the exit (and the stderr drain) before naming what it printed.
            await server.child.exited.catch(() => null);
            Deferred.doneUnsafe(
              found,
              Effect.fail(new Error(`opencode serve exited before it reported a listen address${server.logTail()}`)),
            );
          } catch {
            Deferred.doneUnsafe(
              found,
              Effect.fail(new Error(`opencode serve exited before it reported a listen address${server.logTail()}`)),
            );
          }
        });
        yield* Effect.forkIn(server.scope)(watch.pipe(Effect.ignore));
        const outcome = yield* Deferred.await(found).pipe(
          Effect.timeoutOrElse({
            duration: START_TIMEOUT,
            orElse: () =>
              Effect.fail(
                new Error(
                  `opencode serve did not report a listen address within ${START_TIMEOUT / 1000}s${server.logTail()}`,
                ),
              ),
          }),
          Effect.result,
        );
        if (Result.isFailure(outcome)) {
          yield* server.stop();
          return yield* Effect.fail(outcome.failure);
        }
        return outcome.success;
      }.bind(this),
    )();
  }

  /// Whether the child still runs. A server that died leaves every later
  /// request failing with a connection error, so callers restart it.
  get alive(): boolean {
    return this.exitStatus === null;
  }

  /// The last lines the child printed, ready to append to an error.
  logTail(): string {
    return this.log.length ? `; last output: ${this.log.join(" | ")}` : "";
  }

  /// Stops the whole process group (`opencode serve` forks workers a
  /// single kill would orphan): `SIGTERM`, then `SIGKILL` after a second.
  /// The `SIGTERM` is the spawn finalizer's: closing the server's scope
  /// stops the child, so a stop is exactly one signal and an unload stops
  /// a server nobody stopped. Ends the server's scope, so its drains end
  /// with it.
  stop() {
    return Effect.fn("OpenCode.stop")(
      function* (this: LocalServer) {
        if (this.exitStatus !== null) return;
        yield* Scope.close(this.scope, Exit.void).pipe(Effect.ignore);
        const exited = yield* Effect.raceFirst(
          Effect.tryPromise({ try: () => this.child.exited, catch: (error) => error }).pipe(
            Effect.as(true),
            Effect.orElseSucceed(() => false),
          ),
          Effect.sleep(STOP_GRACE).pipe(Effect.as(false)),
        );
        if (!exited)
          yield* Effect.tryPromise({ try: () => this.child.kill("SIGKILL"), catch: () => new Error("kill") }).pipe(
            Effect.ignore,
          );
      }.bind(this),
    )();
  }
}

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <394.8 KB5 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.