@opencode-cockpit/daemon 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/daemon.js +193 -0
- package/dist/core/errors.js +4 -0
- package/dist/core/logger.js +45 -0
- package/dist/core/module.js +1 -0
- package/dist/core/router.js +33 -0
- package/dist/core/server.js +211 -0
- package/dist/index.js +3 -0
- package/dist/main.js +40 -0
- package/dist/modules/index.js +5 -0
- package/dist/modules/shell/ids.js +7 -0
- package/dist/modules/shell/module.js +334 -0
- package/dist/modules/shell/output/line-log.js +76 -0
- package/dist/modules/shell/output/normalizer.js +161 -0
- package/dist/modules/shell/output/raw-ring.js +49 -0
- package/dist/modules/shell/output/screen.js +45 -0
- package/dist/modules/shell/port-probe.js +30 -0
- package/dist/modules/shell/pty.js +59 -0
- package/dist/modules/shell/registry.js +83 -0
- package/dist/modules/shell/shell.js +193 -0
- package/dist/modules/shell/wait.js +107 -0
- package/package.json +12 -5
- package/types/core/daemon.d.ts +36 -0
- package/types/core/errors.d.ts +4 -0
- package/types/core/logger.d.ts +11 -0
- package/types/core/module.d.ts +37 -0
- package/types/core/router.d.ts +11 -0
- package/types/core/server.d.ts +49 -0
- package/{src/index.ts → types/index.d.ts} +5 -5
- package/types/main.d.ts +2 -0
- package/types/modules/index.d.ts +7 -0
- package/types/modules/shell/ids.d.ts +1 -0
- package/types/modules/shell/module.d.ts +43 -0
- package/types/modules/shell/output/line-log.d.ts +36 -0
- package/types/modules/shell/output/normalizer.d.ts +32 -0
- package/types/modules/shell/output/raw-ring.d.ts +19 -0
- package/types/modules/shell/output/screen.d.ts +12 -0
- package/types/modules/shell/port-probe.d.ts +2 -0
- package/types/modules/shell/pty.d.ts +33 -0
- package/types/modules/shell/registry.d.ts +17 -0
- package/types/modules/shell/shell.d.ts +77 -0
- package/types/modules/shell/wait.d.ts +12 -0
- package/src/core/daemon.ts +0 -197
- package/src/core/errors.ts +0 -6
- package/src/core/logger.ts +0 -44
- package/src/core/module.ts +0 -45
- package/src/core/router.ts +0 -40
- package/src/core/server.ts +0 -223
- package/src/main.ts +0 -36
- package/src/modules/index.ts +0 -11
- package/src/modules/shell/ids.ts +0 -8
- package/src/modules/shell/module.ts +0 -323
- package/src/modules/shell/output/line-log.ts +0 -92
- package/src/modules/shell/output/normalizer.ts +0 -172
- package/src/modules/shell/output/raw-ring.ts +0 -46
- package/src/modules/shell/output/screen.ts +0 -44
- package/src/modules/shell/port-probe.ts +0 -30
- package/src/modules/shell/pty.ts +0 -98
- package/src/modules/shell/registry.ts +0 -86
- package/src/modules/shell/shell.ts +0 -252
- package/src/modules/shell/wait.ts +0 -91
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { daemonBuildId, ErrorCode, PROTOCOL_VERSION, RpcError } from "@opencode-cockpit/protocol";
|
|
3
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
4
|
+
import { createLogger } from "./logger.js";
|
|
5
|
+
import { Router } from "./router.js";
|
|
6
|
+
import { RpcServer } from "./server.js";
|
|
7
|
+
export const DAEMON_VERSION = pkg.version;
|
|
8
|
+
|
|
9
|
+
/** Build id of this daemon's own code; computed once, matches what clients compute for the entry. */
|
|
10
|
+
// The directory this module was loaded from: `src/` in a checkout, `dist/` once published.
|
|
11
|
+
export const DAEMON_BUILD = daemonBuildId(Bun.fileURLToPath(new URL("..", import.meta.url)), DAEMON_VERSION);
|
|
12
|
+
export class Daemon {
|
|
13
|
+
router = new Router();
|
|
14
|
+
startedAt = Date.now();
|
|
15
|
+
/** Resolves once the daemon has fully shut down. */
|
|
16
|
+
stopped = new Promise(resolve => {
|
|
17
|
+
this.resolveStopped = resolve;
|
|
18
|
+
});
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
this.log = createLogger(options.logToFile === false ? undefined : options.paths.logFile, options.logLevel);
|
|
22
|
+
this.server = new RpcServer(this.router, {
|
|
23
|
+
onConnect: () => this.refreshIdle(),
|
|
24
|
+
onDisconnect: () => this.refreshIdle()
|
|
25
|
+
}, this.log.child("rpc"));
|
|
26
|
+
this.registerCore();
|
|
27
|
+
for (const module of options.modules) this.router.addModule(module);
|
|
28
|
+
}
|
|
29
|
+
async start() {
|
|
30
|
+
const {
|
|
31
|
+
paths
|
|
32
|
+
} = this.options;
|
|
33
|
+
mkdirSync(paths.home, {
|
|
34
|
+
recursive: true,
|
|
35
|
+
mode: 0o700
|
|
36
|
+
});
|
|
37
|
+
chmodSync(paths.home, 0o700);
|
|
38
|
+
await this.claimSocket(paths.socket);
|
|
39
|
+
for (const module of this.options.modules) {
|
|
40
|
+
await module.start({
|
|
41
|
+
log: this.log.child(module.name),
|
|
42
|
+
emit: (topic, data) => {
|
|
43
|
+
this.server.broadcast(topic, data);
|
|
44
|
+
// Module state changes (a shell exiting) can make the daemon idle.
|
|
45
|
+
this.refreshIdle();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
this.server.listen(paths.socket);
|
|
50
|
+
chmodSync(paths.socket, 0o600);
|
|
51
|
+
writeFileSync(paths.pidFile, String(process.pid), {
|
|
52
|
+
mode: 0o600
|
|
53
|
+
});
|
|
54
|
+
this.idleCheck = setInterval(() => this.refreshIdle(), 30_000);
|
|
55
|
+
this.refreshIdle();
|
|
56
|
+
this.log.info("daemon started", {
|
|
57
|
+
pid: process.pid,
|
|
58
|
+
build: DAEMON_BUILD,
|
|
59
|
+
socket: paths.socket
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
stop(reason = "requested") {
|
|
63
|
+
this.stopping ??= (async () => {
|
|
64
|
+
this.log.info("daemon stopping", {
|
|
65
|
+
reason
|
|
66
|
+
});
|
|
67
|
+
clearTimeout(this.idleTimer);
|
|
68
|
+
clearInterval(this.idleCheck);
|
|
69
|
+
this.server.stop();
|
|
70
|
+
for (const module of [...this.options.modules].reverse()) {
|
|
71
|
+
try {
|
|
72
|
+
await module.stop();
|
|
73
|
+
} catch (err) {
|
|
74
|
+
this.log.error("module stop failed", {
|
|
75
|
+
module: module.name,
|
|
76
|
+
err: String(err)
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const {
|
|
81
|
+
paths
|
|
82
|
+
} = this.options;
|
|
83
|
+
if (readPid(paths.pidFile) === process.pid) rmSync(paths.pidFile, {
|
|
84
|
+
force: true
|
|
85
|
+
});
|
|
86
|
+
rmSync(paths.socket, {
|
|
87
|
+
force: true
|
|
88
|
+
});
|
|
89
|
+
this.log.info("daemon stopped");
|
|
90
|
+
this.resolveStopped();
|
|
91
|
+
})();
|
|
92
|
+
return this.stopping;
|
|
93
|
+
}
|
|
94
|
+
busy() {
|
|
95
|
+
return this.options.modules.some(m => m.busy());
|
|
96
|
+
}
|
|
97
|
+
refreshIdle() {
|
|
98
|
+
const timeout = this.options.idleTimeoutMs ?? 0;
|
|
99
|
+
if (timeout <= 0 || this.stopping) return;
|
|
100
|
+
const idle = this.server.clientCount === 0 && !this.busy();
|
|
101
|
+
if (!idle) {
|
|
102
|
+
clearTimeout(this.idleTimer);
|
|
103
|
+
this.idleTimer = undefined;
|
|
104
|
+
} else if (!this.idleTimer) {
|
|
105
|
+
this.idleTimer = setTimeout(() => {
|
|
106
|
+
if (this.server.clientCount === 0 && !this.busy()) void this.stop("idle");else this.idleTimer = undefined;
|
|
107
|
+
}, timeout);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Refuse to start if a live daemon owns the socket; otherwise clear a stale one. */
|
|
112
|
+
async claimSocket(socket) {
|
|
113
|
+
if (!existsSync(socket)) return;
|
|
114
|
+
const alive = await new Promise(resolve => {
|
|
115
|
+
Bun.connect({
|
|
116
|
+
unix: socket,
|
|
117
|
+
socket: {
|
|
118
|
+
open(s) {
|
|
119
|
+
s.end();
|
|
120
|
+
resolve(true);
|
|
121
|
+
},
|
|
122
|
+
data() {},
|
|
123
|
+
connectError: () => resolve(false),
|
|
124
|
+
error: () => resolve(false)
|
|
125
|
+
}
|
|
126
|
+
}).catch(() => resolve(false));
|
|
127
|
+
});
|
|
128
|
+
if (alive) throw new Error(`another daemon is listening on ${socket}`);
|
|
129
|
+
rmSync(socket, {
|
|
130
|
+
force: true
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
registerCore() {
|
|
134
|
+
this.router.add("daemon.hello", (raw, {
|
|
135
|
+
peer
|
|
136
|
+
}) => {
|
|
137
|
+
const params = raw;
|
|
138
|
+
if (params.protocol.major !== PROTOCOL_VERSION.major) {
|
|
139
|
+
throw new RpcError(ErrorCode.ProtocolMismatch, "protocol major version mismatch", {
|
|
140
|
+
daemon: PROTOCOL_VERSION,
|
|
141
|
+
client: params.protocol,
|
|
142
|
+
busy: this.busy()
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
peer.greet(params.client.name);
|
|
146
|
+
return {
|
|
147
|
+
daemonVersion: DAEMON_VERSION,
|
|
148
|
+
build: DAEMON_BUILD,
|
|
149
|
+
protocol: PROTOCOL_VERSION,
|
|
150
|
+
modules: this.options.modules.map(m => m.name),
|
|
151
|
+
pid: process.pid,
|
|
152
|
+
startedAt: this.startedAt
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
this.router.add("daemon.status", () => ({
|
|
156
|
+
pid: process.pid,
|
|
157
|
+
uptimeMs: Date.now() - this.startedAt,
|
|
158
|
+
clients: this.server.clientCount,
|
|
159
|
+
modules: this.options.modules.map(m => ({
|
|
160
|
+
name: m.name,
|
|
161
|
+
busy: m.busy()
|
|
162
|
+
}))
|
|
163
|
+
}));
|
|
164
|
+
this.router.add("daemon.shutdown", raw => {
|
|
165
|
+
const force = raw?.force === true;
|
|
166
|
+
if (this.busy() && !force) return {
|
|
167
|
+
accepted: false
|
|
168
|
+
};
|
|
169
|
+
setTimeout(() => void this.stop(force ? "forced shutdown" : "shutdown"), 10);
|
|
170
|
+
return {
|
|
171
|
+
accepted: true
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
const topics = on => (raw, {
|
|
175
|
+
peer
|
|
176
|
+
}) => {
|
|
177
|
+
const list = raw.topics;
|
|
178
|
+
for (const t of list) on ? peer.topics.add(t) : peer.topics.delete(t);
|
|
179
|
+
return {
|
|
180
|
+
topics: [...peer.topics]
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
this.router.add("events.subscribe", topics(true));
|
|
184
|
+
this.router.add("events.unsubscribe", topics(false));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function readPid(file) {
|
|
188
|
+
try {
|
|
189
|
+
return Number.parseInt(readFileSync(file, "utf8"), 10);
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { ErrorCode, RpcError } from "@opencode-cockpit/protocol";
|
|
2
|
+
export const notFound = what => new RpcError(ErrorCode.NotFound, `${what} not found`);
|
|
3
|
+
export const invalidState = message => new RpcError(ErrorCode.InvalidState, message);
|
|
4
|
+
export const invalidParams = (message, data) => new RpcError(ErrorCode.InvalidParams, message, data);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
const order = {
|
|
3
|
+
debug: 10,
|
|
4
|
+
info: 20,
|
|
5
|
+
warn: 30,
|
|
6
|
+
error: 40
|
|
7
|
+
};
|
|
8
|
+
/** JSON-lines logger. Writes synchronously so the last lines survive a crash. */
|
|
9
|
+
export function createLogger(file, level = "info", scope = "cockpitd") {
|
|
10
|
+
const write = (lvl, msg, fields) => {
|
|
11
|
+
if (order[lvl] < order[level]) return;
|
|
12
|
+
const line = `${JSON.stringify({
|
|
13
|
+
t: new Date().toISOString(),
|
|
14
|
+
lvl,
|
|
15
|
+
scope,
|
|
16
|
+
msg,
|
|
17
|
+
...fields
|
|
18
|
+
})}\n`;
|
|
19
|
+
if (file) {
|
|
20
|
+
try {
|
|
21
|
+
appendFileSync(file, line, {
|
|
22
|
+
mode: 0o600
|
|
23
|
+
});
|
|
24
|
+
} catch {
|
|
25
|
+
process.stderr.write(line);
|
|
26
|
+
}
|
|
27
|
+
} else if (lvl !== "debug") {
|
|
28
|
+
process.stderr.write(line);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
debug: (m, f) => write("debug", m, f),
|
|
33
|
+
info: (m, f) => write("info", m, f),
|
|
34
|
+
warn: (m, f) => write("warn", m, f),
|
|
35
|
+
error: (m, f) => write("error", m, f),
|
|
36
|
+
child: s => createLogger(file, level, `${scope}:${s}`)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export const silentLogger = {
|
|
40
|
+
debug() {},
|
|
41
|
+
info() {},
|
|
42
|
+
warn() {},
|
|
43
|
+
error() {},
|
|
44
|
+
child: () => silentLogger
|
|
45
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { contract, ErrorCode, RpcError } from "@opencode-cockpit/protocol";
|
|
2
|
+
/** Validates params against the protocol contract and dispatches to module handlers. */
|
|
3
|
+
export class Router {
|
|
4
|
+
handlers = new Map();
|
|
5
|
+
add(name, handler) {
|
|
6
|
+
if (!(name in contract)) throw new Error(`method ${name} is not declared in the protocol contract`);
|
|
7
|
+
if (this.handlers.has(name)) throw new Error(`method ${name} registered twice`);
|
|
8
|
+
this.handlers.set(name, handler);
|
|
9
|
+
}
|
|
10
|
+
addModule(module) {
|
|
11
|
+
for (const [short, handler] of Object.entries(module.methods)) {
|
|
12
|
+
this.add(`${module.name}.${short}`, handler.bind(module.methods));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
has(name) {
|
|
16
|
+
return this.handlers.has(name);
|
|
17
|
+
}
|
|
18
|
+
async dispatch(name, params, call) {
|
|
19
|
+
const handler = this.handlers.get(name);
|
|
20
|
+
const spec = contract[name];
|
|
21
|
+
if (!handler || !spec) throw new RpcError(ErrorCode.MethodNotFound, `unknown method ${name}`);
|
|
22
|
+
const parsed = spec.params.safeParse(params);
|
|
23
|
+
if (!parsed.success) {
|
|
24
|
+
throw new RpcError(ErrorCode.InvalidParams, `invalid params for ${name}: ${parsed.error.issues.map(i => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`, {
|
|
25
|
+
issues: parsed.error.issues.map(i => ({
|
|
26
|
+
path: i.path,
|
|
27
|
+
message: i.message
|
|
28
|
+
}))
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return handler(parsed.data, call);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { ErrorCode, EVENT_METHOD, encodeFrame, LineDecoder, RpcError } from "@opencode-cockpit/protocol";
|
|
2
|
+
const MAX_QUEUED_BYTES = 32 * 1024 * 1024;
|
|
3
|
+
class PeerImpl {
|
|
4
|
+
name = "unknown";
|
|
5
|
+
greeted = false;
|
|
6
|
+
topics = new Set();
|
|
7
|
+
closers = [];
|
|
8
|
+
queue = [];
|
|
9
|
+
queued = 0;
|
|
10
|
+
closed = false;
|
|
11
|
+
constructor(id, socket, log) {
|
|
12
|
+
this.id = id;
|
|
13
|
+
this.socket = socket;
|
|
14
|
+
this.log = log;
|
|
15
|
+
}
|
|
16
|
+
greet(name) {
|
|
17
|
+
this.name = name;
|
|
18
|
+
this.greeted = true;
|
|
19
|
+
}
|
|
20
|
+
onClose(fn) {
|
|
21
|
+
if (this.closed) fn();else this.closers.push(fn);
|
|
22
|
+
}
|
|
23
|
+
send(topic, data) {
|
|
24
|
+
this.write({
|
|
25
|
+
jsonrpc: "2.0",
|
|
26
|
+
method: EVENT_METHOD,
|
|
27
|
+
params: {
|
|
28
|
+
topic,
|
|
29
|
+
data
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
subscribed(topic) {
|
|
34
|
+
if (this.topics.has(topic) || this.topics.has("*")) return true;
|
|
35
|
+
const dot = topic.indexOf(".");
|
|
36
|
+
return dot > 0 && this.topics.has(`${topic.slice(0, dot)}.*`);
|
|
37
|
+
}
|
|
38
|
+
write(message) {
|
|
39
|
+
if (this.closed) return;
|
|
40
|
+
const frame = encodeFrame(message);
|
|
41
|
+
if (this.queue.length > 0) {
|
|
42
|
+
this.enqueue(frame);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const written = this.socket.write(frame);
|
|
46
|
+
if (written < frame.byteLength) this.enqueue(frame.subarray(Math.max(0, written)));
|
|
47
|
+
}
|
|
48
|
+
drain() {
|
|
49
|
+
while (this.queue.length > 0) {
|
|
50
|
+
const head = this.queue[0];
|
|
51
|
+
const written = this.socket.write(head);
|
|
52
|
+
if (written < head.byteLength) {
|
|
53
|
+
this.queue[0] = head.subarray(Math.max(0, written));
|
|
54
|
+
this.queued -= Math.max(0, written);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
this.queue.shift();
|
|
58
|
+
this.queued -= head.byteLength;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
close() {
|
|
62
|
+
if (this.closed) return;
|
|
63
|
+
this.closed = true;
|
|
64
|
+
this.queue = [];
|
|
65
|
+
for (const fn of this.closers.splice(0)) {
|
|
66
|
+
try {
|
|
67
|
+
fn();
|
|
68
|
+
} catch (err) {
|
|
69
|
+
this.log.warn("peer close hook failed", {
|
|
70
|
+
err: String(err)
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
enqueue(frame) {
|
|
76
|
+
this.queue.push(frame);
|
|
77
|
+
this.queued += frame.byteLength;
|
|
78
|
+
if (this.queued > MAX_QUEUED_BYTES) {
|
|
79
|
+
this.log.warn("peer too slow, disconnecting", {
|
|
80
|
+
peer: this.id,
|
|
81
|
+
queued: this.queued
|
|
82
|
+
});
|
|
83
|
+
this.socket.end();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export class RpcServer {
|
|
88
|
+
peers = new Set();
|
|
89
|
+
nextId = 1;
|
|
90
|
+
constructor(router, hooks, log) {
|
|
91
|
+
this.router = router;
|
|
92
|
+
this.hooks = hooks;
|
|
93
|
+
this.log = log;
|
|
94
|
+
}
|
|
95
|
+
get clientCount() {
|
|
96
|
+
return this.peers.size;
|
|
97
|
+
}
|
|
98
|
+
listen(path) {
|
|
99
|
+
const decoders = new WeakMap();
|
|
100
|
+
this.listener = Bun.listen({
|
|
101
|
+
unix: path,
|
|
102
|
+
socket: {
|
|
103
|
+
open: socket => {
|
|
104
|
+
const peer = new PeerImpl(this.nextId++, socket, this.log);
|
|
105
|
+
socket.data = {
|
|
106
|
+
peer
|
|
107
|
+
};
|
|
108
|
+
decoders.set(peer, new LineDecoder());
|
|
109
|
+
this.peers.add(peer);
|
|
110
|
+
this.hooks.onConnect(this.peers.size);
|
|
111
|
+
},
|
|
112
|
+
data: (socket, chunk) => {
|
|
113
|
+
const peer = socket.data.peer;
|
|
114
|
+
let lines;
|
|
115
|
+
try {
|
|
116
|
+
lines = decoders.get(peer).push(chunk);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
peer.write({
|
|
119
|
+
jsonrpc: "2.0",
|
|
120
|
+
id: null,
|
|
121
|
+
error: {
|
|
122
|
+
code: ErrorCode.ParseError,
|
|
123
|
+
message: String(err)
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
socket.end();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
for (const line of lines) void this.handleLine(peer, line);
|
|
130
|
+
},
|
|
131
|
+
drain: socket => socket.data.peer.drain(),
|
|
132
|
+
close: socket => this.drop(socket.data.peer),
|
|
133
|
+
error: (socket, err) => {
|
|
134
|
+
this.log.warn("socket error", {
|
|
135
|
+
err: String(err)
|
|
136
|
+
});
|
|
137
|
+
this.drop(socket.data.peer);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
broadcast(topic, data) {
|
|
143
|
+
for (const peer of this.peers) if (peer.subscribed(topic)) peer.send(topic, data);
|
|
144
|
+
}
|
|
145
|
+
stop() {
|
|
146
|
+
this.listener?.stop(true);
|
|
147
|
+
for (const peer of this.peers) peer.close();
|
|
148
|
+
this.peers.clear();
|
|
149
|
+
}
|
|
150
|
+
drop(peer) {
|
|
151
|
+
if (!this.peers.delete(peer)) return;
|
|
152
|
+
peer.close();
|
|
153
|
+
this.hooks.onDisconnect(this.peers.size);
|
|
154
|
+
}
|
|
155
|
+
async handleLine(peer, line) {
|
|
156
|
+
let message;
|
|
157
|
+
try {
|
|
158
|
+
message = JSON.parse(line);
|
|
159
|
+
} catch {
|
|
160
|
+
peer.write({
|
|
161
|
+
jsonrpc: "2.0",
|
|
162
|
+
id: null,
|
|
163
|
+
error: {
|
|
164
|
+
code: ErrorCode.ParseError,
|
|
165
|
+
message: "invalid JSON"
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (typeof message !== "object" || message === null || !("method" in message) || typeof message.method !== "string") {
|
|
171
|
+
peer.write({
|
|
172
|
+
jsonrpc: "2.0",
|
|
173
|
+
id: null,
|
|
174
|
+
error: {
|
|
175
|
+
code: ErrorCode.InvalidRequest,
|
|
176
|
+
message: "expected a request"
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const hasId = "id" in message && (typeof message.id === "number" || typeof message.id === "string");
|
|
182
|
+
const request = message;
|
|
183
|
+
try {
|
|
184
|
+
const result = await this.invoke(peer, request);
|
|
185
|
+
if (hasId) peer.write({
|
|
186
|
+
jsonrpc: "2.0",
|
|
187
|
+
id: request.id,
|
|
188
|
+
result: result ?? {}
|
|
189
|
+
});
|
|
190
|
+
} catch (err) {
|
|
191
|
+
const rpc = err instanceof RpcError ? err : new RpcError(ErrorCode.InternalError, err instanceof Error ? err.message : String(err));
|
|
192
|
+
if (!(err instanceof RpcError)) this.log.error("handler crashed", {
|
|
193
|
+
method: request.method,
|
|
194
|
+
err: String(err)
|
|
195
|
+
});
|
|
196
|
+
if (hasId) peer.write({
|
|
197
|
+
jsonrpc: "2.0",
|
|
198
|
+
id: request.id,
|
|
199
|
+
error: rpc.toShape()
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async invoke(peer, request) {
|
|
204
|
+
if (!peer.greeted && request.method !== "daemon.hello") {
|
|
205
|
+
throw new RpcError(ErrorCode.InvalidRequest, "call daemon.hello first");
|
|
206
|
+
}
|
|
207
|
+
return this.router.dispatch(request.method, request.params, {
|
|
208
|
+
peer
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
package/dist/index.js
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
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.js";
|
|
6
|
+
import { createModules } from "./modules/index.js";
|
|
7
|
+
const env = process.env;
|
|
8
|
+
const foreground = process.argv.includes("--foreground");
|
|
9
|
+
const paths = resolvePaths(env);
|
|
10
|
+
const daemon = new Daemon({
|
|
11
|
+
paths,
|
|
12
|
+
modules: createModules({
|
|
13
|
+
shell: {
|
|
14
|
+
registryFile: join(paths.home, "shells.json")
|
|
15
|
+
}
|
|
16
|
+
}),
|
|
17
|
+
idleTimeoutMs: Number(env.COCKPIT_IDLE_TIMEOUT_MS ?? 10 * 60_000),
|
|
18
|
+
logLevel: env.COCKPIT_LOG_LEVEL ?? "info",
|
|
19
|
+
logToFile: !foreground
|
|
20
|
+
});
|
|
21
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
22
|
+
process.on(signal, () => void daemon.stop(signal));
|
|
23
|
+
}
|
|
24
|
+
process.on("uncaughtException", err => daemon.log.error("uncaught exception", {
|
|
25
|
+
err: String(err),
|
|
26
|
+
stack: err.stack
|
|
27
|
+
}));
|
|
28
|
+
process.on("unhandledRejection", err => daemon.log.error("unhandled rejection", {
|
|
29
|
+
err: String(err)
|
|
30
|
+
}));
|
|
31
|
+
try {
|
|
32
|
+
await daemon.start();
|
|
33
|
+
} catch (err) {
|
|
34
|
+
daemon.log.error("failed to start", {
|
|
35
|
+
err: String(err)
|
|
36
|
+
});
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
await daemon.stopped;
|
|
40
|
+
process.exit(0);
|