🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@opencode-ai/client

Package Overview
Dependencies
Maintainers
2
Versions
249
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@opencode-ai/client - npm Package Compare versions

Comparing version
0.0.0-next-15541
to
0.0.0-next-15555
+8
-0
dist/effect/api/api.d.ts

@@ -9,4 +9,12 @@ import type { Effect, Stream } from "effect";

export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Output, E>;
type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0];
export type Endpoint0_1Input = {
readonly instanceID: Endpoint0_1Request["payload"]["instanceID"];
readonly targetVersion?: Endpoint0_1Request["payload"]["targetVersion"];
};
export type Endpoint0_1Output = EffectValue<ReturnType<RawClient["server.health"]["health.stop"]>>;
export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>;
export interface HealthApi<E = never> {
readonly get: HealthGetOperation<E>;
readonly stop: HealthStopOperation<E>;
}

@@ -13,0 +21,0 @@ export type Endpoint1_0Output = EffectValue<ReturnType<RawClient["server.server"]["server.get"]>>;

+2
-1

@@ -12,3 +12,4 @@ // Generated by @opencode-ai/httpapi-codegen. Do not edit.

const Endpoint0_0 = (raw) => () => raw["health.get"]({}).pipe(Effect.mapError(mapClientError));
const adaptGroup0 = (raw) => ({ get: Endpoint0_0(raw) });
const Endpoint0_1 = (raw) => (input) => raw["health.stop"]({ payload: { instanceID: input["instanceID"], targetVersion: input["targetVersion"] } }).pipe(Effect.mapError(mapClientError));
const adaptGroup0 = (raw) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) });
const Endpoint1_0 = (raw) => () => raw["server.get"]({}).pipe(Effect.mapError(mapClientError));

@@ -15,0 +16,0 @@ const adaptGroup1 = (raw) => ({ get: Endpoint1_0(raw) });

@@ -0,1 +1,2 @@

import { ServiceStatus } from "@opencode-ai/protocol/groups/health";
import { Effect, FileSystem, Schema } from "effect";

@@ -18,3 +19,19 @@ export type Endpoint = {

readonly onStart?: (reason: StartReason, existing?: Info) => void;
readonly onStatus?: (status: Status) => void;
};
export type Status = {
readonly type: "missing";
} | {
readonly type: "unreachable";
} | {
readonly type: "unresponsive";
} | (ServiceStatus.State & {
readonly version?: string;
});
declare const FailedError_base: Schema.Class<FailedError, Schema.TaggedStruct<"ServiceFailedError", {
readonly message: Schema.String;
readonly action: Schema.String;
}>, import("effect/Cause").YieldableError>;
export declare class FailedError extends FailedError_base {
}
export declare const discover: (options?: Options | undefined) => Effect.Effect<{

@@ -28,12 +45,11 @@ url: string;

} | undefined, never, FileSystem.FileSystem>;
export declare const start: (options?: StartOptions | undefined) => Effect.Effect<{
url: string;
auth: {
type: "basic";
username: string;
password: string;
} | undefined;
}, Error, FileSystem.FileSystem>;
export declare const stop: (options?: Options | undefined) => Effect.Effect<void, Error, FileSystem.FileSystem>;
export declare function headers(endpoint: Endpoint): RequestInit["headers"];
export declare const status: (options?: Options | undefined) => Effect.Effect<Status, never, FileSystem.FileSystem>;
export declare const start: (options?: StartOptions | undefined) => Effect.Effect<Endpoint, Error, FileSystem.FileSystem>;
export type StopMetadata = {
readonly targetVersion?: string;
};
export declare const stop: (options?: Options | undefined, metadata?: StopMetadata | undefined) => Effect.Effect<void, Error, FileSystem.FileSystem>;
export declare function headers(endpoint: Endpoint): {
authorization: string;
} | undefined;
export declare const Info: Schema.Struct<{

@@ -40,0 +56,0 @@ readonly id: Schema.optional<Schema.String>;

@@ -0,1 +1,2 @@

import { ServiceStatus } from "@opencode-ai/protocol/groups/health";
import { Effect, FileSystem, Option, Schedule, Schema } from "effect";

@@ -5,2 +6,7 @@ import { spawn } from "node:child_process";

import { join } from "node:path";
export class FailedError extends Schema.TaggedErrorClass()("ServiceFailedError", {
message: Schema.String,
action: Schema.String,
}) {
}
// Read-only lookup: registration file plus health check and version gate.

@@ -11,45 +17,133 @@ // Never spawns; escalation to start() is the caller's policy.

});
export const status = Effect.fn("service.status")(function* (options = {}) {
const result = yield* registered(options.file, true);
if (result.info === undefined)
return { type: "missing" };
if (result.service === undefined)
return { type: "unreachable" };
return publicStatus(result.service);
});
function publicStatus(service) {
return { ...service.status, version: service.version };
}
const discoverLocal = Effect.fnUntraced(function* (options) {
const info = yield* read(options.file);
if (info === undefined)
const found = (yield* registered(options.file)).service;
if (found?.status.type !== "ready")
return undefined;
if (options.version !== undefined && info.version !== options.version)
if (options.version !== undefined && found.version !== options.version)
return undefined;
return yield* probe(info, options.version);
return found;
});
// Idempotent ensure-running: reuses a healthy compatible server, replaces a
// version-mismatched one, and otherwise spawns the service command detached.
// version-mismatched one, and otherwise spawns small contenders until a server
// becomes discoverable. A contender is never killed merely for slow startup.
export const start = Effect.fn("service.start")(function* (options = {}) {
const compatible = yield* discover(options);
if (compatible !== undefined)
return compatible;
const existing = yield* find(options);
if (existing?.version !== undefined && (options.version === undefined || existing.version === options.version))
return existing.endpoint;
yield* Effect.sync(() => options.onStart?.(existing === undefined ? "missing" : "version-mismatch", existing?.info));
if (existing !== undefined)
yield* kill(existing.info, options).pipe(Effect.ignore);
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"];
if (command === undefined)
return yield* Effect.fail(new Error("Missing service command"));
const child = yield* Effect.try({
try: () => {
const child = spawn(command, args, { detached: true, stdio: "ignore" });
child.unref();
return child;
},
catch: (cause) => new Error("Failed to start server", { cause }),
const contenders = new Set();
let announced = false;
let reported;
let lastSpawn = 0;
let spawnDelay = 5_000;
let ownerHeld = false;
const announce = (reason, existing) => Effect.sync(() => {
if (announced)
return;
announced = true;
options.onStart?.(reason, existing);
});
return yield* discoverLocal(options).pipe(Effect.flatMap((found) => found === undefined ? Effect.fail(new Error("Server is not ready")) : Effect.succeed(found)), Effect.retry(poll), Effect.tap((found) => found.info.pid === child.pid
? Effect.void
: Effect.sync(() => {
child.kill("SIGTERM");
})), Effect.map((found) => found.endpoint), Effect.tapError(() => Effect.try({ try: () => child.kill("SIGTERM"), catch: () => undefined }).pipe(Effect.ignore)), Effect.mapError(() => new Error("Failed to start server")));
const spawnContender = Effect.gen(function* () {
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"];
if (command === undefined)
return yield* Effect.fail(new Error("Missing service command"));
return yield* Effect.try({
try: () => {
const child = spawn(command, args, { detached: true, stdio: "ignore" });
let error;
child.once("error", (cause) => {
error = new Error("Failed to start server", { cause });
});
child.unref();
return { child, error: () => error };
},
catch: (cause) => new Error("Failed to start server", { cause }),
});
});
const found = yield* Effect.gen(function* () {
const registration = yield* registered(options.file, true);
const info = registration.info;
const service = registration.service;
const current = service === undefined ? { type: info === undefined ? "missing" : "unreachable" } : publicStatus(service);
const next = ownerHeld && service === undefined ? { type: "unresponsive" } : current;
yield* Effect.sync(() => {
if (sameStatus(reported, next))
return;
reported = next;
options.onStatus?.(next);
});
if (service !== undefined) {
ownerHeld = false;
spawnDelay = 5_000;
const compatible = !service.legacy && (options.version === undefined || service.version === options.version);
if (compatible && service.status.type === "ready")
return Option.some(service);
if (compatible && service.status.type === "failed")
return yield* new FailedError({ message: service.status.message, action: service.status.action });
if (compatible || service.status.type === "stopping")
return Option.none();
yield* announce("version-mismatch", service.info);
yield* kill(service, options, options.version).pipe(Effect.ignore);
lastSpawn = 0;
return Option.none();
}
else if (lastSpawn === 0 && info !== undefined)
lastSpawn = Date.now();
const failure = [...contenders].map(contenderFailure).find((error) => error !== undefined);
if (failure !== undefined)
return yield* Effect.fail(failure);
const finished = [...contenders].filter(contenderFinished);
if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true;
spawnDelay = Math.min(spawnDelay * 2, 30_000);
}
finished.forEach((item) => contenders.delete(item));
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
yield* announce("missing");
contenders.add(yield* spawnContender);
lastSpawn = Date.now();
}
return Option.none();
}).pipe(Effect.repeat({ until: Option.isSome, schedule: Schedule.spaced("1 second") }));
return Option.getOrThrow(found).endpoint;
});
export const stop = Effect.fn("service.stop")(function* (options = {}) {
const fs = yield* FileSystem.FileSystem;
function sameStatus(left, right) {
if (left?.type !== right.type)
return false;
if (right.type === "failed")
return (left.type === "failed" &&
left.version === right.version &&
left.message === right.message &&
left.action === right.action);
if (right.type === "stopping")
return left.type === "stopping" && left.version === right.version && left.targetVersion === right.targetVersion;
if (right.type === "starting" || right.type === "ready")
return left.type === right.type && left.version === right.version;
return true;
}
function contenderFailure(contender) {
const error = contender.error();
if (error !== undefined)
return error;
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
return new Error(`Server process exited with code ${contender.child.exitCode}`);
if (contender.child.signalCode !== null)
return new Error(`Server process terminated by ${contender.child.signalCode}`);
return undefined;
}
function contenderFinished(contender) {
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null;
}
export const stop = Effect.fn("service.stop")(function* (options = {}, metadata = {}) {
const existing = yield* find(options);
if (existing !== undefined)
yield* kill(existing.info, options);
yield* fs.remove(options.file ?? fallback()).pipe(Effect.ignore);
yield* kill(existing, options, metadata.targetVersion);
});

@@ -73,3 +167,3 @@ function fallback() {

const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info));
const decodeHealth = Schema.decodeUnknownOption(Schema.Struct({ healthy: Schema.Literal(true), version: Schema.String, pid: Schema.Int }));
const decodeHealth = Schema.decodeUnknownOption(ServiceStatus.Health);
const decodeLegacyHealth = Schema.decodeUnknownOption(Schema.Struct({ healthy: Schema.Literal(true) }));

@@ -85,3 +179,3 @@ // A missing or corrupt file means no valid info; callers treat both

});
const probe = Effect.fnUntraced(function* (info, version, allowLegacy = false) {
const probe = Effect.fnUntraced(function* (info, allowLegacy = false) {
const endpoint = {

@@ -97,3 +191,3 @@ url: info.url,

})).pipe(Effect.option, Effect.map(Option.getOrUndefined));
if (response === undefined || !response.ok)
if (response === undefined)
return undefined;

@@ -107,5 +201,11 @@ const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined));

return undefined;
if (version !== undefined && health.value.version !== version)
if (info.id !== undefined && health.value.instanceID !== undefined && health.value.instanceID !== info.id)
return undefined;
return { info, endpoint, version: health.value.version };
return {
info,
endpoint,
version: health.value.version,
status: health.value.status,
legacy: false,
};
}

@@ -116,13 +216,17 @@ if (!allowLegacy ||

return undefined;
return { info, endpoint };
return { info, endpoint, status: { type: "ready" }, legacy: true };
});
// Health-checked lookup without the version gate: lifecycle operations must be
const registered = Effect.fnUntraced(function* (file, allowLegacy = false) {
const info = yield* read(file);
if (info === undefined)
return { info: undefined, service: undefined };
return { info, service: yield* probe(info, allowLegacy) };
});
// Health-checked lookup without the version gate: status operations must be
// able to see (and replace or stop) a server from a different version.
const find = Effect.fnUntraced(function* (options) {
const info = yield* read(options.file);
if (info === undefined)
return undefined;
return yield* probe(info, undefined, true);
return (yield* registered(options.file, true)).service;
});
// 50ms cadence bounded at ~5s, shared by stop escalation and start readiness.
// 50ms cadence bounded at ~5s, shared by stop escalation and each start
// discovery window.
const poll = Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)));

@@ -139,18 +243,41 @@ const signal = (pid, name) => Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore);

}
const kill = Effect.fnUntraced(function* (info, options) {
// A stale registration may point at a PID that has since been reused by
// another process. Only signal the PID after authenticating the server.
const current = yield* find(options);
if (current === undefined || !same(current.info, info))
const kill = Effect.fnUntraced(function* (service, options, targetVersion) {
const requested = yield* requestStop(service, targetVersion);
if (requested === "rejected")
return;
yield* signal(info.pid, "SIGTERM");
const done = yield* stopped(info.pid).pipe(Effect.retry(poll), Effect.option);
if (requested === "unsupported") {
// A stale registration may point at a reused PID. Authenticate again
// immediately before the legacy signal fallback.
const current = yield* find(options);
if (current === undefined || !same(current.info, service.info))
return;
yield* signal(service.info.pid, "SIGTERM");
}
const done = yield* stopped(service.info.pid).pipe(Effect.retry(poll), Effect.option);
if (Option.isSome(done))
return;
const latest = yield* find(options);
if (latest === undefined || !same(latest.info, info))
if (latest === undefined || !same(latest.info, service.info))
return;
yield* signal(info.pid, "SIGKILL");
yield* stopped(info.pid).pipe(Effect.retry(poll));
yield* signal(service.info.pid, "SIGKILL");
yield* stopped(service.info.pid).pipe(Effect.retry(poll));
});
const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse);
const requestStop = Effect.fnUntraced(function* (service, targetVersion) {
if (service.info.id === undefined || service.legacy)
return "unsupported";
const response = yield* Effect.tryPromise(() => fetch(new URL("/api/service/stop", service.info.url), {
method: "POST",
headers: { ...headers(service.endpoint), "content-type": "application/json" },
body: JSON.stringify({ instanceID: service.info.id, targetVersion }),
signal: AbortSignal.timeout(2_000),
})).pipe(Effect.option, Effect.map(Option.getOrUndefined));
if (response === undefined || response.status === 404 || response.status === 405)
return "unsupported";
const body = yield* Effect.tryPromise(() => response.json()).pipe(Effect.option, Effect.map(Option.getOrUndefined));
const decoded = decodeStopResponse(body);
if (!response.ok || Option.isNone(decoded) || !decoded.value.accepted)
return "rejected";
return "accepted";
});
export * as Service from "./service.js";

@@ -1,2 +0,2 @@

import type { HealthGetOutput, ServerGetOutput, LocationGetInput, LocationGetOutput, AgentListInput, AgentListOutput, PluginListInput, PluginListOutput, SessionListInput, SessionCreateInput, SessionGetInput, SessionRemoveInput, SessionForkInput, SessionSwitchAgentInput, SessionSwitchModelInput, SessionRenameInput, SessionMoveInput, SessionPromptInput, SessionCommandInput, SessionSkillInput, SessionSyntheticInput, SessionShellInput, SessionCompactInput, SessionWaitInput, SessionRevertStageInput, SessionRevertClearInput, SessionRevertCommitInput, SessionContextInput, SessionPendingListInput, SessionInstructionsEntryListInput, SessionInstructionsEntryPutInput, SessionInstructionsEntryRemoveInput, SessionLogInput, SessionLogOutput, SessionInterruptInput, SessionBackgroundInput, SessionMessageInput, MessageListInput, ModelListInput, ModelListOutput, ModelDefaultInput, ModelDefaultOutput, GenerateTextInput, ProviderListInput, ProviderListOutput, ProviderGetInput, ProviderGetOutput, IntegrationListInput, IntegrationListOutput, IntegrationGetInput, IntegrationGetOutput, IntegrationConnectKeyInput, IntegrationConnectOauthInput, IntegrationConnectOauthOutput, IntegrationAttemptStatusInput, IntegrationAttemptStatusOutput, IntegrationAttemptCompleteInput, IntegrationAttemptCancelInput, McpListInput, McpListOutput, McpResourceCatalogInput, McpResourceCatalogOutput, CredentialUpdateInput, CredentialRemoveInput, ProjectListOutput, ProjectCurrentInput, ProjectDirectoriesInput, FormRequestListInput, FormRequestListOutput, FormListInput, FormCreateInput, FormGetInput, FormStateInput, FormReplyInput, FormCancelInput, PermissionRequestListInput, PermissionRequestListOutput, PermissionSavedListInput, PermissionSavedRemoveInput, PermissionCreateInput, PermissionListInput, PermissionGetInput, PermissionReplyInput, FileReadInput, FileReadOutput, FileListInput, FileListOutput, FileFindInput, FileFindOutput, CommandListInput, CommandListOutput, SkillListInput, SkillListOutput, EventSubscribeOutput, PtyListInput, PtyListOutput, PtyCreateInput, PtyCreateOutput, PtyGetInput, PtyGetOutput, PtyUpdateInput, PtyUpdateOutput, PtyRemoveInput, ShellListInput, ShellListOutput, ShellCreateInput, ShellCreateOutput, ShellGetInput, ShellGetOutput, ShellTimeoutInput, ShellTimeoutOutput, ShellOutputInput, ShellOutputOutput, ShellRemoveInput, QuestionRequestListInput, QuestionRequestListOutput, QuestionListInput, QuestionReplyInput, QuestionRejectInput, ReferenceListInput, ReferenceListOutput, ProjectCopyCreateInput, ProjectCopyRemoveInput, ProjectCopyRefreshInput, VcsStatusInput, VcsStatusOutput, VcsDiffInput, VcsDiffOutput, DebugLocationListOutput, DebugLocationEvictInput } from "./types";
import type { HealthStopInput, ServerGetOutput, LocationGetInput, LocationGetOutput, AgentListInput, AgentListOutput, PluginListInput, PluginListOutput, SessionListInput, SessionCreateInput, SessionGetInput, SessionRemoveInput, SessionForkInput, SessionSwitchAgentInput, SessionSwitchModelInput, SessionRenameInput, SessionMoveInput, SessionPromptInput, SessionCommandInput, SessionSkillInput, SessionSyntheticInput, SessionShellInput, SessionCompactInput, SessionWaitInput, SessionRevertStageInput, SessionRevertClearInput, SessionRevertCommitInput, SessionContextInput, SessionPendingListInput, SessionInstructionsEntryListInput, SessionInstructionsEntryPutInput, SessionInstructionsEntryRemoveInput, SessionLogInput, SessionLogOutput, SessionInterruptInput, SessionBackgroundInput, SessionMessageInput, MessageListInput, ModelListInput, ModelListOutput, ModelDefaultInput, ModelDefaultOutput, GenerateTextInput, ProviderListInput, ProviderListOutput, ProviderGetInput, ProviderGetOutput, IntegrationListInput, IntegrationListOutput, IntegrationGetInput, IntegrationGetOutput, IntegrationConnectKeyInput, IntegrationConnectOauthInput, IntegrationConnectOauthOutput, IntegrationAttemptStatusInput, IntegrationAttemptStatusOutput, IntegrationAttemptCompleteInput, IntegrationAttemptCancelInput, McpListInput, McpListOutput, McpResourceCatalogInput, McpResourceCatalogOutput, CredentialUpdateInput, CredentialRemoveInput, ProjectListOutput, ProjectCurrentInput, ProjectDirectoriesInput, FormRequestListInput, FormRequestListOutput, FormListInput, FormCreateInput, FormGetInput, FormStateInput, FormReplyInput, FormCancelInput, PermissionRequestListInput, PermissionRequestListOutput, PermissionSavedListInput, PermissionSavedRemoveInput, PermissionCreateInput, PermissionListInput, PermissionGetInput, PermissionReplyInput, FileReadInput, FileReadOutput, FileListInput, FileListOutput, FileFindInput, FileFindOutput, CommandListInput, CommandListOutput, SkillListInput, SkillListOutput, EventSubscribeOutput, PtyListInput, PtyListOutput, PtyCreateInput, PtyCreateOutput, PtyGetInput, PtyGetOutput, PtyUpdateInput, PtyUpdateOutput, PtyRemoveInput, ShellListInput, ShellListOutput, ShellCreateInput, ShellCreateOutput, ShellGetInput, ShellGetOutput, ShellTimeoutInput, ShellTimeoutOutput, ShellOutputInput, ShellOutputOutput, ShellRemoveInput, QuestionRequestListInput, QuestionRequestListOutput, QuestionListInput, QuestionReplyInput, QuestionRejectInput, ReferenceListInput, ReferenceListOutput, ProjectCopyCreateInput, ProjectCopyRemoveInput, ProjectCopyRefreshInput, VcsStatusInput, VcsStatusOutput, VcsDiffInput, VcsDiffOutput, DebugLocationListOutput, DebugLocationEvictInput } from "./types";
export interface ClientOptions {

@@ -13,3 +13,4 @@ readonly baseUrl: string;

health: {
get: (requestOptions?: RequestOptions) => Promise<HealthGetOutput>;
get: (requestOptions?: RequestOptions) => Promise<import("./types").ServiceHealth>;
stop: (input: HealthStopInput, requestOptions?: RequestOptions) => Promise<import("./types").ServiceStopResponse>;
};

@@ -16,0 +17,0 @@ server: {

@@ -132,2 +132,10 @@ import { ClientError } from "./client-error";

get: (requestOptions) => request({ method: "GET", path: `/api/health`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, requestOptions),
stop: (input, requestOptions) => request({
method: "POST",
path: `/api/service/stop`,
body: { instanceID: input["instanceID"], targetVersion: input["targetVersion"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
}, requestOptions),
},

@@ -134,0 +142,0 @@ server: {

{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/client",
"version": "0.0.0-next-15541",
"version": "0.0.0-next-15555",
"type": "module",

@@ -48,4 +48,4 @@ "license": "MIT",

"dependencies": {
"@opencode-ai/schema": "0.0.0-next-15541",
"@opencode-ai/protocol": "0.0.0-next-15541"
"@opencode-ai/schema": "0.0.0-next-15555",
"@opencode-ai/protocol": "0.0.0-next-15555"
},

@@ -62,3 +62,3 @@ "peerDependencies": {

"@effect/platform-node": "4.0.0-beta.83",
"@opencode-ai/httpapi-codegen": "0.0.0-next-15541",
"@opencode-ai/httpapi-codegen": "0.0.0-next-15555",
"@tsconfig/bun": "1.0.9",

@@ -65,0 +65,0 @@ "@types/bun": "1.3.13",

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display