@opencode-cockpit/daemon 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 +43 -0
- package/src/core/daemon.ts +197 -0
- package/src/core/errors.ts +6 -0
- package/src/core/logger.ts +44 -0
- package/src/core/module.ts +45 -0
- package/src/core/router.ts +40 -0
- package/src/core/server.ts +223 -0
- package/src/index.ts +5 -0
- package/src/main.ts +36 -0
- package/src/modules/index.ts +11 -0
- package/src/modules/shell/ids.ts +8 -0
- package/src/modules/shell/module.ts +323 -0
- package/src/modules/shell/output/line-log.ts +92 -0
- package/src/modules/shell/output/normalizer.ts +172 -0
- package/src/modules/shell/output/raw-ring.ts +46 -0
- package/src/modules/shell/output/screen.ts +44 -0
- package/src/modules/shell/port-probe.ts +30 -0
- package/src/modules/shell/pty.ts +98 -0
- package/src/modules/shell/registry.ts +86 -0
- package/src/modules/shell/shell.ts +247 -0
- package/src/modules/shell/wait.ts +86 -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/daemon
|
|
2
|
+
|
|
3
|
+
`cockpitd`, the long-lived process host. Owns PTY shells (each its own process group), normalizes output into clean logs, emulates the screen, implements wait conditions, reaps orphans after crashes and shuts down when idle. Capabilities are modules with a namespace, a method table and a lifecycle.
|
|
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,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opencode-cockpit/daemon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "cockpitd: the process host behind opencode-cockpit (PTY shells, clean logs, wait conditions)",
|
|
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/daemon"
|
|
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
|
+
"pty",
|
|
20
|
+
"daemon",
|
|
21
|
+
"terminal"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./src/index.ts",
|
|
25
|
+
"./main": "./src/main.ts",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"src",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@opencode-cockpit/protocol": "0.0.1",
|
|
38
|
+
"@xterm/headless": "6.0.0"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"bun": ">=1.3.5"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import {
|
|
3
|
+
type CockpitPaths,
|
|
4
|
+
daemonBuildId,
|
|
5
|
+
ErrorCode,
|
|
6
|
+
PROTOCOL_VERSION,
|
|
7
|
+
RpcError,
|
|
8
|
+
} from "@opencode-cockpit/protocol"
|
|
9
|
+
import pkg from "../../package.json" with { type: "json" }
|
|
10
|
+
import { createLogger, type Level, type Logger } from "./logger.ts"
|
|
11
|
+
import type { Module } from "./module.ts"
|
|
12
|
+
import { Router } from "./router.ts"
|
|
13
|
+
import { RpcServer } from "./server.ts"
|
|
14
|
+
|
|
15
|
+
export interface DaemonOptions {
|
|
16
|
+
paths: CockpitPaths
|
|
17
|
+
modules: Module[]
|
|
18
|
+
/** Shut down after this long with no clients and no busy module. 0 disables. */
|
|
19
|
+
idleTimeoutMs?: number
|
|
20
|
+
logLevel?: Level
|
|
21
|
+
/** Log to the log file (default) or stderr. */
|
|
22
|
+
logToFile?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const DAEMON_VERSION: string = pkg.version
|
|
26
|
+
|
|
27
|
+
/** Build id of this daemon's own code; computed once, matches what clients compute for the entry. */
|
|
28
|
+
export const DAEMON_BUILD: string = daemonBuildId(
|
|
29
|
+
Bun.fileURLToPath(new URL("../main.ts", import.meta.url)),
|
|
30
|
+
DAEMON_VERSION,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
export class Daemon {
|
|
34
|
+
readonly log: Logger
|
|
35
|
+
private readonly router = new Router()
|
|
36
|
+
private readonly server: RpcServer
|
|
37
|
+
private readonly startedAt = Date.now()
|
|
38
|
+
private idleTimer: ReturnType<typeof setTimeout> | undefined
|
|
39
|
+
private idleCheck: ReturnType<typeof setInterval> | undefined
|
|
40
|
+
private stopping: Promise<void> | undefined
|
|
41
|
+
private resolveStopped!: () => void
|
|
42
|
+
/** Resolves once the daemon has fully shut down. */
|
|
43
|
+
readonly stopped = new Promise<void>((resolve) => {
|
|
44
|
+
this.resolveStopped = resolve
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
constructor(private readonly options: DaemonOptions) {
|
|
48
|
+
this.log = createLogger(options.logToFile === false ? undefined : options.paths.logFile, options.logLevel)
|
|
49
|
+
this.server = new RpcServer(
|
|
50
|
+
this.router,
|
|
51
|
+
{
|
|
52
|
+
onConnect: () => this.refreshIdle(),
|
|
53
|
+
onDisconnect: () => this.refreshIdle(),
|
|
54
|
+
},
|
|
55
|
+
this.log.child("rpc"),
|
|
56
|
+
)
|
|
57
|
+
this.registerCore()
|
|
58
|
+
for (const module of options.modules) this.router.addModule(module)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async start(): Promise<void> {
|
|
62
|
+
const { paths } = this.options
|
|
63
|
+
mkdirSync(paths.home, { recursive: true, mode: 0o700 })
|
|
64
|
+
chmodSync(paths.home, 0o700)
|
|
65
|
+
await this.claimSocket(paths.socket)
|
|
66
|
+
|
|
67
|
+
for (const module of this.options.modules) {
|
|
68
|
+
await module.start({
|
|
69
|
+
log: this.log.child(module.name),
|
|
70
|
+
emit: (topic, data) => {
|
|
71
|
+
this.server.broadcast(topic, data)
|
|
72
|
+
// Module state changes (a shell exiting) can make the daemon idle.
|
|
73
|
+
this.refreshIdle()
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
this.server.listen(paths.socket)
|
|
78
|
+
chmodSync(paths.socket, 0o600)
|
|
79
|
+
writeFileSync(paths.pidFile, String(process.pid), { mode: 0o600 })
|
|
80
|
+
this.idleCheck = setInterval(() => this.refreshIdle(), 30_000)
|
|
81
|
+
this.refreshIdle()
|
|
82
|
+
this.log.info("daemon started", { pid: process.pid, build: DAEMON_BUILD, socket: paths.socket })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
stop(reason = "requested"): Promise<void> {
|
|
86
|
+
this.stopping ??= (async () => {
|
|
87
|
+
this.log.info("daemon stopping", { reason })
|
|
88
|
+
clearTimeout(this.idleTimer)
|
|
89
|
+
clearInterval(this.idleCheck)
|
|
90
|
+
this.server.stop()
|
|
91
|
+
for (const module of [...this.options.modules].reverse()) {
|
|
92
|
+
try {
|
|
93
|
+
await module.stop()
|
|
94
|
+
} catch (err) {
|
|
95
|
+
this.log.error("module stop failed", { module: module.name, err: String(err) })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const { paths } = this.options
|
|
99
|
+
if (readPid(paths.pidFile) === process.pid) rmSync(paths.pidFile, { force: true })
|
|
100
|
+
rmSync(paths.socket, { force: true })
|
|
101
|
+
this.log.info("daemon stopped")
|
|
102
|
+
this.resolveStopped()
|
|
103
|
+
})()
|
|
104
|
+
return this.stopping
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private busy(): boolean {
|
|
108
|
+
return this.options.modules.some((m) => m.busy())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private refreshIdle(): void {
|
|
112
|
+
const timeout = this.options.idleTimeoutMs ?? 0
|
|
113
|
+
if (timeout <= 0 || this.stopping) return
|
|
114
|
+
const idle = this.server.clientCount === 0 && !this.busy()
|
|
115
|
+
if (!idle) {
|
|
116
|
+
clearTimeout(this.idleTimer)
|
|
117
|
+
this.idleTimer = undefined
|
|
118
|
+
} else if (!this.idleTimer) {
|
|
119
|
+
this.idleTimer = setTimeout(() => {
|
|
120
|
+
if (this.server.clientCount === 0 && !this.busy()) void this.stop("idle")
|
|
121
|
+
else this.idleTimer = undefined
|
|
122
|
+
}, timeout)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Refuse to start if a live daemon owns the socket; otherwise clear a stale one. */
|
|
127
|
+
private async claimSocket(socket: string): Promise<void> {
|
|
128
|
+
if (!existsSync(socket)) return
|
|
129
|
+
const alive = await new Promise<boolean>((resolve) => {
|
|
130
|
+
Bun.connect({
|
|
131
|
+
unix: socket,
|
|
132
|
+
socket: {
|
|
133
|
+
open(s) {
|
|
134
|
+
s.end()
|
|
135
|
+
resolve(true)
|
|
136
|
+
},
|
|
137
|
+
data() {},
|
|
138
|
+
connectError: () => resolve(false),
|
|
139
|
+
error: () => resolve(false),
|
|
140
|
+
},
|
|
141
|
+
}).catch(() => resolve(false))
|
|
142
|
+
})
|
|
143
|
+
if (alive) throw new Error(`another daemon is listening on ${socket}`)
|
|
144
|
+
rmSync(socket, { force: true })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private registerCore(): void {
|
|
148
|
+
this.router.add("daemon.hello", (raw, { peer }) => {
|
|
149
|
+
const params = raw as { client: { name: string }; protocol: { major: number } }
|
|
150
|
+
if (params.protocol.major !== PROTOCOL_VERSION.major) {
|
|
151
|
+
throw new RpcError(ErrorCode.ProtocolMismatch, "protocol major version mismatch", {
|
|
152
|
+
daemon: PROTOCOL_VERSION,
|
|
153
|
+
client: params.protocol,
|
|
154
|
+
busy: this.busy(),
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
peer.greet(params.client.name)
|
|
158
|
+
return {
|
|
159
|
+
daemonVersion: DAEMON_VERSION,
|
|
160
|
+
build: DAEMON_BUILD,
|
|
161
|
+
protocol: PROTOCOL_VERSION,
|
|
162
|
+
modules: this.options.modules.map((m) => m.name),
|
|
163
|
+
pid: process.pid,
|
|
164
|
+
startedAt: this.startedAt,
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
this.router.add("daemon.status", () => ({
|
|
168
|
+
pid: process.pid,
|
|
169
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
170
|
+
clients: this.server.clientCount,
|
|
171
|
+
modules: this.options.modules.map((m) => ({ name: m.name, busy: m.busy() })),
|
|
172
|
+
}))
|
|
173
|
+
this.router.add("daemon.shutdown", (raw) => {
|
|
174
|
+
const force = (raw as { force?: boolean } | undefined)?.force === true
|
|
175
|
+
if (this.busy() && !force) return { accepted: false }
|
|
176
|
+
setTimeout(() => void this.stop(force ? "forced shutdown" : "shutdown"), 10)
|
|
177
|
+
return { accepted: true }
|
|
178
|
+
})
|
|
179
|
+
const topics =
|
|
180
|
+
(on: boolean) =>
|
|
181
|
+
(raw: unknown, { peer }: { peer: { topics: Set<string> } }) => {
|
|
182
|
+
const list = (raw as { topics: string[] }).topics
|
|
183
|
+
for (const t of list) on ? peer.topics.add(t) : peer.topics.delete(t)
|
|
184
|
+
return { topics: [...peer.topics] }
|
|
185
|
+
}
|
|
186
|
+
this.router.add("events.subscribe", topics(true))
|
|
187
|
+
this.router.add("events.unsubscribe", topics(false))
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function readPid(file: string): number | undefined {
|
|
192
|
+
try {
|
|
193
|
+
return Number.parseInt(readFileSync(file, "utf8"), 10)
|
|
194
|
+
} catch {
|
|
195
|
+
return undefined
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { ErrorCode, RpcError } from "@opencode-cockpit/protocol"
|
|
2
|
+
|
|
3
|
+
export const notFound = (what: string) => new RpcError(ErrorCode.NotFound, `${what} not found`)
|
|
4
|
+
export const invalidState = (message: string) => new RpcError(ErrorCode.InvalidState, message)
|
|
5
|
+
export const invalidParams = (message: string, data?: unknown) =>
|
|
6
|
+
new RpcError(ErrorCode.InvalidParams, message, data)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs"
|
|
2
|
+
|
|
3
|
+
export type Level = "debug" | "info" | "warn" | "error"
|
|
4
|
+
const order: Record<Level, number> = { debug: 10, info: 20, warn: 30, error: 40 }
|
|
5
|
+
|
|
6
|
+
export interface Logger {
|
|
7
|
+
debug(msg: string, fields?: Record<string, unknown>): void
|
|
8
|
+
info(msg: string, fields?: Record<string, unknown>): void
|
|
9
|
+
warn(msg: string, fields?: Record<string, unknown>): void
|
|
10
|
+
error(msg: string, fields?: Record<string, unknown>): void
|
|
11
|
+
child(scope: string): Logger
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** JSON-lines logger. Writes synchronously so the last lines survive a crash. */
|
|
15
|
+
export function createLogger(file: string | undefined, level: Level = "info", scope = "cockpitd"): Logger {
|
|
16
|
+
const write = (lvl: Level, msg: string, fields?: Record<string, unknown>) => {
|
|
17
|
+
if (order[lvl] < order[level]) return
|
|
18
|
+
const line = `${JSON.stringify({ t: new Date().toISOString(), lvl, scope, msg, ...fields })}\n`
|
|
19
|
+
if (file) {
|
|
20
|
+
try {
|
|
21
|
+
appendFileSync(file, line, { mode: 0o600 })
|
|
22
|
+
} catch {
|
|
23
|
+
process.stderr.write(line)
|
|
24
|
+
}
|
|
25
|
+
} else if (lvl !== "debug") {
|
|
26
|
+
process.stderr.write(line)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
debug: (m, f) => write("debug", m, f),
|
|
31
|
+
info: (m, f) => write("info", m, f),
|
|
32
|
+
warn: (m, f) => write("warn", m, f),
|
|
33
|
+
error: (m, f) => write("error", m, f),
|
|
34
|
+
child: (s) => createLogger(file, level, `${scope}:${s}`),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const silentLogger: Logger = {
|
|
39
|
+
debug() {},
|
|
40
|
+
info() {},
|
|
41
|
+
warn() {},
|
|
42
|
+
error() {},
|
|
43
|
+
child: () => silentLogger,
|
|
44
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { MethodName, Methods, ParsedParamsOf, ResultOf } from "@opencode-cockpit/protocol"
|
|
2
|
+
import type { Logger } from "./logger.ts"
|
|
3
|
+
|
|
4
|
+
/** A connected client as seen by modules. */
|
|
5
|
+
export interface Peer {
|
|
6
|
+
readonly id: number
|
|
7
|
+
readonly name: string
|
|
8
|
+
/** Send an event to this peer only, regardless of its subscriptions. */
|
|
9
|
+
send(topic: string, data: unknown): void
|
|
10
|
+
/** Run when the peer disconnects. */
|
|
11
|
+
onClose(fn: () => void): void
|
|
12
|
+
/** Topic patterns: exact (`shell.exited`), namespace (`shell.*`) or everything (`*`). */
|
|
13
|
+
readonly topics: Set<string>
|
|
14
|
+
greet(name: string): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CallContext {
|
|
18
|
+
peer: Peer
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ModuleContext {
|
|
22
|
+
log: Logger
|
|
23
|
+
/** Broadcast to every peer subscribed to `topic`. */
|
|
24
|
+
emit(topic: string, data: unknown): void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Handler<M extends MethodName> = (
|
|
28
|
+
params: ParsedParamsOf<Methods, M>,
|
|
29
|
+
call: CallContext,
|
|
30
|
+
) => Promise<ResultOf<Methods, M>> | ResultOf<Methods, M>
|
|
31
|
+
|
|
32
|
+
/** Handlers for the methods under one namespace, typed from the protocol contract. */
|
|
33
|
+
export type MethodTable<NS extends string> = {
|
|
34
|
+
[M in MethodName as M extends `${NS}.${infer Rest}` ? Rest : never]: Handler<M>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Module<NS extends string = string> {
|
|
38
|
+
readonly name: NS
|
|
39
|
+
/** Typed per namespace; erased to a plain record when modules are handled generically. */
|
|
40
|
+
readonly methods: string extends NS ? object : MethodTable<NS>
|
|
41
|
+
start(ctx: ModuleContext): Promise<void>
|
|
42
|
+
stop(): Promise<void>
|
|
43
|
+
/** While true the daemon will not shut down for idleness. */
|
|
44
|
+
busy(): boolean
|
|
45
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { contract, ErrorCode, RpcError } from "@opencode-cockpit/protocol"
|
|
2
|
+
import type { CallContext, Module } from "./module.ts"
|
|
3
|
+
|
|
4
|
+
type AnyHandler = (params: unknown, call: CallContext) => unknown
|
|
5
|
+
|
|
6
|
+
/** Validates params against the protocol contract and dispatches to module handlers. */
|
|
7
|
+
export class Router {
|
|
8
|
+
private readonly handlers = new Map<string, AnyHandler>()
|
|
9
|
+
|
|
10
|
+
add(name: string, handler: AnyHandler): void {
|
|
11
|
+
if (!(name in contract)) throw new Error(`method ${name} is not declared in the protocol contract`)
|
|
12
|
+
if (this.handlers.has(name)) throw new Error(`method ${name} registered twice`)
|
|
13
|
+
this.handlers.set(name, handler)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
addModule(module: Module): void {
|
|
17
|
+
for (const [short, handler] of Object.entries(module.methods as Record<string, AnyHandler>)) {
|
|
18
|
+
this.add(`${module.name}.${short}`, handler.bind(module.methods))
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
has(name: string): boolean {
|
|
23
|
+
return this.handlers.has(name)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async dispatch(name: string, params: unknown, call: CallContext): Promise<unknown> {
|
|
27
|
+
const handler = this.handlers.get(name)
|
|
28
|
+
const spec = contract[name as keyof typeof contract]
|
|
29
|
+
if (!handler || !spec) throw new RpcError(ErrorCode.MethodNotFound, `unknown method ${name}`)
|
|
30
|
+
const parsed = spec.params.safeParse(params)
|
|
31
|
+
if (!parsed.success) {
|
|
32
|
+
throw new RpcError(
|
|
33
|
+
ErrorCode.InvalidParams,
|
|
34
|
+
`invalid params for ${name}: ${parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`,
|
|
35
|
+
{ issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })) },
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
return handler(parsed.data, call)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ErrorCode,
|
|
3
|
+
EVENT_METHOD,
|
|
4
|
+
encodeFrame,
|
|
5
|
+
LineDecoder,
|
|
6
|
+
RpcError,
|
|
7
|
+
type RpcMessage,
|
|
8
|
+
type RpcRequest,
|
|
9
|
+
} from "@opencode-cockpit/protocol"
|
|
10
|
+
import type { Socket, UnixSocketListener } from "bun"
|
|
11
|
+
import type { Logger } from "./logger.ts"
|
|
12
|
+
import type { Peer } from "./module.ts"
|
|
13
|
+
import type { Router } from "./router.ts"
|
|
14
|
+
|
|
15
|
+
const MAX_QUEUED_BYTES = 32 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
interface ConnState {
|
|
18
|
+
peer: PeerImpl
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class PeerImpl implements Peer {
|
|
22
|
+
name = "unknown"
|
|
23
|
+
greeted = false
|
|
24
|
+
readonly topics = new Set<string>()
|
|
25
|
+
private readonly closers: (() => void)[] = []
|
|
26
|
+
private queue: Uint8Array[] = []
|
|
27
|
+
private queued = 0
|
|
28
|
+
closed = false
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
readonly id: number,
|
|
32
|
+
private readonly socket: Socket<ConnState>,
|
|
33
|
+
private readonly log: Logger,
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
greet(name: string): void {
|
|
37
|
+
this.name = name
|
|
38
|
+
this.greeted = true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
onClose(fn: () => void): void {
|
|
42
|
+
if (this.closed) fn()
|
|
43
|
+
else this.closers.push(fn)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
send(topic: string, data: unknown): void {
|
|
47
|
+
this.write({ jsonrpc: "2.0", method: EVENT_METHOD, params: { topic, data } })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
subscribed(topic: string): boolean {
|
|
51
|
+
if (this.topics.has(topic) || this.topics.has("*")) return true
|
|
52
|
+
const dot = topic.indexOf(".")
|
|
53
|
+
return dot > 0 && this.topics.has(`${topic.slice(0, dot)}.*`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
write(message: unknown): void {
|
|
57
|
+
if (this.closed) return
|
|
58
|
+
const frame = encodeFrame(message)
|
|
59
|
+
if (this.queue.length > 0) {
|
|
60
|
+
this.enqueue(frame)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
const written = this.socket.write(frame)
|
|
64
|
+
if (written < frame.byteLength) this.enqueue(frame.subarray(Math.max(0, written)))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
drain(): void {
|
|
68
|
+
while (this.queue.length > 0) {
|
|
69
|
+
const head = this.queue[0] as Uint8Array
|
|
70
|
+
const written = this.socket.write(head)
|
|
71
|
+
if (written < head.byteLength) {
|
|
72
|
+
this.queue[0] = head.subarray(Math.max(0, written))
|
|
73
|
+
this.queued -= Math.max(0, written)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
this.queue.shift()
|
|
77
|
+
this.queued -= head.byteLength
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
close(): void {
|
|
82
|
+
if (this.closed) return
|
|
83
|
+
this.closed = true
|
|
84
|
+
this.queue = []
|
|
85
|
+
for (const fn of this.closers.splice(0)) {
|
|
86
|
+
try {
|
|
87
|
+
fn()
|
|
88
|
+
} catch (err) {
|
|
89
|
+
this.log.warn("peer close hook failed", { err: String(err) })
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private enqueue(frame: Uint8Array): void {
|
|
95
|
+
this.queue.push(frame)
|
|
96
|
+
this.queued += frame.byteLength
|
|
97
|
+
if (this.queued > MAX_QUEUED_BYTES) {
|
|
98
|
+
this.log.warn("peer too slow, disconnecting", { peer: this.id, queued: this.queued })
|
|
99
|
+
this.socket.end()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface RpcServerHooks {
|
|
105
|
+
onConnect(count: number): void
|
|
106
|
+
onDisconnect(count: number): void
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class RpcServer {
|
|
110
|
+
private listener: UnixSocketListener<ConnState> | undefined
|
|
111
|
+
private readonly peers = new Set<PeerImpl>()
|
|
112
|
+
private nextId = 1
|
|
113
|
+
|
|
114
|
+
constructor(
|
|
115
|
+
private readonly router: Router,
|
|
116
|
+
private readonly hooks: RpcServerHooks,
|
|
117
|
+
private readonly log: Logger,
|
|
118
|
+
) {}
|
|
119
|
+
|
|
120
|
+
get clientCount(): number {
|
|
121
|
+
return this.peers.size
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
listen(path: string): void {
|
|
125
|
+
const decoders = new WeakMap<PeerImpl, LineDecoder>()
|
|
126
|
+
this.listener = Bun.listen<ConnState>({
|
|
127
|
+
unix: path,
|
|
128
|
+
socket: {
|
|
129
|
+
open: (socket) => {
|
|
130
|
+
const peer = new PeerImpl(this.nextId++, socket, this.log)
|
|
131
|
+
socket.data = { peer }
|
|
132
|
+
decoders.set(peer, new LineDecoder())
|
|
133
|
+
this.peers.add(peer)
|
|
134
|
+
this.hooks.onConnect(this.peers.size)
|
|
135
|
+
},
|
|
136
|
+
data: (socket, chunk) => {
|
|
137
|
+
const peer = socket.data.peer
|
|
138
|
+
let lines: string[]
|
|
139
|
+
try {
|
|
140
|
+
lines = (decoders.get(peer) as LineDecoder).push(chunk)
|
|
141
|
+
} catch (err) {
|
|
142
|
+
peer.write({
|
|
143
|
+
jsonrpc: "2.0",
|
|
144
|
+
id: null,
|
|
145
|
+
error: { code: ErrorCode.ParseError, message: String(err) },
|
|
146
|
+
})
|
|
147
|
+
socket.end()
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
for (const line of lines) void this.handleLine(peer, line)
|
|
151
|
+
},
|
|
152
|
+
drain: (socket) => socket.data.peer.drain(),
|
|
153
|
+
close: (socket) => this.drop(socket.data.peer),
|
|
154
|
+
error: (socket, err) => {
|
|
155
|
+
this.log.warn("socket error", { err: String(err) })
|
|
156
|
+
this.drop(socket.data.peer)
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
broadcast(topic: string, data: unknown): void {
|
|
163
|
+
for (const peer of this.peers) if (peer.subscribed(topic)) peer.send(topic, data)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
stop(): void {
|
|
167
|
+
this.listener?.stop(true)
|
|
168
|
+
for (const peer of this.peers) peer.close()
|
|
169
|
+
this.peers.clear()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private drop(peer: PeerImpl): void {
|
|
173
|
+
if (!this.peers.delete(peer)) return
|
|
174
|
+
peer.close()
|
|
175
|
+
this.hooks.onDisconnect(this.peers.size)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async handleLine(peer: PeerImpl, line: string): Promise<void> {
|
|
179
|
+
let message: RpcMessage
|
|
180
|
+
try {
|
|
181
|
+
message = JSON.parse(line)
|
|
182
|
+
} catch {
|
|
183
|
+
peer.write({ jsonrpc: "2.0", id: null, error: { code: ErrorCode.ParseError, message: "invalid JSON" } })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
if (
|
|
187
|
+
typeof message !== "object" ||
|
|
188
|
+
message === null ||
|
|
189
|
+
!("method" in message) ||
|
|
190
|
+
typeof message.method !== "string"
|
|
191
|
+
) {
|
|
192
|
+
peer.write({
|
|
193
|
+
jsonrpc: "2.0",
|
|
194
|
+
id: null,
|
|
195
|
+
error: { code: ErrorCode.InvalidRequest, message: "expected a request" },
|
|
196
|
+
})
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
const hasId = "id" in message && (typeof message.id === "number" || typeof message.id === "string")
|
|
200
|
+
const request = message as RpcRequest
|
|
201
|
+
try {
|
|
202
|
+
const result = await this.invoke(peer, request)
|
|
203
|
+
if (hasId) peer.write({ jsonrpc: "2.0", id: request.id, result: result ?? {} })
|
|
204
|
+
} catch (err) {
|
|
205
|
+
const rpc =
|
|
206
|
+
err instanceof RpcError
|
|
207
|
+
? err
|
|
208
|
+
: new RpcError(ErrorCode.InternalError, err instanceof Error ? err.message : String(err))
|
|
209
|
+
if (!(err instanceof RpcError))
|
|
210
|
+
this.log.error("handler crashed", { method: request.method, err: String(err) })
|
|
211
|
+
if (hasId) peer.write({ jsonrpc: "2.0", id: request.id, error: rpc.toShape() })
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async invoke(peer: PeerImpl, request: RpcRequest): Promise<unknown> {
|
|
216
|
+
if (!peer.greeted && request.method !== "daemon.hello") {
|
|
217
|
+
throw new RpcError(ErrorCode.InvalidRequest, "call daemon.hello first")
|
|
218
|
+
}
|
|
219
|
+
return this.router.dispatch(request.method, request.params, { peer })
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export type { PeerImpl }
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { DAEMON_BUILD, DAEMON_VERSION, Daemon, type DaemonOptions } from "./core/daemon.ts"
|
|
2
|
+
export type { CallContext, MethodTable, Module, ModuleContext, Peer } from "./core/module.ts"
|
|
3
|
+
export { createModules, type ModuleOptions } from "./modules/index.ts"
|
|
4
|
+
export { ShellModule, type ShellModuleOptions } from "./modules/shell/module.ts"
|
|
5
|
+
export type { PtyBackend, PtyProcess, PtySpawnOptions } from "./modules/shell/pty.ts"
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/** cockpitd entry point. Started detached by clients (ADR 0001); safe to run by hand for debugging. */
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { resolvePaths } from "@opencode-cockpit/protocol"
|
|
5
|
+
import { Daemon } from "./core/daemon.ts"
|
|
6
|
+
import type { Level } from "./core/logger.ts"
|
|
7
|
+
import { createModules } from "./modules/index.ts"
|
|
8
|
+
|
|
9
|
+
const env = process.env
|
|
10
|
+
const foreground = process.argv.includes("--foreground")
|
|
11
|
+
|
|
12
|
+
const paths = resolvePaths(env)
|
|
13
|
+
const daemon = new Daemon({
|
|
14
|
+
paths,
|
|
15
|
+
modules: createModules({ shell: { registryFile: join(paths.home, "shells.json") } }),
|
|
16
|
+
idleTimeoutMs: Number(env.COCKPIT_IDLE_TIMEOUT_MS ?? 10 * 60_000),
|
|
17
|
+
logLevel: (env.COCKPIT_LOG_LEVEL as Level | undefined) ?? "info",
|
|
18
|
+
logToFile: !foreground,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
|
22
|
+
process.on(signal, () => void daemon.stop(signal))
|
|
23
|
+
}
|
|
24
|
+
process.on("uncaughtException", (err) =>
|
|
25
|
+
daemon.log.error("uncaught exception", { err: String(err), stack: err.stack }),
|
|
26
|
+
)
|
|
27
|
+
process.on("unhandledRejection", (err) => daemon.log.error("unhandled rejection", { err: String(err) }))
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await daemon.start()
|
|
31
|
+
} catch (err) {
|
|
32
|
+
daemon.log.error("failed to start", { err: String(err) })
|
|
33
|
+
process.exit(1)
|
|
34
|
+
}
|
|
35
|
+
await daemon.stopped
|
|
36
|
+
process.exit(0)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Module } from "../core/module.ts"
|
|
2
|
+
import { ShellModule, type ShellModuleOptions } from "./shell/module.ts"
|
|
3
|
+
|
|
4
|
+
export interface ModuleOptions {
|
|
5
|
+
shell?: ShellModuleOptions
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Every capability the daemon hosts. Add new modules here. */
|
|
9
|
+
export function createModules(options: ModuleOptions = {}): Module[] {
|
|
10
|
+
return [new ShellModule(options.shell)]
|
|
11
|
+
}
|