@opencode-cockpit/client 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Codestz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # @opencode-cockpit/client
2
+
3
+ Typed client for cockpitd. Starts the daemon when needed (one per machine, even under concurrent first calls), reconnects and restores subscriptions, replays read-only calls after a lost connection, and replaces a daemon running outdated code when it is idle.
4
+
5
+ Part of [opencode-cockpit](https://github.com/Codestz/opencode-cockpit). Install the plugin, not this package, unless you are building your own front end:
6
+
7
+ ```sh
8
+ opencode plugin opencode-cockpit --global
9
+ ```
10
+
11
+ Requires Bun ≥ 1.3.5 (OpenCode's embedded runtime qualifies). License: MIT.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@opencode-cockpit/client",
3
+ "version": "0.1.0",
4
+ "description": "Auto-spawning, reconnecting, typed client for cockpitd",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Codestz",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Codestz/opencode-cockpit.git",
11
+ "directory": "packages/client"
12
+ },
13
+ "homepage": "https://github.com/Codestz/opencode-cockpit#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/Codestz/opencode-cockpit/issues"
16
+ },
17
+ "keywords": [
18
+ "opencode",
19
+ "client",
20
+ "json-rpc"
21
+ ],
22
+ "exports": {
23
+ ".": "./src/index.ts"
24
+ },
25
+ "files": [
26
+ "src",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dependencies": {
34
+ "@opencode-cockpit/protocol": "0.0.1"
35
+ },
36
+ "devDependencies": {
37
+ "@opencode-cockpit/daemon": "0.0.1"
38
+ },
39
+ "engines": {
40
+ "bun": ">=1.3.5"
41
+ }
42
+ }
package/src/client.ts ADDED
@@ -0,0 +1,306 @@
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
+ const IDEMPOTENT = new Set<string>([
43
+ "daemon.hello",
44
+ "daemon.status",
45
+ "shell.list",
46
+ "shell.get",
47
+ "shell.read",
48
+ "shell.screen",
49
+ "shell.wait",
50
+ ])
51
+
52
+ type Listener = (data: unknown, topic: string) => void
53
+ export type ConnectionState = "connected" | "disconnected"
54
+
55
+ /**
56
+ * Typed, reconnecting client for cockpitd. Calls transparently (re)connect and, when allowed,
57
+ * start the daemon. Subscriptions survive reconnects.
58
+ */
59
+ export class CockpitClient {
60
+ readonly paths: CockpitPaths
61
+ private connection: Connection | undefined
62
+ private connecting: Promise<Connection> | undefined
63
+ private readonly listeners = new Map<string, Set<Listener>>()
64
+ private readonly stateListeners = new Set<(state: ConnectionState) => void>()
65
+ private hello: HelloResult | undefined
66
+ private closed = false
67
+ private outdatedInfo: OutdatedDaemon | undefined
68
+ private readonly outdatedListeners = new Set<(info: OutdatedDaemon | undefined) => void>()
69
+
70
+ constructor(private readonly options: ClientOptions) {
71
+ this.paths = options.paths ?? resolvePaths()
72
+ }
73
+
74
+ get daemon(): HelloResult | undefined {
75
+ return this.hello
76
+ }
77
+
78
+ get connected(): boolean {
79
+ return this.connection !== undefined && !this.connection.closed
80
+ }
81
+
82
+ async call<M extends MethodName>(method: M, ...args: ParamsArg<M>): Promise<ResultOf<Methods, M>> {
83
+ const conn = await this.ensure()
84
+ try {
85
+ return (await conn.request(method, args[0])) as ResultOf<Methods, M>
86
+ } catch (err) {
87
+ // The daemon went away under us. Methods without side effects are safe to replay once.
88
+ const lost =
89
+ err instanceof RpcError && err.code === ErrorCode.ShuttingDown && conn.closed && !this.closed
90
+ if (!lost || !IDEMPOTENT.has(method)) throw err
91
+ const next = await this.ensure()
92
+ return (await next.request(method, args[0])) as ResultOf<Methods, M>
93
+ }
94
+ }
95
+
96
+ /** Listen to a topic. Returns an unsubscribe function. */
97
+ on<T extends Topic>(topic: T, listener: (data: EventOf<Events, T>) => void): () => void {
98
+ let set = this.listeners.get(topic)
99
+ const isNew = !set
100
+ if (!set) {
101
+ set = new Set()
102
+ this.listeners.set(topic, set)
103
+ }
104
+ set.add(listener as Listener)
105
+ if (isNew && this.connected)
106
+ void this.connection?.request("events.subscribe", { topics: [topic] }).catch(() => {})
107
+ else if (isNew) void this.ensure().catch(() => {})
108
+ return () => {
109
+ set.delete(listener as Listener)
110
+ if (set.size === 0) {
111
+ this.listeners.delete(topic)
112
+ if (this.connected)
113
+ void this.connection?.request("events.unsubscribe", { topics: [topic] }).catch(() => {})
114
+ }
115
+ }
116
+ }
117
+
118
+ /** Set while connected to a daemon running different code than `expectedBuild`. */
119
+ get outdated(): OutdatedDaemon | undefined {
120
+ return this.outdatedInfo
121
+ }
122
+
123
+ onOutdated(listener: (info: OutdatedDaemon | undefined) => void): () => void {
124
+ this.outdatedListeners.add(listener)
125
+ return () => this.outdatedListeners.delete(listener)
126
+ }
127
+
128
+ /**
129
+ * Stop the daemon and start a fresh one from this client's code. Without `force` it refuses
130
+ * while shells are running. Returns false when refused.
131
+ */
132
+ async restartDaemon(options: { force?: boolean } = {}): Promise<boolean> {
133
+ const conn = await this.ensure()
134
+ const { accepted } = (await conn.request("daemon.shutdown", { force: options.force === true })) as {
135
+ accepted: boolean
136
+ }
137
+ if (!accepted) return false
138
+ await this.waitForSocketGone()
139
+ await this.ensure()
140
+ return true
141
+ }
142
+
143
+ onState(listener: (state: ConnectionState) => void): () => void {
144
+ this.stateListeners.add(listener)
145
+ return () => this.stateListeners.delete(listener)
146
+ }
147
+
148
+ /** Connect now (spawning if configured). Useful to surface errors early. */
149
+ async connect(): Promise<HelloResult> {
150
+ await this.ensure()
151
+ return this.hello as HelloResult
152
+ }
153
+
154
+ close(): void {
155
+ this.closed = true
156
+ this.connection?.close()
157
+ this.connection = undefined
158
+ }
159
+
160
+ private ensure(): Promise<Connection> {
161
+ if (this.closed) return Promise.reject(new RpcError(ErrorCode.ShuttingDown, "client closed"))
162
+ if (this.connection && !this.connection.closed) return Promise.resolve(this.connection)
163
+ this.connecting ??= this.establish().finally(() => {
164
+ this.connecting = undefined
165
+ })
166
+ return this.connecting
167
+ }
168
+
169
+ private async establish(replaced = false): Promise<Connection> {
170
+ let conn = await this.tryOpen()
171
+ if (!conn) {
172
+ if (!this.options.spawn) {
173
+ throw new RpcError(ErrorCode.ShuttingDown, `cockpitd is not running (${this.paths.socket})`)
174
+ }
175
+ const spawned = spawnDaemon(this.paths, this.options.spawn)
176
+ try {
177
+ conn = await this.waitForSocket(this.options.connectTimeoutMs ?? 8000)
178
+ } finally {
179
+ if (spawned) releaseSpawnLock(this.paths)
180
+ }
181
+ }
182
+
183
+ try {
184
+ this.hello = (await conn.request("daemon.hello", {
185
+ client: this.options.client,
186
+ protocol: PROTOCOL_VERSION,
187
+ })) as HelloResult
188
+ } catch (err) {
189
+ conn.close()
190
+ if (
191
+ err instanceof RpcError &&
192
+ err.code === ErrorCode.ProtocolMismatch &&
193
+ this.options.spawn &&
194
+ !replaced
195
+ ) {
196
+ return this.replaceIncompatibleDaemon(err)
197
+ }
198
+ throw err
199
+ }
200
+
201
+ const expected = this.options.expectedBuild
202
+ if (expected && this.hello.build !== expected) {
203
+ const status = (await conn.request("daemon.status", {})) as { modules: { busy: boolean }[] }
204
+ const busy = status.modules.some((m) => m.busy)
205
+ if (!busy && this.options.spawn && !replaced) {
206
+ await conn.request("daemon.shutdown", {}).catch(() => {})
207
+ conn.close()
208
+ await this.waitForSocketGone()
209
+ return this.establish(true)
210
+ }
211
+ this.setOutdated({ running: this.hello.build, expected })
212
+ } else {
213
+ this.setOutdated(undefined)
214
+ }
215
+
216
+ this.connection = conn
217
+ const topics = [...this.listeners.keys()]
218
+ if (topics.length > 0) await conn.request("events.subscribe", { topics })
219
+ for (const l of this.stateListeners) l("connected")
220
+ return conn
221
+ }
222
+
223
+ /** An older daemon speaks another protocol. Replace it only if nothing is running in it. */
224
+ private async replaceIncompatibleDaemon(err: RpcError): Promise<Connection> {
225
+ const data = err.data as { busy?: boolean } | undefined
226
+ if (data?.busy) {
227
+ throw new RpcError(
228
+ ErrorCode.ProtocolMismatch,
229
+ "cockpitd is running an incompatible version and has running shells; stop them or restart the daemon",
230
+ err.data,
231
+ )
232
+ }
233
+ const pid = await Bun.file(this.paths.pidFile)
234
+ .text()
235
+ .then((t) => Number.parseInt(t, 10))
236
+ .catch(() => Number.NaN)
237
+ if (Number.isFinite(pid)) {
238
+ try {
239
+ process.kill(pid, "SIGTERM")
240
+ } catch {
241
+ // already gone
242
+ }
243
+ }
244
+ await this.waitForSocketGone()
245
+ return this.establish(true)
246
+ }
247
+
248
+ private async waitForSocketGone(timeoutMs = 5000): Promise<void> {
249
+ const deadline = Date.now() + timeoutMs
250
+ // Bun.file().exists() reports false for unix sockets; use a stat-based check.
251
+ while (Date.now() < deadline && existsSync(this.paths.socket)) await Bun.sleep(50)
252
+ }
253
+
254
+ private setOutdated(info: OutdatedDaemon | undefined): void {
255
+ const changed =
256
+ info?.running !== this.outdatedInfo?.running ||
257
+ (info === undefined) !== (this.outdatedInfo === undefined)
258
+ this.outdatedInfo = info
259
+ if (changed) for (const l of this.outdatedListeners) l(info)
260
+ }
261
+
262
+ private async tryOpen(): Promise<Connection | undefined> {
263
+ try {
264
+ return await Connection.open(
265
+ this.paths.socket,
266
+ (event) => this.dispatch(event),
267
+ () => this.handleDisconnect(),
268
+ )
269
+ } catch {
270
+ return undefined
271
+ }
272
+ }
273
+
274
+ private async waitForSocket(timeoutMs: number): Promise<Connection> {
275
+ const deadline = Date.now() + timeoutMs
276
+ let delay = 25
277
+ while (Date.now() < deadline) {
278
+ const conn = await this.tryOpen()
279
+ if (conn) return conn
280
+ await Bun.sleep(delay)
281
+ delay = Math.min(delay * 2, 250)
282
+ }
283
+ throw new RpcError(
284
+ ErrorCode.ShuttingDown,
285
+ `cockpitd did not start within ${timeoutMs}ms; see ${this.paths.logFile}`,
286
+ )
287
+ }
288
+
289
+ private handleDisconnect(): void {
290
+ this.connection = undefined
291
+ for (const l of this.stateListeners) l("disconnected")
292
+ }
293
+
294
+ private dispatch(event: EventEnvelope): void {
295
+ for (const listener of this.listeners.get(event.topic) ?? []) {
296
+ try {
297
+ listener(event.data, event.topic)
298
+ } catch {
299
+ // a listener's failure must not break delivery to others
300
+ }
301
+ }
302
+ }
303
+ }
304
+
305
+ type ParamsArg<M extends MethodName> =
306
+ undefined extends ParamsOf<Methods, M> ? [params?: ParamsOf<Methods, M>] : [params: ParamsOf<Methods, M>]
@@ -0,0 +1,132 @@
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/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { type ClientOptions, CockpitClient, type ConnectionState, type OutdatedDaemon } from "./client.ts"
2
+ export type { SpawnOptions } from "./spawn.ts"
package/src/spawn.ts ADDED
@@ -0,0 +1,66 @@
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
+ }