@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 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
+ }