@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 +253 -0
- package/dist/connection.js +113 -0
- package/dist/feature.js +44 -0
- package/dist/index.js +2 -0
- package/dist/spawn.js +65 -0
- package/package.json +9 -5
- package/types/client.d.ts +75 -0
- package/types/connection.d.ts +21 -0
- package/types/feature.d.ts +19 -0
- package/types/index.d.ts +3 -0
- package/types/spawn.d.ts +14 -0
- package/src/client.ts +0 -340
- package/src/connection.ts +0 -132
- package/src/feature.ts +0 -55
- package/src/index.ts +0 -9
- package/src/spawn.ts +0 -66
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
|
+
}
|
package/dist/feature.js
ADDED
|
@@ -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
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.
|
|
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
|
-
".":
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./types/index.d.ts",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
}
|
|
24
27
|
},
|
|
25
28
|
"files": [
|
|
26
|
-
"
|
|
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.
|
|
38
|
+
"@opencode-cockpit/protocol": "0.1.5"
|
|
35
39
|
},
|
|
36
40
|
"devDependencies": {
|
|
37
|
-
"@opencode-cockpit/daemon": "0.1.
|
|
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;
|
package/types/index.d.ts
ADDED
package/types/spawn.d.ts
ADDED
|
@@ -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
|
-
}
|