@opencode-cockpit/client 0.1.4 → 0.1.5

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.
package/dist/client.js ADDED
@@ -0,0 +1,253 @@
1
+ import { existsSync } from "node:fs";
2
+ import { ErrorCode, PROTOCOL_VERSION, RpcError, resolvePaths } from "@opencode-cockpit/protocol";
3
+ import { Connection } from "./connection.js";
4
+ import { releaseSpawnLock, spawnDaemon } from "./spawn.js";
5
+ /**
6
+ * Orders build ids (`<semver>+<hash>`, see daemonBuildId). Higher semver wins; equal versions with
7
+ * different hashes are local development builds, where the client's code counts as newer. A
8
+ * daemon without a build id predates build ids and is always older.
9
+ */
10
+ export function compareBuilds(client, daemon) {
11
+ if (!daemon) return 1;
12
+ if (client === daemon) return 0;
13
+ const [cv = "", ch = ""] = client.split("+");
14
+ const [dv = "", dh = ""] = daemon.split("+");
15
+ const order = compareSemver(cv, dv);
16
+ if (order !== 0) return order;
17
+ return ch === dh ? 0 : 1;
18
+ }
19
+ function compareSemver(a, b) {
20
+ const parse = v => {
21
+ const [core = "", pre] = v.split("-", 2);
22
+ return {
23
+ nums: core.split(".").map(n => Number.parseInt(n, 10) || 0),
24
+ pre
25
+ };
26
+ };
27
+ const x = parse(a);
28
+ const y = parse(b);
29
+ for (let i = 0; i < 3; i++) {
30
+ const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
31
+ if (d !== 0) return Math.sign(d);
32
+ }
33
+ if (x.pre === y.pre) return 0;
34
+ if (x.pre === undefined) return 1; // 1.0.0 > 1.0.0-beta
35
+ if (y.pre === undefined) return -1;
36
+ return x.pre < y.pre ? -1 : 1;
37
+ }
38
+ const IDEMPOTENT = new Set(["daemon.hello", "daemon.status", "shell.list", "shell.get", "shell.read", "shell.screen", "shell.wait"]);
39
+ /**
40
+ * Typed, reconnecting client for cockpitd. Calls transparently (re)connect and, when allowed,
41
+ * start the daemon. Subscriptions survive reconnects.
42
+ */
43
+ export class CockpitClient {
44
+ listeners = new Map();
45
+ stateListeners = new Set();
46
+ closed = false;
47
+ outdatedListeners = new Set();
48
+ constructor(options) {
49
+ this.options = options;
50
+ this.paths = options.paths ?? resolvePaths();
51
+ }
52
+ get daemon() {
53
+ return this.hello;
54
+ }
55
+ get connected() {
56
+ return this.connection !== undefined && !this.connection.closed;
57
+ }
58
+ async call(method, ...args) {
59
+ const conn = await this.ensure();
60
+ try {
61
+ return await conn.request(method, args[0]);
62
+ } catch (err) {
63
+ // The daemon went away under us. Methods without side effects are safe to replay once.
64
+ const lost = err instanceof RpcError && err.code === ErrorCode.ShuttingDown && conn.closed && !this.closed;
65
+ if (!lost || !IDEMPOTENT.has(method)) throw err;
66
+ const next = await this.ensure();
67
+ return await next.request(method, args[0]);
68
+ }
69
+ }
70
+
71
+ /** Listen to a topic. Returns an unsubscribe function. */
72
+ on(topic, listener) {
73
+ let set = this.listeners.get(topic);
74
+ const isNew = !set;
75
+ if (!set) {
76
+ set = new Set();
77
+ this.listeners.set(topic, set);
78
+ }
79
+ set.add(listener);
80
+ if (isNew && this.connected) void this.connection?.request("events.subscribe", {
81
+ topics: [topic]
82
+ }).catch(() => {});else if (isNew) void this.ensure().catch(() => {});
83
+ return () => {
84
+ set.delete(listener);
85
+ if (set.size === 0) {
86
+ this.listeners.delete(topic);
87
+ if (this.connected) void this.connection?.request("events.unsubscribe", {
88
+ topics: [topic]
89
+ }).catch(() => {});
90
+ }
91
+ };
92
+ }
93
+
94
+ /** Set while connected to a daemon running different code than `expectedBuild`. */
95
+ get outdated() {
96
+ return this.outdatedInfo;
97
+ }
98
+ onOutdated(listener) {
99
+ this.outdatedListeners.add(listener);
100
+ return () => this.outdatedListeners.delete(listener);
101
+ }
102
+
103
+ /**
104
+ * Stop the daemon and start a fresh one from this client's code. Without `force` it refuses
105
+ * while shells are running. Returns false when refused.
106
+ */
107
+ async restartDaemon(options = {}) {
108
+ const conn = await this.ensure();
109
+ const {
110
+ accepted
111
+ } = await conn.request("daemon.shutdown", {
112
+ force: options.force === true
113
+ });
114
+ if (!accepted) return false;
115
+ await this.waitForSocketGone();
116
+ await this.ensure();
117
+ return true;
118
+ }
119
+ onState(listener) {
120
+ this.stateListeners.add(listener);
121
+ return () => this.stateListeners.delete(listener);
122
+ }
123
+
124
+ /** Connect now (spawning if configured). Useful to surface errors early. */
125
+ async connect() {
126
+ await this.ensure();
127
+ return this.hello;
128
+ }
129
+ close() {
130
+ this.closed = true;
131
+ this.connection?.close();
132
+ this.connection = undefined;
133
+ }
134
+ ensure() {
135
+ if (this.closed) return Promise.reject(new RpcError(ErrorCode.ShuttingDown, "client closed"));
136
+ if (this.connection && !this.connection.closed) return Promise.resolve(this.connection);
137
+ this.connecting ??= this.establish().finally(() => {
138
+ this.connecting = undefined;
139
+ });
140
+ return this.connecting;
141
+ }
142
+ async establish(replaced = false) {
143
+ let conn = await this.tryOpen();
144
+ if (!conn) {
145
+ if (!this.options.spawn) {
146
+ throw new RpcError(ErrorCode.ShuttingDown, `cockpitd is not running (${this.paths.socket})`);
147
+ }
148
+ const spawned = spawnDaemon(this.paths, this.options.spawn);
149
+ try {
150
+ conn = await this.waitForSocket(this.options.connectTimeoutMs ?? 8000);
151
+ } finally {
152
+ if (spawned) releaseSpawnLock(this.paths);
153
+ }
154
+ }
155
+ try {
156
+ this.hello = await conn.request("daemon.hello", {
157
+ client: this.options.client,
158
+ protocol: PROTOCOL_VERSION
159
+ });
160
+ } catch (err) {
161
+ conn.close();
162
+ if (err instanceof RpcError && err.code === ErrorCode.ProtocolMismatch && this.options.spawn && !replaced) {
163
+ return this.replaceIncompatibleDaemon(err);
164
+ }
165
+ throw err;
166
+ }
167
+ const expected = this.options.expectedBuild;
168
+ // Only move forward: several plugins at different versions share one daemon, and letting an
169
+ // older one replace a newer daemon would make them take turns replacing each other.
170
+ if (expected && compareBuilds(expected, this.hello.build) > 0) {
171
+ const status = await conn.request("daemon.status", {});
172
+ const busy = status.modules.some(m => m.busy);
173
+ if (!busy && this.options.spawn && !replaced) {
174
+ await conn.request("daemon.shutdown", {}).catch(() => {});
175
+ conn.close();
176
+ await this.waitForSocketGone();
177
+ return this.establish(true);
178
+ }
179
+ this.setOutdated({
180
+ running: this.hello.build,
181
+ expected
182
+ });
183
+ } else {
184
+ this.setOutdated(undefined);
185
+ }
186
+ this.connection = conn;
187
+ const topics = [...this.listeners.keys()];
188
+ if (topics.length > 0) await conn.request("events.subscribe", {
189
+ topics
190
+ });
191
+ for (const l of this.stateListeners) l("connected");
192
+ return conn;
193
+ }
194
+
195
+ /** An older daemon speaks another protocol. Replace it only if nothing is running in it. */
196
+ async replaceIncompatibleDaemon(err) {
197
+ const data = err.data;
198
+ if (data?.busy) {
199
+ throw new RpcError(ErrorCode.ProtocolMismatch, "cockpitd is running an incompatible version and has running shells; stop them or restart the daemon", err.data);
200
+ }
201
+ const pid = await Bun.file(this.paths.pidFile).text().then(t => Number.parseInt(t, 10)).catch(() => Number.NaN);
202
+ if (Number.isFinite(pid)) {
203
+ try {
204
+ process.kill(pid, "SIGTERM");
205
+ } catch {
206
+ // already gone
207
+ }
208
+ }
209
+ await this.waitForSocketGone();
210
+ return this.establish(true);
211
+ }
212
+ async waitForSocketGone(timeoutMs = 5000) {
213
+ const deadline = Date.now() + timeoutMs;
214
+ // Bun.file().exists() reports false for unix sockets; use a stat-based check.
215
+ while (Date.now() < deadline && existsSync(this.paths.socket)) await Bun.sleep(50);
216
+ }
217
+ setOutdated(info) {
218
+ const changed = info?.running !== this.outdatedInfo?.running || info === undefined !== (this.outdatedInfo === undefined);
219
+ this.outdatedInfo = info;
220
+ if (changed) for (const l of this.outdatedListeners) l(info);
221
+ }
222
+ async tryOpen() {
223
+ try {
224
+ return await Connection.open(this.paths.socket, event => this.dispatch(event), () => this.handleDisconnect());
225
+ } catch {
226
+ return undefined;
227
+ }
228
+ }
229
+ async waitForSocket(timeoutMs) {
230
+ const deadline = Date.now() + timeoutMs;
231
+ let delay = 25;
232
+ while (Date.now() < deadline) {
233
+ const conn = await this.tryOpen();
234
+ if (conn) return conn;
235
+ await Bun.sleep(delay);
236
+ delay = Math.min(delay * 2, 250);
237
+ }
238
+ throw new RpcError(ErrorCode.ShuttingDown, `cockpitd did not start within ${timeoutMs}ms; see ${this.paths.logFile}`);
239
+ }
240
+ handleDisconnect() {
241
+ this.connection = undefined;
242
+ for (const l of this.stateListeners) l("disconnected");
243
+ }
244
+ dispatch(event) {
245
+ for (const listener of this.listeners.get(event.topic) ?? []) {
246
+ try {
247
+ listener(event.data, event.topic);
248
+ } catch {
249
+ // a listener's failure must not break delivery to others
250
+ }
251
+ }
252
+ }
253
+ }
@@ -0,0 +1,113 @@
1
+ import { ErrorCode, EVENT_METHOD, encodeFrame, LineDecoder, RpcError } from "@opencode-cockpit/protocol";
2
+ /** One socket to the daemon: request/response correlation, events, write backpressure. */
3
+ export class Connection {
4
+ pending = new Map();
5
+ decoder = new LineDecoder();
6
+ queue = [];
7
+ nextId = 1;
8
+ closedFlag = false;
9
+ constructor(socket, onEvent, onClose) {
10
+ this.socket = socket;
11
+ this.onEvent = onEvent;
12
+ this.onClose = onClose;
13
+ }
14
+ static open(path, onEvent, onClose) {
15
+ return new Promise((resolve, reject) => {
16
+ let conn;
17
+ Bun.connect({
18
+ unix: path,
19
+ socket: {
20
+ open(socket) {
21
+ conn = new Connection(socket, onEvent, onClose);
22
+ resolve(conn);
23
+ },
24
+ data(_socket, chunk) {
25
+ conn?.receive(chunk);
26
+ },
27
+ drain() {
28
+ conn?.drain();
29
+ },
30
+ close() {
31
+ conn?.handleClose();
32
+ },
33
+ error(_socket, err) {
34
+ if (conn) conn.handleClose();else reject(err);
35
+ },
36
+ connectError(_socket, err) {
37
+ reject(err);
38
+ }
39
+ }
40
+ }).catch(reject);
41
+ });
42
+ }
43
+ get closed() {
44
+ return this.closedFlag;
45
+ }
46
+ request(method, params) {
47
+ if (this.closedFlag) return Promise.reject(new RpcError(ErrorCode.ShuttingDown, "connection closed"));
48
+ const id = this.nextId++;
49
+ return new Promise((resolve, reject) => {
50
+ this.pending.set(id, {
51
+ resolve,
52
+ reject
53
+ });
54
+ this.write({
55
+ jsonrpc: "2.0",
56
+ id,
57
+ method,
58
+ params
59
+ });
60
+ });
61
+ }
62
+ close() {
63
+ this.socket.end();
64
+ this.handleClose();
65
+ }
66
+ write(message) {
67
+ const frame = encodeFrame(message);
68
+ if (this.queue.length > 0) {
69
+ this.queue.push(frame);
70
+ return;
71
+ }
72
+ const written = this.socket.write(frame);
73
+ if (written < frame.byteLength) this.queue.push(frame.subarray(Math.max(0, written)));
74
+ }
75
+ drain() {
76
+ while (this.queue.length > 0) {
77
+ const head = this.queue[0];
78
+ const written = this.socket.write(head);
79
+ if (written < head.byteLength) {
80
+ this.queue[0] = head.subarray(Math.max(0, written));
81
+ return;
82
+ }
83
+ this.queue.shift();
84
+ }
85
+ }
86
+ receive(chunk) {
87
+ for (const line of this.decoder.push(chunk)) {
88
+ let message;
89
+ try {
90
+ message = JSON.parse(line);
91
+ } catch {
92
+ continue;
93
+ }
94
+ if ("method" in message) {
95
+ if (message.method === EVENT_METHOD) this.onEvent(message.params);
96
+ continue;
97
+ }
98
+ if (message.id === null) continue;
99
+ const pending = this.pending.get(message.id);
100
+ if (!pending) continue;
101
+ this.pending.delete(message.id);
102
+ if ("error" in message) pending.reject(RpcError.from(message.error));else pending.resolve(message.result);
103
+ }
104
+ }
105
+ handleClose() {
106
+ if (this.closedFlag) return;
107
+ this.closedFlag = true;
108
+ const error = new RpcError(ErrorCode.ShuttingDown, "connection to cockpitd closed");
109
+ for (const pending of this.pending.values()) pending.reject(error);
110
+ this.pending.clear();
111
+ this.onClose();
112
+ }
113
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Guards against loading the same cockpit feature twice, e.g. when both `opencode-cockpit` and
3
+ * `@opencode-cockpit/shell` are configured. OpenCode does not deduplicate plugin tools, and
4
+ * duplicate tool names make model requests fail, so the first copy loaded wins and later copies
5
+ * stay inactive.
6
+ *
7
+ * Claims are scoped to an object that all plugins of one OpenCode instance share: the plugin
8
+ * input on the server side, the renderer in the TUI. Separate instances claim independently.
9
+ */
10
+
11
+ const REGISTRY = Symbol.for("opencode-cockpit.features");
12
+ function registry() {
13
+ const host = globalThis;
14
+ host[REGISTRY] ??= new WeakMap();
15
+ return host[REGISTRY];
16
+ }
17
+ export function claimFeature(scope, feature, source) {
18
+ const reg = registry();
19
+ let claims = reg.get(scope);
20
+ if (!claims) {
21
+ claims = new Map();
22
+ reg.set(scope, claims);
23
+ }
24
+ const owner = claims.get(feature);
25
+ if (owner !== undefined) return {
26
+ active: false,
27
+ owner,
28
+ release() {}
29
+ };
30
+ claims.set(feature, source);
31
+ let released = false;
32
+ return {
33
+ active: true,
34
+ owner: source,
35
+ release() {
36
+ if (released) return;
37
+ released = true;
38
+ if (claims.get(feature) === source) claims.delete(feature);
39
+ }
40
+ };
41
+ }
42
+ export function duplicateFeatureMessage(feature, owner, skipped) {
43
+ return `${feature} is configured twice (${owner} and ${skipped}). Using ${owner}; remove one of them from your OpenCode config.`;
44
+ }
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { CockpitClient, compareBuilds } from "./client.js";
2
+ export { claimFeature, duplicateFeatureMessage } from "./feature.js";
package/dist/spawn.js ADDED
@@ -0,0 +1,65 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, mkdirSync, openSync, rmSync, statSync, writeSync } from "node:fs";
3
+ const LOCK_STALE_MS = 15_000;
4
+
5
+ /**
6
+ * Takes an exclusive spawn lock so concurrent first calls start one daemon. Returns false when
7
+ * another process holds a fresh lock (it is spawning; the caller should just wait and connect).
8
+ */
9
+ export function spawnDaemon(paths, options) {
10
+ mkdirSync(paths.home, {
11
+ recursive: true,
12
+ mode: 0o700
13
+ });
14
+ if (!acquireLock(paths.lockFile)) return false;
15
+ try {
16
+ const child = spawn(options.execPath ?? process.execPath, [options.entry], {
17
+ detached: true,
18
+ stdio: "ignore",
19
+ env: {
20
+ ...process.env,
21
+ ...options.env,
22
+ BUN_BE_BUN: "1",
23
+ COCKPIT_HOME: paths.home
24
+ }
25
+ });
26
+ child.unref();
27
+ } catch (err) {
28
+ releaseLock(paths.lockFile);
29
+ throw err;
30
+ }
31
+ // The lock is released after the caller connects or times out; see releaseSpawnLock.
32
+ return true;
33
+ }
34
+ export function releaseSpawnLock(paths) {
35
+ releaseLock(paths.lockFile);
36
+ }
37
+ function acquireLock(file) {
38
+ for (let attempt = 0; attempt < 2; attempt++) {
39
+ try {
40
+ const fd = openSync(file, "wx", 0o600);
41
+ writeSync(fd, String(process.pid));
42
+ closeSync(fd);
43
+ return true;
44
+ } catch (err) {
45
+ if (err.code !== "EEXIST") throw err;
46
+ try {
47
+ if (Date.now() - statSync(file).mtimeMs > LOCK_STALE_MS) {
48
+ rmSync(file, {
49
+ force: true
50
+ });
51
+ continue;
52
+ }
53
+ } catch {
54
+ continue; // vanished between calls; retry
55
+ }
56
+ return false;
57
+ }
58
+ }
59
+ return false;
60
+ }
61
+ function releaseLock(file) {
62
+ rmSync(file, {
63
+ force: true
64
+ });
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode-cockpit/client",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Auto-spawning, reconnecting, typed client for cockpitd",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,10 +20,14 @@
20
20
  "json-rpc"
21
21
  ],
22
22
  "exports": {
23
- ".": "./src/index.ts"
23
+ ".": {
24
+ "types": "./types/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ }
24
27
  },
25
28
  "files": [
26
- "src",
29
+ "dist",
30
+ "types",
27
31
  "README.md",
28
32
  "LICENSE"
29
33
  ],
@@ -31,10 +35,10 @@
31
35
  "access": "public"
32
36
  },
33
37
  "dependencies": {
34
- "@opencode-cockpit/protocol": "0.1.4"
38
+ "@opencode-cockpit/protocol": "0.1.5"
35
39
  },
36
40
  "devDependencies": {
37
- "@opencode-cockpit/daemon": "0.1.4"
41
+ "@opencode-cockpit/daemon": "0.1.5"
38
42
  },
39
43
  "engines": {
40
44
  "bun": ">=1.3.5"
@@ -0,0 +1,75 @@
1
+ import { type ClientInfo, type CockpitPaths, type EventOf, type Events, type HelloResult, type MethodName, type Methods, type ParamsOf, type ResultOf, type Topic } from "@opencode-cockpit/protocol";
2
+ import { type SpawnOptions } from "./spawn.ts";
3
+ export interface ClientOptions {
4
+ client: ClientInfo;
5
+ paths?: CockpitPaths;
6
+ /** Start the daemon when it is not running. Omit to only connect. */
7
+ spawn?: SpawnOptions;
8
+ /** How long to wait for a freshly spawned daemon. */
9
+ connectTimeoutMs?: number;
10
+ /**
11
+ * Build id of the daemon code this client ships with (see `daemonBuildId`). When the running
12
+ * daemon differs, an idle daemon is replaced automatically; a busy one is kept and reported
13
+ * through `onOutdated` so running shells are never killed behind the user's back.
14
+ */
15
+ expectedBuild?: string;
16
+ }
17
+ export interface OutdatedDaemon {
18
+ running: string | undefined;
19
+ expected: string;
20
+ }
21
+ /**
22
+ * Orders build ids (`<semver>+<hash>`, see daemonBuildId). Higher semver wins; equal versions with
23
+ * different hashes are local development builds, where the client's code counts as newer. A
24
+ * daemon without a build id predates build ids and is always older.
25
+ */
26
+ export declare function compareBuilds(client: string, daemon: string | undefined): number;
27
+ export type ConnectionState = "connected" | "disconnected";
28
+ /**
29
+ * Typed, reconnecting client for cockpitd. Calls transparently (re)connect and, when allowed,
30
+ * start the daemon. Subscriptions survive reconnects.
31
+ */
32
+ export declare class CockpitClient {
33
+ private readonly options;
34
+ readonly paths: CockpitPaths;
35
+ private connection;
36
+ private connecting;
37
+ private readonly listeners;
38
+ private readonly stateListeners;
39
+ private hello;
40
+ private closed;
41
+ private outdatedInfo;
42
+ private readonly outdatedListeners;
43
+ constructor(options: ClientOptions);
44
+ get daemon(): HelloResult | undefined;
45
+ get connected(): boolean;
46
+ call<M extends MethodName>(method: M, ...args: ParamsArg<M>): Promise<ResultOf<Methods, M>>;
47
+ /** Listen to a topic. Returns an unsubscribe function. */
48
+ on<T extends Topic>(topic: T, listener: (data: EventOf<Events, T>) => void): () => void;
49
+ /** Set while connected to a daemon running different code than `expectedBuild`. */
50
+ get outdated(): OutdatedDaemon | undefined;
51
+ onOutdated(listener: (info: OutdatedDaemon | undefined) => void): () => void;
52
+ /**
53
+ * Stop the daemon and start a fresh one from this client's code. Without `force` it refuses
54
+ * while shells are running. Returns false when refused.
55
+ */
56
+ restartDaemon(options?: {
57
+ force?: boolean;
58
+ }): Promise<boolean>;
59
+ onState(listener: (state: ConnectionState) => void): () => void;
60
+ /** Connect now (spawning if configured). Useful to surface errors early. */
61
+ connect(): Promise<HelloResult>;
62
+ close(): void;
63
+ private ensure;
64
+ private establish;
65
+ /** An older daemon speaks another protocol. Replace it only if nothing is running in it. */
66
+ private replaceIncompatibleDaemon;
67
+ private waitForSocketGone;
68
+ private setOutdated;
69
+ private tryOpen;
70
+ private waitForSocket;
71
+ private handleDisconnect;
72
+ private dispatch;
73
+ }
74
+ type ParamsArg<M extends MethodName> = undefined extends ParamsOf<Methods, M> ? [params?: ParamsOf<Methods, M>] : [params: ParamsOf<Methods, M>];
75
+ export {};
@@ -0,0 +1,21 @@
1
+ import { type EventEnvelope } from "@opencode-cockpit/protocol";
2
+ /** One socket to the daemon: request/response correlation, events, write backpressure. */
3
+ export declare class Connection {
4
+ private socket;
5
+ private readonly onEvent;
6
+ private readonly onClose;
7
+ private readonly pending;
8
+ private readonly decoder;
9
+ private queue;
10
+ private nextId;
11
+ private closedFlag;
12
+ private constructor();
13
+ static open(path: string, onEvent: (e: EventEnvelope) => void, onClose: () => void): Promise<Connection>;
14
+ get closed(): boolean;
15
+ request(method: string, params: unknown): Promise<unknown>;
16
+ close(): void;
17
+ private write;
18
+ private drain;
19
+ private receive;
20
+ private handleClose;
21
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Guards against loading the same cockpit feature twice, e.g. when both `opencode-cockpit` and
3
+ * `@opencode-cockpit/shell` are configured. OpenCode does not deduplicate plugin tools, and
4
+ * duplicate tool names make model requests fail, so the first copy loaded wins and later copies
5
+ * stay inactive.
6
+ *
7
+ * Claims are scoped to an object that all plugins of one OpenCode instance share: the plugin
8
+ * input on the server side, the renderer in the TUI. Separate instances claim independently.
9
+ */
10
+ export interface FeatureClaim {
11
+ /** True when this copy owns the feature and should register itself. */
12
+ active: boolean;
13
+ /** Source label of the copy that owns the feature. */
14
+ owner: string;
15
+ /** Give up ownership, so a reloaded plugin can claim again. No-op for inactive claims. */
16
+ release(): void;
17
+ }
18
+ export declare function claimFeature(scope: object, feature: string, source: string): FeatureClaim;
19
+ export declare function duplicateFeatureMessage(feature: string, owner: string, skipped: string): string;
@@ -0,0 +1,3 @@
1
+ export { type ClientOptions, CockpitClient, type ConnectionState, compareBuilds, type OutdatedDaemon, } from "./client.ts";
2
+ export { claimFeature, duplicateFeatureMessage, type FeatureClaim } from "./feature.ts";
3
+ export type { SpawnOptions } from "./spawn.ts";
@@ -0,0 +1,14 @@
1
+ import type { CockpitPaths } from "@opencode-cockpit/protocol";
2
+ export interface SpawnOptions {
3
+ /** Path to the daemon entry script (`@opencode-cockpit/daemon/main`). */
4
+ entry: string;
5
+ /** Runtime used to run it. Inside OpenCode this is the OpenCode binary, run with BUN_BE_BUN=1. */
6
+ execPath?: string;
7
+ env?: Record<string, string | undefined>;
8
+ }
9
+ /**
10
+ * Takes an exclusive spawn lock so concurrent first calls start one daemon. Returns false when
11
+ * another process holds a fresh lock (it is spawning; the caller should just wait and connect).
12
+ */
13
+ export declare function spawnDaemon(paths: CockpitPaths, options: SpawnOptions): boolean;
14
+ export declare function releaseSpawnLock(paths: CockpitPaths): void;
package/src/client.ts DELETED
@@ -1,340 +0,0 @@
1
- import { existsSync } from "node:fs"
2
- import {
3
- type ClientInfo,
4
- type CockpitPaths,
5
- ErrorCode,
6
- type EventEnvelope,
7
- type EventOf,
8
- type Events,
9
- type HelloResult,
10
- type MethodName,
11
- type Methods,
12
- type ParamsOf,
13
- PROTOCOL_VERSION,
14
- type ResultOf,
15
- RpcError,
16
- resolvePaths,
17
- type Topic,
18
- } from "@opencode-cockpit/protocol"
19
- import { Connection } from "./connection.ts"
20
- import { releaseSpawnLock, type SpawnOptions, spawnDaemon } from "./spawn.ts"
21
-
22
- export interface ClientOptions {
23
- client: ClientInfo
24
- paths?: CockpitPaths
25
- /** Start the daemon when it is not running. Omit to only connect. */
26
- spawn?: SpawnOptions
27
- /** How long to wait for a freshly spawned daemon. */
28
- connectTimeoutMs?: number
29
- /**
30
- * Build id of the daemon code this client ships with (see `daemonBuildId`). When the running
31
- * daemon differs, an idle daemon is replaced automatically; a busy one is kept and reported
32
- * through `onOutdated` so running shells are never killed behind the user's back.
33
- */
34
- expectedBuild?: string
35
- }
36
-
37
- export interface OutdatedDaemon {
38
- running: string | undefined
39
- expected: string
40
- }
41
-
42
- /**
43
- * Orders build ids (`<semver>+<hash>`, see daemonBuildId). Higher semver wins; equal versions with
44
- * different hashes are local development builds, where the client's code counts as newer. A
45
- * daemon without a build id predates build ids and is always older.
46
- */
47
- export function compareBuilds(client: string, daemon: string | undefined): number {
48
- if (!daemon) return 1
49
- if (client === daemon) return 0
50
- const [cv = "", ch = ""] = client.split("+")
51
- const [dv = "", dh = ""] = daemon.split("+")
52
- const order = compareSemver(cv, dv)
53
- if (order !== 0) return order
54
- return ch === dh ? 0 : 1
55
- }
56
-
57
- function compareSemver(a: string, b: string): number {
58
- const parse = (v: string) => {
59
- const [core = "", pre] = v.split("-", 2)
60
- return { nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre }
61
- }
62
- const x = parse(a)
63
- const y = parse(b)
64
- for (let i = 0; i < 3; i++) {
65
- const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0)
66
- if (d !== 0) return Math.sign(d)
67
- }
68
- if (x.pre === y.pre) return 0
69
- if (x.pre === undefined) return 1 // 1.0.0 > 1.0.0-beta
70
- if (y.pre === undefined) return -1
71
- return x.pre < y.pre ? -1 : 1
72
- }
73
-
74
- const IDEMPOTENT = new Set<string>([
75
- "daemon.hello",
76
- "daemon.status",
77
- "shell.list",
78
- "shell.get",
79
- "shell.read",
80
- "shell.screen",
81
- "shell.wait",
82
- ])
83
-
84
- type Listener = (data: unknown, topic: string) => void
85
- export type ConnectionState = "connected" | "disconnected"
86
-
87
- /**
88
- * Typed, reconnecting client for cockpitd. Calls transparently (re)connect and, when allowed,
89
- * start the daemon. Subscriptions survive reconnects.
90
- */
91
- export class CockpitClient {
92
- readonly paths: CockpitPaths
93
- private connection: Connection | undefined
94
- private connecting: Promise<Connection> | undefined
95
- private readonly listeners = new Map<string, Set<Listener>>()
96
- private readonly stateListeners = new Set<(state: ConnectionState) => void>()
97
- private hello: HelloResult | undefined
98
- private closed = false
99
- private outdatedInfo: OutdatedDaemon | undefined
100
- private readonly outdatedListeners = new Set<(info: OutdatedDaemon | undefined) => void>()
101
-
102
- constructor(private readonly options: ClientOptions) {
103
- this.paths = options.paths ?? resolvePaths()
104
- }
105
-
106
- get daemon(): HelloResult | undefined {
107
- return this.hello
108
- }
109
-
110
- get connected(): boolean {
111
- return this.connection !== undefined && !this.connection.closed
112
- }
113
-
114
- async call<M extends MethodName>(method: M, ...args: ParamsArg<M>): Promise<ResultOf<Methods, M>> {
115
- const conn = await this.ensure()
116
- try {
117
- return (await conn.request(method, args[0])) as ResultOf<Methods, M>
118
- } catch (err) {
119
- // The daemon went away under us. Methods without side effects are safe to replay once.
120
- const lost =
121
- err instanceof RpcError && err.code === ErrorCode.ShuttingDown && conn.closed && !this.closed
122
- if (!lost || !IDEMPOTENT.has(method)) throw err
123
- const next = await this.ensure()
124
- return (await next.request(method, args[0])) as ResultOf<Methods, M>
125
- }
126
- }
127
-
128
- /** Listen to a topic. Returns an unsubscribe function. */
129
- on<T extends Topic>(topic: T, listener: (data: EventOf<Events, T>) => void): () => void {
130
- let set = this.listeners.get(topic)
131
- const isNew = !set
132
- if (!set) {
133
- set = new Set()
134
- this.listeners.set(topic, set)
135
- }
136
- set.add(listener as Listener)
137
- if (isNew && this.connected)
138
- void this.connection?.request("events.subscribe", { topics: [topic] }).catch(() => {})
139
- else if (isNew) void this.ensure().catch(() => {})
140
- return () => {
141
- set.delete(listener as Listener)
142
- if (set.size === 0) {
143
- this.listeners.delete(topic)
144
- if (this.connected)
145
- void this.connection?.request("events.unsubscribe", { topics: [topic] }).catch(() => {})
146
- }
147
- }
148
- }
149
-
150
- /** Set while connected to a daemon running different code than `expectedBuild`. */
151
- get outdated(): OutdatedDaemon | undefined {
152
- return this.outdatedInfo
153
- }
154
-
155
- onOutdated(listener: (info: OutdatedDaemon | undefined) => void): () => void {
156
- this.outdatedListeners.add(listener)
157
- return () => this.outdatedListeners.delete(listener)
158
- }
159
-
160
- /**
161
- * Stop the daemon and start a fresh one from this client's code. Without `force` it refuses
162
- * while shells are running. Returns false when refused.
163
- */
164
- async restartDaemon(options: { force?: boolean } = {}): Promise<boolean> {
165
- const conn = await this.ensure()
166
- const { accepted } = (await conn.request("daemon.shutdown", { force: options.force === true })) as {
167
- accepted: boolean
168
- }
169
- if (!accepted) return false
170
- await this.waitForSocketGone()
171
- await this.ensure()
172
- return true
173
- }
174
-
175
- onState(listener: (state: ConnectionState) => void): () => void {
176
- this.stateListeners.add(listener)
177
- return () => this.stateListeners.delete(listener)
178
- }
179
-
180
- /** Connect now (spawning if configured). Useful to surface errors early. */
181
- async connect(): Promise<HelloResult> {
182
- await this.ensure()
183
- return this.hello as HelloResult
184
- }
185
-
186
- close(): void {
187
- this.closed = true
188
- this.connection?.close()
189
- this.connection = undefined
190
- }
191
-
192
- private ensure(): Promise<Connection> {
193
- if (this.closed) return Promise.reject(new RpcError(ErrorCode.ShuttingDown, "client closed"))
194
- if (this.connection && !this.connection.closed) return Promise.resolve(this.connection)
195
- this.connecting ??= this.establish().finally(() => {
196
- this.connecting = undefined
197
- })
198
- return this.connecting
199
- }
200
-
201
- private async establish(replaced = false): Promise<Connection> {
202
- let conn = await this.tryOpen()
203
- if (!conn) {
204
- if (!this.options.spawn) {
205
- throw new RpcError(ErrorCode.ShuttingDown, `cockpitd is not running (${this.paths.socket})`)
206
- }
207
- const spawned = spawnDaemon(this.paths, this.options.spawn)
208
- try {
209
- conn = await this.waitForSocket(this.options.connectTimeoutMs ?? 8000)
210
- } finally {
211
- if (spawned) releaseSpawnLock(this.paths)
212
- }
213
- }
214
-
215
- try {
216
- this.hello = (await conn.request("daemon.hello", {
217
- client: this.options.client,
218
- protocol: PROTOCOL_VERSION,
219
- })) as HelloResult
220
- } catch (err) {
221
- conn.close()
222
- if (
223
- err instanceof RpcError &&
224
- err.code === ErrorCode.ProtocolMismatch &&
225
- this.options.spawn &&
226
- !replaced
227
- ) {
228
- return this.replaceIncompatibleDaemon(err)
229
- }
230
- throw err
231
- }
232
-
233
- const expected = this.options.expectedBuild
234
- // Only move forward: several plugins at different versions share one daemon, and letting an
235
- // older one replace a newer daemon would make them take turns replacing each other.
236
- if (expected && compareBuilds(expected, this.hello.build) > 0) {
237
- const status = (await conn.request("daemon.status", {})) as { modules: { busy: boolean }[] }
238
- const busy = status.modules.some((m) => m.busy)
239
- if (!busy && this.options.spawn && !replaced) {
240
- await conn.request("daemon.shutdown", {}).catch(() => {})
241
- conn.close()
242
- await this.waitForSocketGone()
243
- return this.establish(true)
244
- }
245
- this.setOutdated({ running: this.hello.build, expected })
246
- } else {
247
- this.setOutdated(undefined)
248
- }
249
-
250
- this.connection = conn
251
- const topics = [...this.listeners.keys()]
252
- if (topics.length > 0) await conn.request("events.subscribe", { topics })
253
- for (const l of this.stateListeners) l("connected")
254
- return conn
255
- }
256
-
257
- /** An older daemon speaks another protocol. Replace it only if nothing is running in it. */
258
- private async replaceIncompatibleDaemon(err: RpcError): Promise<Connection> {
259
- const data = err.data as { busy?: boolean } | undefined
260
- if (data?.busy) {
261
- throw new RpcError(
262
- ErrorCode.ProtocolMismatch,
263
- "cockpitd is running an incompatible version and has running shells; stop them or restart the daemon",
264
- err.data,
265
- )
266
- }
267
- const pid = await Bun.file(this.paths.pidFile)
268
- .text()
269
- .then((t) => Number.parseInt(t, 10))
270
- .catch(() => Number.NaN)
271
- if (Number.isFinite(pid)) {
272
- try {
273
- process.kill(pid, "SIGTERM")
274
- } catch {
275
- // already gone
276
- }
277
- }
278
- await this.waitForSocketGone()
279
- return this.establish(true)
280
- }
281
-
282
- private async waitForSocketGone(timeoutMs = 5000): Promise<void> {
283
- const deadline = Date.now() + timeoutMs
284
- // Bun.file().exists() reports false for unix sockets; use a stat-based check.
285
- while (Date.now() < deadline && existsSync(this.paths.socket)) await Bun.sleep(50)
286
- }
287
-
288
- private setOutdated(info: OutdatedDaemon | undefined): void {
289
- const changed =
290
- info?.running !== this.outdatedInfo?.running ||
291
- (info === undefined) !== (this.outdatedInfo === undefined)
292
- this.outdatedInfo = info
293
- if (changed) for (const l of this.outdatedListeners) l(info)
294
- }
295
-
296
- private async tryOpen(): Promise<Connection | undefined> {
297
- try {
298
- return await Connection.open(
299
- this.paths.socket,
300
- (event) => this.dispatch(event),
301
- () => this.handleDisconnect(),
302
- )
303
- } catch {
304
- return undefined
305
- }
306
- }
307
-
308
- private async waitForSocket(timeoutMs: number): Promise<Connection> {
309
- const deadline = Date.now() + timeoutMs
310
- let delay = 25
311
- while (Date.now() < deadline) {
312
- const conn = await this.tryOpen()
313
- if (conn) return conn
314
- await Bun.sleep(delay)
315
- delay = Math.min(delay * 2, 250)
316
- }
317
- throw new RpcError(
318
- ErrorCode.ShuttingDown,
319
- `cockpitd did not start within ${timeoutMs}ms; see ${this.paths.logFile}`,
320
- )
321
- }
322
-
323
- private handleDisconnect(): void {
324
- this.connection = undefined
325
- for (const l of this.stateListeners) l("disconnected")
326
- }
327
-
328
- private dispatch(event: EventEnvelope): void {
329
- for (const listener of this.listeners.get(event.topic) ?? []) {
330
- try {
331
- listener(event.data, event.topic)
332
- } catch {
333
- // a listener's failure must not break delivery to others
334
- }
335
- }
336
- }
337
- }
338
-
339
- type ParamsArg<M extends MethodName> =
340
- undefined extends ParamsOf<Methods, M> ? [params?: ParamsOf<Methods, M>] : [params: ParamsOf<Methods, M>]
package/src/connection.ts DELETED
@@ -1,132 +0,0 @@
1
- import {
2
- ErrorCode,
3
- EVENT_METHOD,
4
- type EventEnvelope,
5
- encodeFrame,
6
- LineDecoder,
7
- type RequestId,
8
- RpcError,
9
- type RpcMessage,
10
- } from "@opencode-cockpit/protocol"
11
- import type { Socket } from "bun"
12
-
13
- interface Pending {
14
- resolve(value: unknown): void
15
- reject(err: unknown): void
16
- }
17
-
18
- /** One socket to the daemon: request/response correlation, events, write backpressure. */
19
- export class Connection {
20
- private readonly pending = new Map<RequestId, Pending>()
21
- private readonly decoder = new LineDecoder()
22
- private queue: Uint8Array[] = []
23
- private nextId = 1
24
- private closedFlag = false
25
-
26
- private constructor(
27
- private socket: Socket<undefined>,
28
- private readonly onEvent: (event: EventEnvelope) => void,
29
- private readonly onClose: () => void,
30
- ) {}
31
-
32
- static open(path: string, onEvent: (e: EventEnvelope) => void, onClose: () => void): Promise<Connection> {
33
- return new Promise((resolve, reject) => {
34
- let conn: Connection | undefined
35
- Bun.connect<undefined>({
36
- unix: path,
37
- socket: {
38
- open(socket) {
39
- conn = new Connection(socket, onEvent, onClose)
40
- resolve(conn)
41
- },
42
- data(_socket, chunk) {
43
- conn?.receive(chunk)
44
- },
45
- drain() {
46
- conn?.drain()
47
- },
48
- close() {
49
- conn?.handleClose()
50
- },
51
- error(_socket, err) {
52
- if (conn) conn.handleClose()
53
- else reject(err)
54
- },
55
- connectError(_socket, err) {
56
- reject(err)
57
- },
58
- },
59
- }).catch(reject)
60
- })
61
- }
62
-
63
- get closed(): boolean {
64
- return this.closedFlag
65
- }
66
-
67
- request(method: string, params: unknown): Promise<unknown> {
68
- if (this.closedFlag) return Promise.reject(new RpcError(ErrorCode.ShuttingDown, "connection closed"))
69
- const id = this.nextId++
70
- return new Promise((resolve, reject) => {
71
- this.pending.set(id, { resolve, reject })
72
- this.write({ jsonrpc: "2.0", id, method, params })
73
- })
74
- }
75
-
76
- close(): void {
77
- this.socket.end()
78
- this.handleClose()
79
- }
80
-
81
- private write(message: unknown): void {
82
- const frame = encodeFrame(message)
83
- if (this.queue.length > 0) {
84
- this.queue.push(frame)
85
- return
86
- }
87
- const written = this.socket.write(frame)
88
- if (written < frame.byteLength) this.queue.push(frame.subarray(Math.max(0, written)))
89
- }
90
-
91
- private drain(): void {
92
- while (this.queue.length > 0) {
93
- const head = this.queue[0] as Uint8Array
94
- const written = this.socket.write(head)
95
- if (written < head.byteLength) {
96
- this.queue[0] = head.subarray(Math.max(0, written))
97
- return
98
- }
99
- this.queue.shift()
100
- }
101
- }
102
-
103
- private receive(chunk: Uint8Array): void {
104
- for (const line of this.decoder.push(chunk)) {
105
- let message: RpcMessage
106
- try {
107
- message = JSON.parse(line)
108
- } catch {
109
- continue
110
- }
111
- if ("method" in message) {
112
- if (message.method === EVENT_METHOD) this.onEvent(message.params as EventEnvelope)
113
- continue
114
- }
115
- if (message.id === null) continue
116
- const pending = this.pending.get(message.id)
117
- if (!pending) continue
118
- this.pending.delete(message.id)
119
- if ("error" in message) pending.reject(RpcError.from(message.error))
120
- else pending.resolve(message.result)
121
- }
122
- }
123
-
124
- private handleClose(): void {
125
- if (this.closedFlag) return
126
- this.closedFlag = true
127
- const error = new RpcError(ErrorCode.ShuttingDown, "connection to cockpitd closed")
128
- for (const pending of this.pending.values()) pending.reject(error)
129
- this.pending.clear()
130
- this.onClose()
131
- }
132
- }
package/src/feature.ts DELETED
@@ -1,55 +0,0 @@
1
- /**
2
- * Guards against loading the same cockpit feature twice, e.g. when both `opencode-cockpit` and
3
- * `@opencode-cockpit/shell` are configured. OpenCode does not deduplicate plugin tools, and
4
- * duplicate tool names make model requests fail, so the first copy loaded wins and later copies
5
- * stay inactive.
6
- *
7
- * Claims are scoped to an object that all plugins of one OpenCode instance share: the plugin
8
- * input on the server side, the renderer in the TUI. Separate instances claim independently.
9
- */
10
-
11
- const REGISTRY = Symbol.for("opencode-cockpit.features")
12
-
13
- type Registry = WeakMap<object, Map<string, string>>
14
-
15
- function registry(): Registry {
16
- const host = globalThis as { [REGISTRY]?: Registry }
17
- host[REGISTRY] ??= new WeakMap()
18
- return host[REGISTRY]
19
- }
20
-
21
- export interface FeatureClaim {
22
- /** True when this copy owns the feature and should register itself. */
23
- active: boolean
24
- /** Source label of the copy that owns the feature. */
25
- owner: string
26
- /** Give up ownership, so a reloaded plugin can claim again. No-op for inactive claims. */
27
- release(): void
28
- }
29
-
30
- export function claimFeature(scope: object, feature: string, source: string): FeatureClaim {
31
- const reg = registry()
32
- let claims = reg.get(scope)
33
- if (!claims) {
34
- claims = new Map()
35
- reg.set(scope, claims)
36
- }
37
- const owner = claims.get(feature)
38
- if (owner !== undefined) return { active: false, owner, release() {} }
39
-
40
- claims.set(feature, source)
41
- let released = false
42
- return {
43
- active: true,
44
- owner: source,
45
- release() {
46
- if (released) return
47
- released = true
48
- if (claims.get(feature) === source) claims.delete(feature)
49
- },
50
- }
51
- }
52
-
53
- export function duplicateFeatureMessage(feature: string, owner: string, skipped: string): string {
54
- return `${feature} is configured twice (${owner} and ${skipped}). Using ${owner}; remove one of them from your OpenCode config.`
55
- }
package/src/index.ts DELETED
@@ -1,9 +0,0 @@
1
- export {
2
- type ClientOptions,
3
- CockpitClient,
4
- type ConnectionState,
5
- compareBuilds,
6
- type OutdatedDaemon,
7
- } from "./client.ts"
8
- export { claimFeature, duplicateFeatureMessage, type FeatureClaim } from "./feature.ts"
9
- export type { SpawnOptions } from "./spawn.ts"
package/src/spawn.ts DELETED
@@ -1,66 +0,0 @@
1
- import { spawn } from "node:child_process"
2
- import { closeSync, mkdirSync, openSync, rmSync, statSync, writeSync } from "node:fs"
3
- import type { CockpitPaths } from "@opencode-cockpit/protocol"
4
-
5
- export interface SpawnOptions {
6
- /** Path to the daemon entry script (`@opencode-cockpit/daemon/main`). */
7
- entry: string
8
- /** Runtime used to run it. Inside OpenCode this is the OpenCode binary, run with BUN_BE_BUN=1. */
9
- execPath?: string
10
- env?: Record<string, string | undefined>
11
- }
12
-
13
- const LOCK_STALE_MS = 15_000
14
-
15
- /**
16
- * Takes an exclusive spawn lock so concurrent first calls start one daemon. Returns false when
17
- * another process holds a fresh lock (it is spawning; the caller should just wait and connect).
18
- */
19
- export function spawnDaemon(paths: CockpitPaths, options: SpawnOptions): boolean {
20
- mkdirSync(paths.home, { recursive: true, mode: 0o700 })
21
- if (!acquireLock(paths.lockFile)) return false
22
- try {
23
- const child = spawn(options.execPath ?? process.execPath, [options.entry], {
24
- detached: true,
25
- stdio: "ignore",
26
- env: { ...process.env, ...options.env, BUN_BE_BUN: "1", COCKPIT_HOME: paths.home },
27
- })
28
- child.unref()
29
- } catch (err) {
30
- releaseLock(paths.lockFile)
31
- throw err
32
- }
33
- // The lock is released after the caller connects or times out; see releaseSpawnLock.
34
- return true
35
- }
36
-
37
- export function releaseSpawnLock(paths: CockpitPaths): void {
38
- releaseLock(paths.lockFile)
39
- }
40
-
41
- function acquireLock(file: string): boolean {
42
- for (let attempt = 0; attempt < 2; attempt++) {
43
- try {
44
- const fd = openSync(file, "wx", 0o600)
45
- writeSync(fd, String(process.pid))
46
- closeSync(fd)
47
- return true
48
- } catch (err) {
49
- if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err
50
- try {
51
- if (Date.now() - statSync(file).mtimeMs > LOCK_STALE_MS) {
52
- rmSync(file, { force: true })
53
- continue
54
- }
55
- } catch {
56
- continue // vanished between calls; retry
57
- }
58
- return false
59
- }
60
- }
61
- return false
62
- }
63
-
64
- function releaseLock(file: string): void {
65
- rmSync(file, { force: true })
66
- }