@opencode-ai/client 0.0.0-next-15611 → 0.0.0-next-15614

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,6 @@ export type HealthGetOperation<E = never> = () => Effect.Effect<Endpoint0_0Outpu
9
9
  type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0];
10
10
  export type Endpoint0_1Input = {
11
11
  readonly instanceID: Endpoint0_1Request["payload"]["instanceID"];
12
- readonly targetVersion?: Endpoint0_1Request["payload"]["targetVersion"];
13
12
  };
14
13
  export type Endpoint0_1Output = EffectValue<ReturnType<RawClient["server.health"]["health.stop"]>>;
15
14
  export type HealthStopOperation<E = never> = (input: Endpoint0_1Input) => Effect.Effect<Endpoint0_1Output, E>;
@@ -8,7 +8,6 @@ type RawClient = HttpApiClient.ForApi<typeof ClientApi>;
8
8
  type Endpoint0_1Request = Parameters<RawClient["server.health"]["health.stop"]>[0];
9
9
  type Endpoint0_1Input = {
10
10
  readonly instanceID: Endpoint0_1Request["payload"]["instanceID"];
11
- readonly targetVersion?: Endpoint0_1Request["payload"]["targetVersion"];
12
11
  };
13
12
  type Endpoint2_0Request = Parameters<RawClient["server.location"]["location.get"]>[0];
14
13
  type Endpoint2_0Input = {
@@ -511,19 +510,6 @@ export declare const make: (options?: {
511
510
  readonly healthy: true;
512
511
  readonly version: string;
513
512
  readonly pid: number;
514
- readonly status: {
515
- readonly type: "starting";
516
- } | {
517
- readonly type: "ready";
518
- } | {
519
- readonly type: "stopping";
520
- readonly targetVersion?: string | undefined;
521
- } | {
522
- readonly type: "failed";
523
- readonly message: string;
524
- readonly action: string;
525
- };
526
- readonly instanceID?: string | undefined;
527
513
  }, Schema.SchemaError | import("@opencode-ai/protocol/errors").InvalidRequestError | import("@opencode-ai/protocol/errors").UnauthorizedError | HttpClientError.HttpClientError | ClientError, never>;
528
514
  stop: (input: Endpoint0_1Input) => Effect.Effect<{
529
515
  readonly accepted: boolean;
@@ -9,7 +9,7 @@ const mapClientError = (error) => HttpClientError.isHttpClientError(error) || Sc
9
9
  ? new ClientError({ cause: error })
10
10
  : error;
11
11
  const Endpoint0_0 = (raw) => () => raw["health.get"]({}).pipe(Effect.mapError(mapClientError));
12
- const Endpoint0_1 = (raw) => (input) => raw["health.stop"]({ payload: { instanceID: input["instanceID"], targetVersion: input["targetVersion"] } }).pipe(Effect.mapError(mapClientError));
12
+ const Endpoint0_1 = (raw) => (input) => raw["health.stop"]({ payload: { instanceID: input["instanceID"] } }).pipe(Effect.mapError(mapClientError));
13
13
  const adaptGroup0 = (raw) => ({ get: Endpoint0_0(raw), stop: Endpoint0_1(raw) });
14
14
  const Endpoint1_0 = (raw) => () => raw["server.get"]({}).pipe(Effect.mapError(mapClientError));
15
15
  const adaptGroup1 = (raw) => ({ get: Endpoint1_0(raw) });
@@ -1,7 +1,6 @@
1
1
  import type { Effect } from "effect";
2
2
  export * from "./generated/index";
3
3
  export type { AgentApi, AppApi, CatalogApi, CommandApi, EventApi, IntegrationApi, ModelApi, PluginApi, ProviderApi, ReferenceApi, SessionApi, SkillApi, } from "./api.js";
4
- export { Service } from "./service.js";
5
4
  export { Agent } from "@opencode-ai/schema/agent";
6
5
  export { Command } from "@opencode-ai/schema/command";
7
6
  export { Credential } from "@opencode-ai/schema/credential";
@@ -1,5 +1,4 @@
1
1
  export * from "./generated/index";
2
- export { Service } from "./service.js";
3
2
  export { Agent } from "@opencode-ai/schema/agent";
4
3
  export { Command } from "@opencode-ai/schema/command";
5
4
  export { Credential } from "@opencode-ai/schema/credential";
@@ -1,39 +1,10 @@
1
- import { ServiceStatus } from "@opencode-ai/protocol/groups/health";
2
1
  import { Effect, FileSystem, Schema } from "effect";
3
- export type Endpoint = {
4
- readonly url: string;
5
- readonly auth?: {
6
- readonly type: "basic";
7
- readonly username: string;
8
- readonly password: string;
9
- };
10
- };
11
- export type Options = {
12
- readonly file?: string;
13
- readonly version?: string;
14
- readonly command?: ReadonlyArray<string>;
15
- };
16
- export type StartReason = "missing" | "version-mismatch";
17
- export type StartOptions = Options & {
18
- readonly onStart?: (reason: StartReason, existing?: Info) => void;
19
- readonly onStatus?: (status: Status) => void;
20
- };
21
- export type Status = {
22
- readonly type: "missing";
23
- } | {
24
- readonly type: "unreachable";
25
- } | {
26
- readonly type: "unresponsive";
27
- } | (ServiceStatus.State & {
28
- readonly version?: string;
29
- });
30
- declare const FailedError_base: Schema.Class<FailedError, Schema.TaggedStruct<"ServiceFailedError", {
31
- readonly message: Schema.String;
32
- readonly action: Schema.String;
33
- }>, import("effect/Cause").YieldableError>;
34
- export declare class FailedError extends FailedError_base {
35
- }
36
- export declare const discover: (options?: Options | undefined) => Effect.Effect<{
2
+ import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js";
3
+ export * from "../service.js";
4
+ /** Contents of the local service registration file. */
5
+ export type Info = import("../service.js").Info;
6
+ /** Discover a healthy, compatible local service without starting one. */
7
+ export declare const discover: (options?: DiscoverOptions | undefined) => Effect.Effect<{
37
8
  url: string;
38
9
  auth: {
39
10
  type: "basic";
@@ -41,15 +12,15 @@ export declare const discover: (options?: Options | undefined) => Effect.Effect<
41
12
  password: string;
42
13
  } | undefined;
43
14
  } | undefined, never, FileSystem.FileSystem>;
44
- export declare const status: (options?: Options | undefined) => Effect.Effect<Status, never, FileSystem.FileSystem>;
45
- export declare const start: (options?: StartOptions | undefined) => Effect.Effect<Endpoint, Error, FileSystem.FileSystem>;
46
- export type StopMetadata = {
47
- readonly targetVersion?: string;
48
- };
49
- export declare const stop: (options?: Options | undefined, metadata?: StopMetadata | undefined) => Effect.Effect<void, Error, FileSystem.FileSystem>;
15
+ /** Ensure a healthy, compatible local service is running. */
16
+ export declare const ensure: (options?: EnsureOptions | undefined) => Effect.Effect<Endpoint, Error, FileSystem.FileSystem>;
17
+ /** Stop the registered local service. */
18
+ export declare const stop: (options?: StopOptions | undefined) => Effect.Effect<void, Error, FileSystem.FileSystem>;
19
+ /** Create HTTP authentication headers for a service endpoint. */
50
20
  export declare function headers(endpoint: Endpoint): {
51
21
  authorization: string;
52
22
  } | undefined;
23
+ /** Schema for the local service registration file. */
53
24
  export declare const Info: Schema.Struct<{
54
25
  readonly id: Schema.optional<Schema.String>;
55
26
  readonly version: Schema.optional<Schema.String>;
@@ -57,5 +28,24 @@ export declare const Info: Schema.Struct<{
57
28
  readonly pid: Schema.Int;
58
29
  readonly password: Schema.optional<Schema.String>;
59
30
  }>;
60
- export type Info = typeof Info.Type;
61
- export * as Service from "./service.js";
31
+ /** Effect-based local service lifecycle operations. */
32
+ export declare const Service: {
33
+ discover: (options?: DiscoverOptions | undefined) => Effect.Effect<{
34
+ url: string;
35
+ auth: {
36
+ type: "basic";
37
+ username: string;
38
+ password: string;
39
+ } | undefined;
40
+ } | undefined, never, FileSystem.FileSystem>;
41
+ ensure: (options?: EnsureOptions | undefined) => Effect.Effect<Endpoint, Error, FileSystem.FileSystem>;
42
+ stop: (options?: StopOptions | undefined) => Effect.Effect<void, Error, FileSystem.FileSystem>;
43
+ headers: typeof headers;
44
+ Info: Schema.Struct<{
45
+ readonly id: Schema.optional<Schema.String>;
46
+ readonly version: Schema.optional<Schema.String>;
47
+ readonly url: Schema.String;
48
+ readonly pid: Schema.Int;
49
+ readonly password: Schema.optional<Schema.String>;
50
+ }>;
51
+ };
@@ -3,30 +3,16 @@ import { Effect, FileSystem, Option, Schedule, Schema } from "effect";
3
3
  import { spawn } from "node:child_process";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- export class FailedError extends Schema.TaggedErrorClass()("ServiceFailedError", {
7
- message: Schema.String,
8
- action: Schema.String,
9
- }) {
10
- }
6
+ export * from "../service.js";
11
7
  // Read-only lookup: registration file plus health check and version gate.
12
- // Never spawns; escalation to start() is the caller's policy.
8
+ // Never spawns; escalation to ensure() is the caller's policy.
9
+ /** Discover a healthy, compatible local service without starting one. */
13
10
  export const discover = Effect.fn("service.discover")(function* (options = {}) {
14
11
  return (yield* discoverLocal(options))?.endpoint;
15
12
  });
16
- export const status = Effect.fn("service.status")(function* (options = {}) {
17
- const result = yield* registered(options.file, true);
18
- if (result.info === undefined)
19
- return { type: "missing" };
20
- if (result.service === undefined)
21
- return { type: "unreachable" };
22
- return publicStatus(result.service);
23
- });
24
- function publicStatus(service) {
25
- return { ...service.status, version: service.version };
26
- }
27
13
  const discoverLocal = Effect.fnUntraced(function* (options) {
28
14
  const found = (yield* registered(options.file)).service;
29
- if (found?.status.type !== "ready")
15
+ if (found?.state !== "ready")
30
16
  return undefined;
31
17
  if (options.version !== undefined && found.version !== options.version)
32
18
  return undefined;
@@ -35,18 +21,18 @@ const discoverLocal = Effect.fnUntraced(function* (options) {
35
21
  // Idempotent ensure-running: reuses a healthy compatible server, replaces a
36
22
  // version-mismatched one, and otherwise spawns small contenders until a server
37
23
  // becomes discoverable. A contender is never killed merely for slow startup.
38
- export const start = Effect.fn("service.start")(function* (options = {}) {
24
+ /** Ensure a healthy, compatible local service is running. */
25
+ export const ensure = Effect.fn("service.ensure")(function* (options = {}) {
39
26
  const contenders = new Set();
40
27
  let announced = false;
41
- let reported;
42
28
  let lastSpawn = 0;
43
29
  let spawnDelay = 5_000;
44
30
  let ownerHeld = false;
45
- const announce = (reason, existing) => Effect.sync(() => {
31
+ const announce = (reason, previousVersion) => Effect.sync(() => {
46
32
  if (announced)
47
33
  return;
48
34
  announced = true;
49
- options.onStart?.(reason, existing);
35
+ options.onStart?.(reason, previousVersion);
50
36
  });
51
37
  const spawnContender = Effect.gen(function* () {
52
38
  const [command, ...args] = options.command ?? ["opencode", "serve", "--service"];
@@ -69,26 +55,18 @@ export const start = Effect.fn("service.start")(function* (options = {}) {
69
55
  const registration = yield* registered(options.file, true);
70
56
  const info = registration.info;
71
57
  const service = registration.service;
72
- const current = service === undefined ? { type: info === undefined ? "missing" : "unreachable" } : publicStatus(service);
73
- const next = ownerHeld && service === undefined ? { type: "unresponsive" } : current;
74
- yield* Effect.sync(() => {
75
- if (sameStatus(reported, next))
76
- return;
77
- reported = next;
78
- options.onStatus?.(next);
79
- });
80
58
  if (service !== undefined) {
81
59
  ownerHeld = false;
82
60
  spawnDelay = 5_000;
83
61
  const compatible = !service.legacy && (options.version === undefined || service.version === options.version);
84
- if (compatible && service.status.type === "ready")
62
+ if (compatible && service.state === "ready")
85
63
  return Option.some(service);
86
- if (compatible && service.status.type === "failed")
87
- return yield* new FailedError({ message: service.status.message, action: service.status.action });
88
- if (compatible || service.status.type === "stopping")
64
+ if (compatible && service.state === "failed")
65
+ return yield* Effect.fail(new Error("Background service failed to start"));
66
+ if (compatible)
89
67
  return Option.none();
90
- yield* announce("version-mismatch", service.info);
91
- yield* kill(service, options, options.version).pipe(Effect.ignore);
68
+ yield* announce("version-mismatch", service.version);
69
+ yield* kill(service, options).pipe(Effect.ignore);
92
70
  lastSpawn = 0;
93
71
  return Option.none();
94
72
  }
@@ -113,20 +91,6 @@ export const start = Effect.fn("service.start")(function* (options = {}) {
113
91
  }).pipe(Effect.repeat({ until: Option.isSome, schedule: Schedule.spaced("1 second") }));
114
92
  return Option.getOrThrow(found).endpoint;
115
93
  });
116
- function sameStatus(left, right) {
117
- if (left?.type !== right.type)
118
- return false;
119
- if (right.type === "failed")
120
- return (left.type === "failed" &&
121
- left.version === right.version &&
122
- left.message === right.message &&
123
- left.action === right.action);
124
- if (right.type === "stopping")
125
- return left.type === "stopping" && left.version === right.version && left.targetVersion === right.targetVersion;
126
- if (right.type === "starting" || right.type === "ready")
127
- return left.type === right.type && left.version === right.version;
128
- return true;
129
- }
130
94
  function contenderFailure(contender) {
131
95
  const error = contender.error();
132
96
  if (error !== undefined)
@@ -140,20 +104,23 @@ function contenderFailure(contender) {
140
104
  function contenderFinished(contender) {
141
105
  return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null;
142
106
  }
143
- export const stop = Effect.fn("service.stop")(function* (options = {}, metadata = {}) {
107
+ /** Stop the registered local service. */
108
+ export const stop = Effect.fn("service.stop")(function* (options = {}) {
144
109
  const existing = yield* find(options);
145
110
  if (existing !== undefined)
146
- yield* kill(existing, options, metadata.targetVersion);
111
+ yield* kill(existing, options);
147
112
  });
148
113
  function fallback() {
149
114
  const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state");
150
115
  return join(state, "opencode", "service.json");
151
116
  }
117
+ /** Create HTTP authentication headers for a service endpoint. */
152
118
  export function headers(endpoint) {
153
119
  if (endpoint.auth === undefined)
154
120
  return undefined;
155
121
  return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) };
156
122
  }
123
+ /** Schema for the local service registration file. */
157
124
  export const Info = Schema.Struct({
158
125
  id: Schema.optional(Schema.String),
159
126
  version: Schema.optional(Schema.String),
@@ -193,13 +160,11 @@ const probe = Effect.fnUntraced(function* (info, allowLegacy = false) {
193
160
  return undefined;
194
161
  if (info.version !== undefined && health.value.version !== info.version)
195
162
  return undefined;
196
- if (info.id !== undefined && health.value.instanceID !== undefined && health.value.instanceID !== info.id)
197
- return undefined;
198
163
  return {
199
164
  info,
200
165
  endpoint,
201
166
  version: health.value.version,
202
- status: health.value.status,
167
+ state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
203
168
  legacy: false,
204
169
  };
205
170
  }
@@ -207,7 +172,7 @@ const probe = Effect.fnUntraced(function* (info, allowLegacy = false) {
207
172
  Option.isNone(decodeLegacyHealth(body)) ||
208
173
  (typeof body === "object" && body !== null && ("version" in body || "pid" in body)))
209
174
  return undefined;
210
- return { info, endpoint, status: { type: "ready" }, legacy: true };
175
+ return { info, endpoint, state: "ready", legacy: true };
211
176
  });
212
177
  const registered = Effect.fnUntraced(function* (file, allowLegacy = false) {
213
178
  const info = yield* read(file);
@@ -215,12 +180,12 @@ const registered = Effect.fnUntraced(function* (file, allowLegacy = false) {
215
180
  return { info: undefined, service: undefined };
216
181
  return { info, service: yield* probe(info, allowLegacy) };
217
182
  });
218
- // Health-checked lookup without the version gate: status operations must be
183
+ // Health-checked lookup without the version gate: lifecycle operations must be
219
184
  // able to see (and replace or stop) a server from a different version.
220
185
  const find = Effect.fnUntraced(function* (options) {
221
186
  return (yield* registered(options.file, true)).service;
222
187
  });
223
- // 50ms cadence bounded at ~5s, shared by stop escalation and each start
188
+ // 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
224
189
  // discovery window.
225
190
  const poll = Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(100)));
226
191
  const signal = (pid, name) => Effect.try({ try: () => process.kill(pid, name), catch: (cause) => cause }).pipe(Effect.ignore);
@@ -233,8 +198,8 @@ const stopped = Effect.fnUntraced(function* (pid) {
233
198
  function same(left, right) {
234
199
  return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid;
235
200
  }
236
- const kill = Effect.fnUntraced(function* (service, options, targetVersion) {
237
- const requested = yield* requestStop(service, targetVersion);
201
+ const kill = Effect.fnUntraced(function* (service, options) {
202
+ const requested = yield* requestStop(service);
238
203
  if (requested === "rejected")
239
204
  return;
240
205
  if (requested === "unsupported") {
@@ -255,13 +220,13 @@ const kill = Effect.fnUntraced(function* (service, options, targetVersion) {
255
220
  yield* stopped(service.info.pid).pipe(Effect.retry(poll));
256
221
  });
257
222
  const decodeStopResponse = Schema.decodeUnknownOption(ServiceStatus.StopResponse);
258
- const requestStop = Effect.fnUntraced(function* (service, targetVersion) {
223
+ const requestStop = Effect.fnUntraced(function* (service) {
259
224
  if (service.info.id === undefined || service.legacy)
260
225
  return "unsupported";
261
226
  const response = yield* Effect.tryPromise(() => fetch(new URL("/api/service/stop", service.info.url), {
262
227
  method: "POST",
263
228
  headers: { ...headers(service.endpoint), "content-type": "application/json" },
264
- body: JSON.stringify({ instanceID: service.info.id, targetVersion }),
229
+ body: JSON.stringify({ instanceID: service.info.id }),
265
230
  signal: AbortSignal.timeout(2_000),
266
231
  })).pipe(Effect.option, Effect.map(Option.getOrUndefined));
267
232
  if (response === undefined || response.status === 404 || response.status === 405)
@@ -272,4 +237,5 @@ const requestStop = Effect.fnUntraced(function* (service, targetVersion) {
272
237
  return "rejected";
273
238
  return "accepted";
274
239
  });
275
- export * as Service from "./service.js";
240
+ /** Effect-based local service lifecycle operations. */
241
+ export const Service = { discover, ensure, stop, headers, Info };
@@ -132,7 +132,7 @@ export function make(options) {
132
132
  stop: (input, requestOptions) => request({
133
133
  method: "POST",
134
134
  path: `/api/service/stop`,
135
- body: { instanceID: input["instanceID"], targetVersion: input["targetVersion"] },
135
+ body: { instanceID: input["instanceID"] },
136
136
  successStatus: 200,
137
137
  declaredStatuses: [401, 400],
138
138
  empty: false,
@@ -1,17 +1,10 @@
1
1
  export type JsonValue = null | boolean | number | string | Array<JsonValue> | {
2
2
  [key: string]: JsonValue;
3
3
  };
4
- export type ServiceStatus = {
5
- type: "starting";
6
- } | {
7
- type: "ready";
8
- } | {
9
- type: "stopping";
10
- targetVersion?: string | null;
11
- } | {
12
- type: "failed";
13
- message: string;
14
- action: string;
4
+ export type ServiceHealth = {
5
+ healthy: true;
6
+ version: string;
7
+ pid: number;
15
8
  };
16
9
  export type ServiceStopResponse = {
17
10
  accepted: boolean;
@@ -752,13 +745,6 @@ export type VcsFileStatus = {
752
745
  deletions: number;
753
746
  status: "added" | "deleted" | "modified";
754
747
  };
755
- export type ServiceHealth = {
756
- healthy: true;
757
- version: string;
758
- pid: number;
759
- instanceID?: string | null;
760
- status?: ServiceStatus;
761
- };
762
748
  export type SessionMessageModelSelected = {
763
749
  id: string;
764
750
  metadata?: {
@@ -3178,12 +3164,7 @@ export type HealthGetOutput = ServiceHealth;
3178
3164
  export type HealthStopInput = {
3179
3165
  readonly instanceID: {
3180
3166
  readonly instanceID: string;
3181
- readonly targetVersion?: string | undefined;
3182
3167
  }["instanceID"];
3183
- readonly targetVersion?: {
3184
- readonly instanceID: string;
3185
- readonly targetVersion?: string | undefined;
3186
- }["targetVersion"];
3187
3168
  };
3188
3169
  export type HealthStopOutput = ServiceStopResponse;
3189
3170
  export type ServerGetOutput = {
@@ -0,0 +1,19 @@
1
+ import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js";
2
+ export * from "../service.js";
3
+ /** Discover a healthy, compatible local service without starting one. */
4
+ export declare function discover(options?: DiscoverOptions): Promise<Endpoint | undefined>;
5
+ /** Ensure a healthy, compatible local service is running. */
6
+ export declare function ensure(options?: EnsureOptions): Promise<Endpoint>;
7
+ /** Stop the registered local service. */
8
+ export declare function stop(options?: StopOptions): Promise<void>;
9
+ /** Create HTTP authentication headers for a service endpoint. */
10
+ export declare function headers(endpoint: Endpoint): {
11
+ authorization: string;
12
+ } | undefined;
13
+ /** Promise-based local service lifecycle operations. */
14
+ export declare const Service: {
15
+ discover: typeof discover;
16
+ ensure: typeof ensure;
17
+ stop: typeof stop;
18
+ headers: typeof headers;
19
+ };
@@ -0,0 +1,230 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { spawn } from "node:child_process";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ export * from "../service.js";
6
+ /** Discover a healthy, compatible local service without starting one. */
7
+ export async function discover(options = {}) {
8
+ return (await discoverLocal(options))?.endpoint;
9
+ }
10
+ async function discoverLocal(options) {
11
+ const found = (await registered(options.file)).service;
12
+ if (found?.state !== "ready")
13
+ return undefined;
14
+ if (options.version !== undefined && found.version !== options.version)
15
+ return undefined;
16
+ return found;
17
+ }
18
+ /** Ensure a healthy, compatible local service is running. */
19
+ export async function ensure(options = {}) {
20
+ const contenders = new Set();
21
+ let announced = false;
22
+ let lastSpawn = 0;
23
+ let spawnDelay = 5_000;
24
+ let ownerHeld = false;
25
+ const announce = (reason, previousVersion) => {
26
+ if (announced)
27
+ return;
28
+ announced = true;
29
+ options.onStart?.(reason, previousVersion);
30
+ };
31
+ const spawnContender = () => {
32
+ const [command, ...args] = options.command ?? ["opencode", "serve", "--service"];
33
+ if (command === undefined)
34
+ throw new Error("Missing service command");
35
+ try {
36
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
37
+ let error;
38
+ child.once("error", (cause) => {
39
+ error = new Error("Failed to start server", { cause });
40
+ });
41
+ child.unref();
42
+ return { child, error: () => error };
43
+ }
44
+ catch (cause) {
45
+ throw new Error("Failed to start server", { cause });
46
+ }
47
+ };
48
+ while (true) {
49
+ const registration = await registered(options.file, true);
50
+ if (registration.service !== undefined) {
51
+ ownerHeld = false;
52
+ spawnDelay = 5_000;
53
+ const service = registration.service;
54
+ const compatible = !service.legacy && (options.version === undefined || service.version === options.version);
55
+ if (compatible && service.state === "ready")
56
+ return service.endpoint;
57
+ if (compatible && service.state === "failed")
58
+ throw new Error("Background service failed to start");
59
+ if (!compatible) {
60
+ announce("version-mismatch", service.version);
61
+ await kill(service, options).catch(() => undefined);
62
+ lastSpawn = 0;
63
+ }
64
+ }
65
+ else {
66
+ if (lastSpawn === 0 && registration.info !== undefined)
67
+ lastSpawn = Date.now();
68
+ const failure = [...contenders].map(contenderFailure).find((error) => error !== undefined);
69
+ if (failure !== undefined)
70
+ throw failure;
71
+ const finished = [...contenders].filter(contenderFinished);
72
+ if (finished.some((item) => item.child.exitCode === 0)) {
73
+ ownerHeld = true;
74
+ spawnDelay = Math.min(spawnDelay * 2, 30_000);
75
+ }
76
+ finished.forEach((item) => contenders.delete(item));
77
+ // Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
78
+ if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
79
+ announce("missing");
80
+ contenders.add(spawnContender());
81
+ lastSpawn = Date.now();
82
+ }
83
+ }
84
+ await delay(1_000);
85
+ }
86
+ }
87
+ function contenderFailure(contender) {
88
+ const error = contender.error();
89
+ if (error !== undefined)
90
+ return error;
91
+ if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
92
+ return new Error(`Server process exited with code ${contender.child.exitCode}`);
93
+ if (contender.child.signalCode !== null)
94
+ return new Error(`Server process terminated by ${contender.child.signalCode}`);
95
+ return undefined;
96
+ }
97
+ function contenderFinished(contender) {
98
+ return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null;
99
+ }
100
+ /** Stop the registered local service. */
101
+ export async function stop(options = {}) {
102
+ const existing = await find(options);
103
+ if (existing !== undefined)
104
+ await kill(existing, options);
105
+ }
106
+ function fallback() {
107
+ return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json");
108
+ }
109
+ /** Create HTTP authentication headers for a service endpoint. */
110
+ export function headers(endpoint) {
111
+ if (endpoint.auth === undefined)
112
+ return undefined;
113
+ return { authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64") };
114
+ }
115
+ async function read(file) {
116
+ const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined);
117
+ if (text === undefined)
118
+ return undefined;
119
+ try {
120
+ return JSON.parse(text);
121
+ }
122
+ catch {
123
+ return undefined;
124
+ }
125
+ }
126
+ async function probe(info, allowLegacy = false) {
127
+ const endpoint = {
128
+ url: info.url,
129
+ auth: info.password === undefined
130
+ ? undefined
131
+ : { type: "basic", username: "opencode", password: info.password },
132
+ };
133
+ const response = await fetch(new URL("/api/health", info.url), {
134
+ headers: headers(endpoint),
135
+ signal: AbortSignal.timeout(2_000),
136
+ }).catch(() => undefined);
137
+ const body = (await response?.json().catch(() => undefined));
138
+ if (body !== undefined && "version" in body && "pid" in body) {
139
+ if (body.pid !== info.pid)
140
+ return undefined;
141
+ if (info.version !== undefined && body.version !== info.version)
142
+ return undefined;
143
+ return {
144
+ info,
145
+ endpoint,
146
+ version: body.version,
147
+ state: response?.ok ? "ready" : response?.status === 500 ? "failed" : "waiting",
148
+ legacy: false,
149
+ };
150
+ }
151
+ if (!allowLegacy || body?.healthy !== true)
152
+ return undefined;
153
+ return { info, endpoint, state: "ready", legacy: true };
154
+ }
155
+ async function registered(file, allowLegacy = false) {
156
+ const info = await read(file);
157
+ if (info === undefined)
158
+ return { info: undefined, service: undefined };
159
+ return { info, service: await probe(info, allowLegacy) };
160
+ }
161
+ async function find(options) {
162
+ return (await registered(options.file, true)).service;
163
+ }
164
+ function signal(pid, name) {
165
+ try {
166
+ process.kill(pid, name);
167
+ }
168
+ catch { }
169
+ }
170
+ function stopped(pid) {
171
+ try {
172
+ process.kill(pid, 0);
173
+ return false;
174
+ }
175
+ catch {
176
+ return true;
177
+ }
178
+ }
179
+ async function waitUntilStopped(pid) {
180
+ for (let attempt = 0; attempt <= 100; attempt++) {
181
+ if (stopped(pid))
182
+ return true;
183
+ if (attempt < 100)
184
+ await delay(50);
185
+ }
186
+ return false;
187
+ }
188
+ function same(left, right) {
189
+ return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid;
190
+ }
191
+ async function kill(service, options) {
192
+ const requested = await requestStop(service);
193
+ if (requested === "rejected")
194
+ return;
195
+ if (requested === "unsupported") {
196
+ const current = await find(options);
197
+ if (current === undefined || !same(current.info, service.info))
198
+ return;
199
+ signal(service.info.pid, "SIGTERM");
200
+ }
201
+ if (await waitUntilStopped(service.info.pid))
202
+ return;
203
+ const latest = await find(options);
204
+ if (latest === undefined || !same(latest.info, service.info))
205
+ return;
206
+ signal(service.info.pid, "SIGKILL");
207
+ if (!(await waitUntilStopped(service.info.pid)))
208
+ throw new Error(`Server process ${service.info.pid} is still running`);
209
+ }
210
+ async function requestStop(service) {
211
+ if (service.info.id === undefined || service.legacy)
212
+ return "unsupported";
213
+ const response = await fetch(new URL("/api/service/stop", service.info.url), {
214
+ method: "POST",
215
+ headers: { ...headers(service.endpoint), "content-type": "application/json" },
216
+ body: JSON.stringify({ instanceID: service.info.id }),
217
+ signal: AbortSignal.timeout(2_000),
218
+ }).catch(() => undefined);
219
+ if (response === undefined || response.status === 404 || response.status === 405)
220
+ return "unsupported";
221
+ const body = (await response.json().catch(() => undefined));
222
+ if (!response.ok || body?.accepted !== true)
223
+ return "rejected";
224
+ return "accepted";
225
+ }
226
+ function delay(milliseconds) {
227
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
228
+ }
229
+ /** Promise-based local service lifecycle operations. */
230
+ export const Service = { discover, ensure, stop, headers };
@@ -0,0 +1,48 @@
1
+ /** Connection details for a local OpenCode service. */
2
+ export type Endpoint = {
3
+ /** Base URL of the service. */
4
+ readonly url: string;
5
+ /** Authentication required by the service, when configured. */
6
+ readonly auth?: {
7
+ /** HTTP authentication scheme. */
8
+ readonly type: "basic";
9
+ /** Basic authentication username. */
10
+ readonly username: string;
11
+ /** Basic authentication password. */
12
+ readonly password: string;
13
+ };
14
+ };
15
+ /** Options used to discover the local OpenCode service. */
16
+ export type DiscoverOptions = {
17
+ /** Absolute registration file path. Defaults to the XDG state directory. */
18
+ readonly file?: string;
19
+ /** Required service version. */
20
+ readonly version?: string;
21
+ };
22
+ /** Reason ensuring the service requires a new process. */
23
+ export type EnsureReason = "missing" | "version-mismatch";
24
+ /** Options used to ensure the local OpenCode service is running. */
25
+ export type EnsureOptions = DiscoverOptions & {
26
+ /** Service command and arguments. Defaults to `opencode serve --service`. */
27
+ readonly command?: ReadonlyArray<string>;
28
+ /** Called once before spawning a new service process. */
29
+ readonly onStart?: (reason: EnsureReason, previousVersion?: string) => void;
30
+ };
31
+ /** Options used to stop the local OpenCode service. */
32
+ export type StopOptions = {
33
+ /** Absolute registration file path. Defaults to the XDG state directory. */
34
+ readonly file?: string;
35
+ };
36
+ /** Contents of the local service registration file. */
37
+ export type Info = {
38
+ /** Unique service instance identifier. */
39
+ readonly id?: string;
40
+ /** OpenCode version served by the process. */
41
+ readonly version?: string;
42
+ /** Base URL advertised by the service. */
43
+ readonly url: string;
44
+ /** Operating system process identifier. */
45
+ readonly pid: number;
46
+ /** Private service password, when authentication is enabled. */
47
+ readonly password?: string;
48
+ };
File without changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@opencode-ai/client",
4
- "version": "0.0.0-next-15611",
4
+ "version": "0.0.0-next-15614",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -28,6 +28,10 @@
28
28
  "import": "./dist/promise/api.js",
29
29
  "types": "./dist/promise/api.d.ts"
30
30
  },
31
+ "./service": {
32
+ "import": "./dist/promise/service.js",
33
+ "types": "./dist/promise/service.d.ts"
34
+ },
31
35
  "./effect": {
32
36
  "import": "./dist/effect/index.js",
33
37
  "types": "./dist/effect/index.d.ts"
@@ -35,6 +39,10 @@
35
39
  "./effect/api": {
36
40
  "import": "./dist/effect/api.js",
37
41
  "types": "./dist/effect/api.d.ts"
42
+ },
43
+ "./effect/service": {
44
+ "import": "./dist/effect/service.js",
45
+ "types": "./dist/effect/service.d.ts"
38
46
  }
39
47
  },
40
48
  "scripts": {
@@ -45,8 +53,8 @@
45
53
  "typecheck": "tsgo --noEmit"
46
54
  },
47
55
  "dependencies": {
48
- "@opencode-ai/schema": "0.0.0-next-15611",
49
- "@opencode-ai/protocol": "0.0.0-next-15611"
56
+ "@opencode-ai/schema": "0.0.0-next-15614",
57
+ "@opencode-ai/protocol": "0.0.0-next-15614"
50
58
  },
51
59
  "peerDependencies": {
52
60
  "effect": "4.0.0-beta.83"
@@ -58,7 +66,7 @@
58
66
  },
59
67
  "devDependencies": {
60
68
  "@effect/platform-node": "4.0.0-beta.83",
61
- "@opencode-ai/httpapi-codegen": "0.0.0-next-15611",
69
+ "@opencode-ai/httpapi-codegen": "0.0.0-next-15614",
62
70
  "@tsconfig/bun": "1.0.9",
63
71
  "@types/bun": "1.3.13",
64
72
  "@typescript/native-preview": "7.0.0-dev.20251207.1",