@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.
Files changed (60) hide show
  1. package/dist/core/daemon.js +193 -0
  2. package/dist/core/errors.js +4 -0
  3. package/dist/core/logger.js +45 -0
  4. package/dist/core/module.js +1 -0
  5. package/dist/core/router.js +33 -0
  6. package/dist/core/server.js +211 -0
  7. package/dist/index.js +3 -0
  8. package/dist/main.js +40 -0
  9. package/dist/modules/index.js +5 -0
  10. package/dist/modules/shell/ids.js +7 -0
  11. package/dist/modules/shell/module.js +334 -0
  12. package/dist/modules/shell/output/line-log.js +76 -0
  13. package/dist/modules/shell/output/normalizer.js +161 -0
  14. package/dist/modules/shell/output/raw-ring.js +49 -0
  15. package/dist/modules/shell/output/screen.js +45 -0
  16. package/dist/modules/shell/port-probe.js +30 -0
  17. package/dist/modules/shell/pty.js +59 -0
  18. package/dist/modules/shell/registry.js +83 -0
  19. package/dist/modules/shell/shell.js +193 -0
  20. package/dist/modules/shell/wait.js +107 -0
  21. package/package.json +12 -5
  22. package/types/core/daemon.d.ts +36 -0
  23. package/types/core/errors.d.ts +4 -0
  24. package/types/core/logger.d.ts +11 -0
  25. package/types/core/module.d.ts +37 -0
  26. package/types/core/router.d.ts +11 -0
  27. package/types/core/server.d.ts +49 -0
  28. package/{src/index.ts → types/index.d.ts} +5 -5
  29. package/types/main.d.ts +2 -0
  30. package/types/modules/index.d.ts +7 -0
  31. package/types/modules/shell/ids.d.ts +1 -0
  32. package/types/modules/shell/module.d.ts +43 -0
  33. package/types/modules/shell/output/line-log.d.ts +36 -0
  34. package/types/modules/shell/output/normalizer.d.ts +32 -0
  35. package/types/modules/shell/output/raw-ring.d.ts +19 -0
  36. package/types/modules/shell/output/screen.d.ts +12 -0
  37. package/types/modules/shell/port-probe.d.ts +2 -0
  38. package/types/modules/shell/pty.d.ts +33 -0
  39. package/types/modules/shell/registry.d.ts +17 -0
  40. package/types/modules/shell/shell.d.ts +77 -0
  41. package/types/modules/shell/wait.d.ts +12 -0
  42. package/src/core/daemon.ts +0 -197
  43. package/src/core/errors.ts +0 -6
  44. package/src/core/logger.ts +0 -44
  45. package/src/core/module.ts +0 -45
  46. package/src/core/router.ts +0 -40
  47. package/src/core/server.ts +0 -223
  48. package/src/main.ts +0 -36
  49. package/src/modules/index.ts +0 -11
  50. package/src/modules/shell/ids.ts +0 -8
  51. package/src/modules/shell/module.ts +0 -323
  52. package/src/modules/shell/output/line-log.ts +0 -92
  53. package/src/modules/shell/output/normalizer.ts +0 -172
  54. package/src/modules/shell/output/raw-ring.ts +0 -46
  55. package/src/modules/shell/output/screen.ts +0 -44
  56. package/src/modules/shell/port-probe.ts +0 -30
  57. package/src/modules/shell/pty.ts +0 -98
  58. package/src/modules/shell/registry.ts +0 -86
  59. package/src/modules/shell/shell.ts +0 -252
  60. 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
@@ -0,0 +1,3 @@
1
+ export { DAEMON_BUILD, DAEMON_VERSION, Daemon } from "./core/daemon.js";
2
+ export { createModules } from "./modules/index.js";
3
+ export { ShellModule } from "./modules/shell/module.js";
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);
@@ -0,0 +1,5 @@
1
+ import { ShellModule } from "./shell/module.js";
2
+ /** Every capability the daemon hosts. Add new modules here. */
3
+ export function createModules(options = {}) {
4
+ return [new ShellModule(options.shell)];
5
+ }
@@ -0,0 +1,7 @@
1
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
2
+ export function newShellId() {
3
+ const bytes = crypto.getRandomValues(new Uint8Array(8));
4
+ let id = "sh_";
5
+ for (const byte of bytes) id += ALPHABET[byte % 32];
6
+ return id;
7
+ }