@opencode-ai/client 0.0.0-next-17255 → 0.0.0-next-17263

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.
@@ -1,10 +1,17 @@
1
1
  import { ServiceStatus } from "@opencode-ai/protocol/groups/health";
2
2
  import { Effect, FileSystem, Option, Schedule, Schema } from "effect";
3
- import { spawn } from "node:child_process";
4
3
  import { homedir } from "node:os";
5
4
  import { join } from "node:path";
5
+ import { contenderFailure, contenderFinished, spawnServiceContender, } from "../service-contender.js";
6
6
  import { defaultEnsureTiming, ensureTiming } from "../service-timing.js";
7
7
  export * from "../service.js";
8
+ // Find, start, and stop the local opencode background service.
9
+ //
10
+ // The service daemon advertises itself through a registration file in the
11
+ // user's state directory: url, pid, version, and the private password, with
12
+ // 0600 permissions. That file is the complete discovery contract — reading it
13
+ // is all a client needs to connect. The daemon's own configuration (port,
14
+ // persisted password) is CLI-owned and never read here.
8
15
  // Read-only lookup: registration file plus health check and version gate.
9
16
  // Never spawns; escalation to ensure() is the caller's policy.
10
17
  /** Discover a healthy, compatible local service without starting one. */
@@ -52,13 +59,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options = {}) {
52
59
  return yield* Effect.fail(new Error("Missing service command"));
53
60
  return yield* Effect.try({
54
61
  try: () => {
55
- const child = spawn(command, args, { detached: true, stdio: "ignore" });
56
- let error;
57
- child.once("error", (cause) => {
58
- error = new Error("Failed to start server", { cause });
59
- });
60
- child.unref();
61
- return { child, error: () => error };
62
+ return spawnServiceContender(command, args);
62
63
  },
63
64
  catch: (cause) => new Error("Failed to start server", { cause }),
64
65
  });
@@ -115,24 +116,11 @@ export const ensure = Effect.fn("service.ensure")(function* (options = {}) {
115
116
  }).pipe(Effect.repeat({
116
117
  until: Option.isSome,
117
118
  schedule: Schedule.max([Schedule.spaced(timing.pollInterval), Schedule.recurs(timing.attempts)]),
118
- }));
119
+ }), Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))));
119
120
  if (Option.isNone(found))
120
121
  return yield* Effect.fail(new Error("Timed out waiting for the background service to start"));
121
122
  return found.value.endpoint;
122
123
  });
123
- function contenderFailure(contender) {
124
- const error = contender.error();
125
- if (error !== undefined)
126
- return error;
127
- if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
128
- return new Error(`Server process exited with code ${contender.child.exitCode}`);
129
- if (contender.child.signalCode !== null)
130
- return new Error(`Server process terminated by ${contender.child.signalCode}`);
131
- return undefined;
132
- }
133
- function contenderFinished(contender) {
134
- return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null;
135
- }
136
124
  /** Stop the registered local service. */
137
125
  export const stop = Effect.fn("service.stop")(function* (options = {}) {
138
126
  const existing = yield* find(options);
@@ -73,7 +73,7 @@ export declare function make(options: ClientOptions): {
73
73
  log: (input: SessionLogInput, requestOptions?: RequestOptions) => AsyncIterable<SessionLogOutput>;
74
74
  interrupt: (input: SessionInterruptInput, requestOptions?: RequestOptions) => Promise<void>;
75
75
  background: (input: SessionBackgroundInput, requestOptions?: RequestOptions) => Promise<void>;
76
- message: (input: SessionMessageInput, requestOptions?: RequestOptions) => Promise<import("./types.js").SessionMessageAgentSelected | import("./types.js").SessionMessageSynthetic | import("./types.js").SessionMessageSystem | import("./types.js").SessionMessageSkill | import("./types.js").SessionMessageShell | import("./types.js").SessionMessageCompactionRunning | import("./types.js").SessionMessageCompactionCompleted | import("./types.js").SessionMessageModelSelected | import("./types.js").SessionMessageCompactionFailed | import("./types.js").SessionMessageUser | import("./types.js").SessionMessageAssistant>;
76
+ message: (input: SessionMessageInput, requestOptions?: RequestOptions) => Promise<import("./types.js").SessionMessageAgentSelected | import("./types.js").SessionMessageSynthetic | import("./types.js").SessionMessageSystem | import("./types.js").SessionMessageSkill | import("./types.js").SessionMessageShell | import("./types.js").SessionMessageCompactionRunning | import("./types.js").SessionMessageCompactionCompleted | import("./types.js").SessionMessageModelSelected | import("./types.js").SessionMessageLocationSwitched | import("./types.js").SessionMessageCompactionFailed | import("./types.js").SessionMessageUser | import("./types.js").SessionMessageAssistant>;
77
77
  };
78
78
  message: {
79
79
  list: (input: MessageListInput, requestOptions?: RequestOptions) => Promise<import("./types.js").SessionMessagesResponse>;
@@ -618,6 +618,24 @@ export type PermissionRule = {
618
618
  resource: string;
619
619
  effect: PermissionEffect;
620
620
  };
621
+ export type SessionMessageLocationSwitched = {
622
+ id: string;
623
+ metadata?: {
624
+ [x: string]: JsonValue;
625
+ };
626
+ time: {
627
+ created: number;
628
+ };
629
+ type: "location-switched";
630
+ location: LocationRef;
631
+ projectID?: string;
632
+ subpath?: string;
633
+ previous?: {
634
+ location: LocationRef;
635
+ projectID?: string;
636
+ subpath?: string;
637
+ };
638
+ };
621
639
  export type SessionCreated = {
622
640
  id: string;
623
641
  created: number;
@@ -2797,7 +2815,7 @@ export type SessionInputAdmitted = {
2797
2815
  input: SessionPendingMessage;
2798
2816
  };
2799
2817
  };
2800
- export type SessionMessageInfo = SessionMessageAgentSelected | SessionMessageModelSelected | SessionMessageUser | SessionMessageSynthetic | SessionMessageSystem | SessionMessageSkill | SessionMessageShell | SessionMessageAssistant | SessionMessageCompaction;
2818
+ export type SessionMessageInfo = SessionMessageAgentSelected | SessionMessageModelSelected | SessionMessageLocationSwitched | SessionMessageUser | SessionMessageSynthetic | SessionMessageSystem | SessionMessageSkill | SessionMessageShell | SessionMessageAssistant | SessionMessageCompaction;
2801
2819
  export type IntegrationMethod = IntegrationOAuthMethod | IntegrationCommandMethod | IntegrationKeyMethod | IntegrationEnvMethod;
2802
2820
  export type FormCreated = {
2803
2821
  id: string;
@@ -3331,6 +3349,29 @@ export type SessionImportInput = {
3331
3349
  readonly providerID: string;
3332
3350
  readonly variant?: string;
3333
3351
  };
3352
+ } | {
3353
+ readonly id: string;
3354
+ readonly metadata?: {
3355
+ readonly [x: string]: JsonValue;
3356
+ };
3357
+ readonly time: {
3358
+ readonly created: number;
3359
+ };
3360
+ readonly type: "location-switched";
3361
+ readonly location: {
3362
+ readonly directory: string;
3363
+ readonly workspaceID?: string;
3364
+ };
3365
+ readonly projectID?: string;
3366
+ readonly subpath?: string;
3367
+ readonly previous?: {
3368
+ readonly location: {
3369
+ readonly directory: string;
3370
+ readonly workspaceID?: string;
3371
+ };
3372
+ readonly projectID?: string;
3373
+ readonly subpath?: string;
3374
+ };
3334
3375
  } | {
3335
3376
  readonly id: string;
3336
3377
  readonly metadata?: {
@@ -3714,6 +3755,29 @@ export type SessionImportInput = {
3714
3755
  readonly providerID: string;
3715
3756
  readonly variant?: string;
3716
3757
  };
3758
+ } | {
3759
+ readonly id: string;
3760
+ readonly metadata?: {
3761
+ readonly [x: string]: JsonValue;
3762
+ };
3763
+ readonly time: {
3764
+ readonly created: number;
3765
+ };
3766
+ readonly type: "location-switched";
3767
+ readonly location: {
3768
+ readonly directory: string;
3769
+ readonly workspaceID?: string;
3770
+ };
3771
+ readonly projectID?: string;
3772
+ readonly subpath?: string;
3773
+ readonly previous?: {
3774
+ readonly location: {
3775
+ readonly directory: string;
3776
+ readonly workspaceID?: string;
3777
+ };
3778
+ readonly projectID?: string;
3779
+ readonly subpath?: string;
3780
+ };
3717
3781
  } | {
3718
3782
  readonly id: string;
3719
3783
  readonly metadata?: {
@@ -4097,6 +4161,29 @@ export type SessionImportInput = {
4097
4161
  readonly providerID: string;
4098
4162
  readonly variant?: string;
4099
4163
  };
4164
+ } | {
4165
+ readonly id: string;
4166
+ readonly metadata?: {
4167
+ readonly [x: string]: JsonValue;
4168
+ };
4169
+ readonly time: {
4170
+ readonly created: number;
4171
+ };
4172
+ readonly type: "location-switched";
4173
+ readonly location: {
4174
+ readonly directory: string;
4175
+ readonly workspaceID?: string;
4176
+ };
4177
+ readonly projectID?: string;
4178
+ readonly subpath?: string;
4179
+ readonly previous?: {
4180
+ readonly location: {
4181
+ readonly directory: string;
4182
+ readonly workspaceID?: string;
4183
+ };
4184
+ readonly projectID?: string;
4185
+ readonly subpath?: string;
4186
+ };
4100
4187
  } | {
4101
4188
  readonly id: string;
4102
4189
  readonly metadata?: {
@@ -1,9 +1,14 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { spawn } from "node:child_process";
3
2
  import { homedir } from "node:os";
4
3
  import { join } from "node:path";
4
+ import { contenderFailure, contenderFinished, spawnServiceContender, } from "../service-contender.js";
5
5
  import { defaultEnsureTiming, ensureTiming } from "../service-timing.js";
6
6
  export * from "../service.js";
7
+ // Find, start, and stop the local opencode background service.
8
+ //
9
+ // The registration file is the complete discovery contract. This module is
10
+ // intentionally implemented with Node APIs so Promise clients do not need
11
+ // Effect or @effect/platform-node at runtime.
7
12
  /** Discover a healthy, compatible local service without starting one. */
8
13
  export async function discover(options = {}) {
9
14
  return (await discoverLocal(options))?.endpoint;
@@ -36,83 +41,69 @@ export async function ensure(options = {}) {
36
41
  if (command === undefined)
37
42
  throw new Error("Missing service command");
38
43
  try {
39
- const child = spawn(command, args, { detached: true, stdio: "ignore" });
40
- let error;
41
- child.once("error", (cause) => {
42
- error = new Error("Failed to start server", { cause });
43
- });
44
- child.unref();
45
- return { child, error: () => error };
44
+ return spawnServiceContender(command, args);
46
45
  }
47
46
  catch (cause) {
48
47
  throw new Error("Failed to start server", { cause });
49
48
  }
50
49
  };
51
- while (true) {
52
- if (Date.now() >= deadline)
53
- throw new Error("Timed out waiting for the background service to start");
54
- const registration = await registered(options.file, true, timing.requestTimeout);
55
- if (registration.timedOut && registration.info !== undefined) {
56
- timeouts = {
57
- info: registration.info,
58
- count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
59
- };
60
- if (timeouts.count >= 3) {
61
- announce("missing");
62
- await evict(registration.info, options, timing);
63
- timeouts = undefined;
64
- lastSpawn = Date.now() - spawnDelay;
65
- }
66
- }
67
- else
68
- timeouts = undefined;
69
- if (registration.service !== undefined) {
70
- spawnDelay = timing.spawnDelay;
71
- const service = registration.service;
72
- const compatible = !service.legacy && (options.version === undefined || service.version === options.version);
73
- if (compatible && service.state === "ready")
74
- return service.endpoint;
75
- if (compatible && service.state === "failed")
76
- throw new Error("Background service failed to start");
77
- if (!compatible) {
78
- announce("version-mismatch", service.version);
79
- await kill(service, options, timing).catch(() => undefined);
80
- lastSpawn = 0;
50
+ try {
51
+ while (true) {
52
+ if (Date.now() >= deadline)
53
+ throw new Error("Timed out waiting for the background service to start");
54
+ const registration = await registered(options.file, true, timing.requestTimeout);
55
+ if (registration.timedOut && registration.info !== undefined) {
56
+ timeouts = {
57
+ info: registration.info,
58
+ count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
59
+ };
60
+ if (timeouts.count >= 3) {
61
+ announce("missing");
62
+ await evict(registration.info, options, timing);
63
+ timeouts = undefined;
64
+ lastSpawn = Date.now() - spawnDelay;
65
+ }
81
66
  }
82
- }
83
- else {
84
- if (lastSpawn === 0 && registration.info !== undefined)
85
- lastSpawn = Date.now();
86
- const finished = [...contenders].filter(contenderFinished);
87
- const failure = finished.map(contenderFailure).find((error) => error !== undefined);
88
- if (finished.some((item) => item.child.exitCode === 0)) {
89
- spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay);
67
+ else
68
+ timeouts = undefined;
69
+ if (registration.service !== undefined) {
70
+ spawnDelay = timing.spawnDelay;
71
+ const service = registration.service;
72
+ const compatible = !service.legacy && (options.version === undefined || service.version === options.version);
73
+ if (compatible && service.state === "ready")
74
+ return service.endpoint;
75
+ if (compatible && service.state === "failed")
76
+ throw new Error("Background service failed to start");
77
+ if (!compatible) {
78
+ announce("version-mismatch", service.version);
79
+ await kill(service, options, timing).catch(() => undefined);
80
+ lastSpawn = 0;
81
+ }
90
82
  }
91
- finished.forEach((item) => contenders.delete(item));
92
- if (failure !== undefined && contenders.size === 0)
93
- throw failure;
94
- // Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
95
- if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
96
- announce("missing");
97
- contenders.add(spawnContender());
98
- lastSpawn = Date.now();
83
+ else {
84
+ if (lastSpawn === 0 && registration.info !== undefined)
85
+ lastSpawn = Date.now();
86
+ const finished = [...contenders].filter(contenderFinished);
87
+ const failure = finished.map(contenderFailure).find((error) => error !== undefined);
88
+ if (finished.some((item) => item.child.exitCode === 0)) {
89
+ spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay);
90
+ }
91
+ finished.forEach((item) => contenders.delete(item));
92
+ if (failure !== undefined && contenders.size === 0)
93
+ throw failure;
94
+ // Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
95
+ if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
96
+ announce("missing");
97
+ contenders.add(spawnContender());
98
+ lastSpawn = Date.now();
99
+ }
99
100
  }
101
+ await delay(timing.pollInterval);
100
102
  }
101
- await delay(timing.pollInterval);
102
103
  }
103
- }
104
- function contenderFailure(contender) {
105
- const error = contender.error();
106
- if (error !== undefined)
107
- return error;
108
- if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
109
- return new Error(`Server process exited with code ${contender.child.exitCode}`);
110
- if (contender.child.signalCode !== null)
111
- return new Error(`Server process terminated by ${contender.child.signalCode}`);
112
- return undefined;
113
- }
114
- function contenderFinished(contender) {
115
- return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null;
104
+ finally {
105
+ contenders.forEach((contender) => contender.release());
106
+ }
116
107
  }
117
108
  /** Stop the registered local service. */
118
109
  export async function stop(options = {}) {
@@ -0,0 +1,11 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ export type ServiceContender = {
3
+ readonly child: ChildProcess;
4
+ readonly error: () => Error | undefined;
5
+ readonly closed: () => boolean;
6
+ readonly stderr: () => string;
7
+ readonly release: () => void;
8
+ };
9
+ export declare function spawnServiceContender(command: string, args: ReadonlyArray<string>): ServiceContender;
10
+ export declare function contenderFailure(contender: ServiceContender): Error | undefined;
11
+ export declare function contenderFinished(contender: ServiceContender): boolean;
@@ -0,0 +1,52 @@
1
+ import { spawn } from "node:child_process";
2
+ const stderrLimit = 8 * 1024;
3
+ export function spawnServiceContender(command, args) {
4
+ const child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] });
5
+ let error;
6
+ let closed = false;
7
+ let stderr = Buffer.alloc(0);
8
+ const onStderr = (chunk) => {
9
+ const tail = chunk.subarray(-stderrLimit);
10
+ stderr =
11
+ tail.length === stderrLimit
12
+ ? Buffer.from(tail)
13
+ : Buffer.concat([stderr.subarray(-(stderrLimit - tail.length)), tail]);
14
+ };
15
+ child.stderr?.on("data", onStderr);
16
+ if (child.stderr !== null && "unref" in child.stderr && typeof child.stderr.unref === "function")
17
+ child.stderr.unref();
18
+ child.once("error", (cause) => {
19
+ error = new Error("Failed to start server", { cause });
20
+ });
21
+ child.once("close", () => {
22
+ closed = true;
23
+ });
24
+ child.unref();
25
+ return {
26
+ child,
27
+ error: () => error,
28
+ closed: () => closed,
29
+ stderr: () => stderr.toString("utf8").trim(),
30
+ release: () => {
31
+ child.stderr?.off("data", onStderr);
32
+ child.stderr?.resume();
33
+ stderr = Buffer.alloc(0);
34
+ },
35
+ };
36
+ }
37
+ export function contenderFailure(contender) {
38
+ const error = contender.error();
39
+ if (error !== undefined)
40
+ return error;
41
+ if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
42
+ return startupError(`Server process exited with code ${contender.child.exitCode}`, contender.stderr());
43
+ if (contender.child.signalCode !== null)
44
+ return startupError(`Server process terminated by ${contender.child.signalCode}`, contender.stderr());
45
+ return undefined;
46
+ }
47
+ export function contenderFinished(contender) {
48
+ return contender.error() !== undefined || contender.closed();
49
+ }
50
+ function startupError(message, stderr) {
51
+ return new Error(stderr ? `${message}\n${stderr}` : message);
52
+ }
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-17255",
4
+ "version": "0.0.0-next-17263",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -53,8 +53,8 @@
53
53
  "typecheck": "tsgo --noEmit"
54
54
  },
55
55
  "dependencies": {
56
- "@opencode-ai/schema": "0.0.0-next-17255",
57
- "@opencode-ai/protocol": "0.0.0-next-17255"
56
+ "@opencode-ai/schema": "0.0.0-next-17263",
57
+ "@opencode-ai/protocol": "0.0.0-next-17263"
58
58
  },
59
59
  "peerDependencies": {
60
60
  "effect": "4.0.0-beta.101"
@@ -66,7 +66,7 @@
66
66
  },
67
67
  "devDependencies": {
68
68
  "@effect/platform-node": "4.0.0-beta.101",
69
- "@opencode-ai/httpapi-codegen": "0.0.0-next-17255",
69
+ "@opencode-ai/httpapi-codegen": "0.0.0-next-17263",
70
70
  "@tsconfig/bun": "1.0.9",
71
71
  "@types/bun": "1.3.13",
72
72
  "@typescript/native-preview": "7.0.0-dev.20251207.1",