@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 +21 -0
- package/README.md +11 -0
- package/package.json +42 -0
- package/src/client.ts +306 -0
- package/src/connection.ts +132 -0
- package/src/index.ts +2 -0
- package/src/spawn.ts +66 -0
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
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
|
+
}
|