@opencode-cockpit/daemon 0.1.4 → 0.2.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.
Files changed (68) 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 +41 -0
  9. package/dist/modules/index.js +5 -0
  10. package/dist/modules/shell/ids.js +7 -0
  11. package/dist/modules/shell/methods.js +186 -0
  12. package/dist/modules/shell/module.js +259 -0
  13. package/dist/modules/shell/output/line-log.js +76 -0
  14. package/dist/modules/shell/output/normalizer.js +161 -0
  15. package/dist/modules/shell/output/palette.js +19 -0
  16. package/dist/modules/shell/output/raw-ring.js +49 -0
  17. package/dist/modules/shell/output/screen.js +102 -0
  18. package/dist/modules/shell/port-probe.js +30 -0
  19. package/dist/modules/shell/pty.js +59 -0
  20. package/dist/modules/shell/registry.js +83 -0
  21. package/dist/modules/shell/shell.js +242 -0
  22. package/dist/modules/shell/wait.js +107 -0
  23. package/dist/modules/shell/watch/presets.js +299 -0
  24. package/dist/modules/shell/watch/watcher.js +82 -0
  25. package/package.json +12 -5
  26. package/types/core/daemon.d.ts +36 -0
  27. package/types/core/errors.d.ts +4 -0
  28. package/types/core/logger.d.ts +11 -0
  29. package/types/core/module.d.ts +37 -0
  30. package/types/core/router.d.ts +11 -0
  31. package/types/core/server.d.ts +49 -0
  32. package/{src/index.ts → types/index.d.ts} +5 -5
  33. package/types/main.d.ts +2 -0
  34. package/types/modules/index.d.ts +7 -0
  35. package/types/modules/shell/ids.d.ts +1 -0
  36. package/types/modules/shell/methods.d.ts +7 -0
  37. package/types/modules/shell/module.d.ts +68 -0
  38. package/types/modules/shell/output/line-log.d.ts +36 -0
  39. package/types/modules/shell/output/normalizer.d.ts +32 -0
  40. package/types/modules/shell/output/palette.d.ts +2 -0
  41. package/types/modules/shell/output/raw-ring.d.ts +19 -0
  42. package/types/modules/shell/output/screen.d.ts +12 -0
  43. package/types/modules/shell/port-probe.d.ts +2 -0
  44. package/types/modules/shell/pty.d.ts +33 -0
  45. package/types/modules/shell/registry.d.ts +17 -0
  46. package/types/modules/shell/shell.d.ts +89 -0
  47. package/types/modules/shell/wait.d.ts +12 -0
  48. package/types/modules/shell/watch/presets.d.ts +17 -0
  49. package/types/modules/shell/watch/watcher.d.ts +43 -0
  50. package/src/core/daemon.ts +0 -197
  51. package/src/core/errors.ts +0 -6
  52. package/src/core/logger.ts +0 -44
  53. package/src/core/module.ts +0 -45
  54. package/src/core/router.ts +0 -40
  55. package/src/core/server.ts +0 -223
  56. package/src/main.ts +0 -36
  57. package/src/modules/index.ts +0 -11
  58. package/src/modules/shell/ids.ts +0 -8
  59. package/src/modules/shell/module.ts +0 -323
  60. package/src/modules/shell/output/line-log.ts +0 -92
  61. package/src/modules/shell/output/normalizer.ts +0 -172
  62. package/src/modules/shell/output/raw-ring.ts +0 -46
  63. package/src/modules/shell/output/screen.ts +0 -44
  64. package/src/modules/shell/port-probe.ts +0 -30
  65. package/src/modules/shell/pty.ts +0 -98
  66. package/src/modules/shell/registry.ts +0 -86
  67. package/src/modules/shell/shell.ts +0 -252
  68. package/src/modules/shell/wait.ts +0 -91
@@ -0,0 +1,36 @@
1
+ import type { LogLine } from "@opencode-cockpit/protocol/shell";
2
+ export interface ReadQuery {
3
+ after?: number;
4
+ tail: number;
5
+ limit: number;
6
+ grep?: RegExp;
7
+ }
8
+ export interface ReadPage {
9
+ lines: LogLine[];
10
+ firstLine: number;
11
+ lastLine: number;
12
+ nextCursor: number;
13
+ truncated: boolean;
14
+ hasMore: boolean;
15
+ }
16
+ /**
17
+ * Committed lines with monotonic numbering (1-based) and a character budget.
18
+ * Eviction drops the oldest lines and advances `firstLine`; numbers are never reused, so cursors
19
+ * held by clients stay meaningful after eviction.
20
+ */
21
+ export declare class LineLog {
22
+ private readonly maxChars;
23
+ private lines;
24
+ private head;
25
+ private chars;
26
+ private first;
27
+ constructor(maxChars?: number);
28
+ /** Number of the oldest retained line (equals `lastLine + 1` when empty). */
29
+ get firstLine(): number;
30
+ /** Number of the newest line, or `firstLine - 1` when empty. */
31
+ get lastLine(): number;
32
+ append(text: string): LogLine;
33
+ get(n: number): string | undefined;
34
+ read(query: ReadQuery): ReadPage;
35
+ private evict;
36
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Streaming terminal-output normalizer (ADR 0003).
3
+ *
4
+ * Turns a PTY byte stream into committed plain-text lines, applying the parts of terminal
5
+ * semantics that matter for a single line: carriage return and backspace overwrite, tabs, erase
6
+ * in line, and horizontal cursor moves. Escape sequences are consumed and dropped. Anything that
7
+ * moves between lines (cursor up, scroll regions) is out of scope; the screen view handles those.
8
+ */
9
+ export interface NormalizerOptions {
10
+ /** Force a commit when a line grows past this many characters. */
11
+ maxLineLength?: number;
12
+ }
13
+ export declare class OutputNormalizer {
14
+ private readonly commit;
15
+ private readonly decoder;
16
+ private readonly maxLineLength;
17
+ private state;
18
+ private csiParams;
19
+ private cells;
20
+ private col;
21
+ constructor(commit: (text: string) => void, options?: NormalizerOptions);
22
+ /** Text of the line currently being written (not yet terminated by a newline). */
23
+ get partial(): string;
24
+ push(chunk: Uint8Array | string): void;
25
+ /** Commit the partial line, if any. Call when the stream ends. */
26
+ flush(): void;
27
+ private step;
28
+ private ground;
29
+ private csi;
30
+ private put;
31
+ private render;
32
+ }
@@ -0,0 +1,2 @@
1
+ export declare function paletteColor(index: number): string;
2
+ export declare function rgbColor(value: number): string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Bounded store of raw PTY bytes addressed by absolute offset, for replaying output to UIs that
3
+ * attach after the fact. Evicts whole chunks from the front.
4
+ */
5
+ export declare class RawRing {
6
+ private readonly maxBytes;
7
+ private chunks;
8
+ private size;
9
+ private start;
10
+ constructor(maxBytes?: number);
11
+ /** Absolute offset one past the last byte ever written. */
12
+ get end(): number;
13
+ append(chunk: Uint8Array): number;
14
+ /** Bytes from `offset` (clamped to what is retained) to the end. */
15
+ since(offset?: number): {
16
+ offset: number;
17
+ bytes: Uint8Array;
18
+ };
19
+ }
@@ -0,0 +1,12 @@
1
+ import type { ScreenResult } from "@opencode-cockpit/protocol/shell";
2
+ /** Full VT emulation of a shell's output: what a human would see right now (ADR 0003). */
3
+ export declare class Screen {
4
+ private readonly term;
5
+ constructor(cols: number, rows: number, scrollback?: number);
6
+ write(chunk: Uint8Array): void;
7
+ resize(cols: number, rows: number): void;
8
+ reset(): void;
9
+ /** Waits until every pending write has been parsed, then renders the viewport. */
10
+ snapshot(): Promise<ScreenResult>;
11
+ dispose(): void;
12
+ }
@@ -0,0 +1,2 @@
1
+ /** Resolves true once something accepts TCP connections on host:port. */
2
+ export declare function probePort(port: number, host?: string, timeoutMs?: number): Promise<boolean>;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * PTY backend seam. The shell module depends only on these interfaces so the process layer can be
3
+ * swapped (Windows ConPTY, remote hosts, test fakes) without touching session logic.
4
+ */
5
+ export interface PtySpawnOptions {
6
+ command: string;
7
+ args: string[];
8
+ cwd: string;
9
+ env: Record<string, string>;
10
+ cols: number;
11
+ rows: number;
12
+ onData: (chunk: Uint8Array) => void;
13
+ }
14
+ export interface PtyExit {
15
+ exitCode: number | null;
16
+ signal: string | null;
17
+ }
18
+ export interface PtyProcess {
19
+ readonly pid: number;
20
+ readonly exited: Promise<PtyExit>;
21
+ write(data: string | Uint8Array): number;
22
+ resize(cols: number, rows: number): void;
23
+ /** Signal the whole process group; falls back to the leader. */
24
+ signal(signal: NodeJS.Signals): void;
25
+ /** True while any member of the process group is alive. */
26
+ groupAlive(): boolean;
27
+ close(): void;
28
+ }
29
+ export interface PtyBackend {
30
+ spawn(options: PtySpawnOptions): PtyProcess;
31
+ }
32
+ /** Native PTY via `Bun.spawn({ terminal })` (Bun ≥ 1.3.5). The child leads its own session and group. */
33
+ export declare const bunPtyBackend: PtyBackend;
@@ -0,0 +1,17 @@
1
+ import type { Logger } from "../../core/logger.ts";
2
+ /**
3
+ * On-disk record of process groups the daemon owns. If the daemon dies without stopping its
4
+ * shells, the next daemon kills whatever is still alive from that list.
5
+ */
6
+ export declare class ProcessRegistry {
7
+ private readonly file;
8
+ private readonly log;
9
+ private entries;
10
+ constructor(file: string, log: Logger);
11
+ /** Kill leftovers from a previous daemon. Returns how many process groups were reaped. */
12
+ reap(): number;
13
+ add(id: string, pid: number, command: string): void;
14
+ remove(id: string): void;
15
+ private flush;
16
+ }
17
+ export declare function processStartTime(pid: number): string | undefined;
@@ -0,0 +1,89 @@
1
+ import type { LogLine, Owner, ScreenResult, ShellInfo, ShellStatus } from "@opencode-cockpit/protocol/shell";
2
+ import { LineLog } from "./output/line-log.ts";
3
+ import { RawRing } from "./output/raw-ring.ts";
4
+ import { Screen } from "./output/screen.ts";
5
+ import type { PtyBackend } from "./pty.ts";
6
+ import type { WatchChange, Watcher } from "./watch/watcher.ts";
7
+ export interface ShellSpec {
8
+ id: string;
9
+ command: string;
10
+ args: string[];
11
+ cwd: string;
12
+ env: Record<string, string>;
13
+ title: string;
14
+ cols: number;
15
+ rows: number;
16
+ owner: Owner;
17
+ timeoutMs?: number;
18
+ idleTimeoutMs?: number;
19
+ /** Absolute path the clean log is appended to, when logging was requested. */
20
+ logFile?: string;
21
+ }
22
+ export interface ShellLimits {
23
+ logChars: number;
24
+ rawBytes: number;
25
+ scrollback: number;
26
+ }
27
+ export interface ShellListener {
28
+ data?(offset: number, chunk: Uint8Array): void;
29
+ line?(line: LogLine): void;
30
+ /** The in-progress line changed (prompts that never end in a newline). */
31
+ partial?(text: string): void;
32
+ exit?(info: ShellInfo): void;
33
+ }
34
+ /**
35
+ * One shell: a command, its PTY, and the three output views (ADR 0003).
36
+ * Survives restarts: `run` increments and output views continue, separated by a marker line.
37
+ */
38
+ export declare class Shell {
39
+ readonly spec: ShellSpec;
40
+ private readonly backend;
41
+ readonly log: LineLog;
42
+ readonly raw: RawRing;
43
+ readonly screen: Screen;
44
+ status: ShellStatus;
45
+ run: number;
46
+ /** First log line number belonging to the current run. */
47
+ runStartLine: number;
48
+ lastOutputAt: number;
49
+ /** Health rule attached to this shell, if any (see watch/watcher.ts). */
50
+ watcher: Watcher | undefined;
51
+ /** Called when the watcher's reported status changes; never per line. */
52
+ onWatchChange: ((change: WatchChange) => void) | undefined;
53
+ private pty;
54
+ private normalizer;
55
+ private listeners;
56
+ private startedAt;
57
+ private endedAt;
58
+ private exit;
59
+ private error;
60
+ /** Why the daemon stopped it, when it was not a user or agent request. */
61
+ private stoppedBecause;
62
+ private summary;
63
+ private stopRequested;
64
+ private timeout;
65
+ private idleTimer;
66
+ private logWriter;
67
+ private exitPromise;
68
+ constructor(spec: ShellSpec, backend: PtyBackend, limits: ShellLimits);
69
+ get id(): string;
70
+ /** The line currently being written (e.g. a prompt awaiting input); empty when none. */
71
+ get partialLine(): string;
72
+ get running(): boolean;
73
+ /** Resolves when the current run has fully exited and been accounted for. */
74
+ get exited(): Promise<void>;
75
+ subscribe(listener: ShellListener): () => void;
76
+ start(): void;
77
+ write(data: string): number;
78
+ resize(cols: number, rows: number): void;
79
+ /** Signal the group, escalate to SIGKILL after `graceMs`, and reap stragglers. */
80
+ stop(signal?: NodeJS.Signals, graceMs?: number): Promise<void>;
81
+ snapshot(): Promise<ScreenResult>;
82
+ info(): ShellInfo;
83
+ dispose(): void;
84
+ /** Last error-looking line of the current run, else its last non-empty line. */
85
+ private summarize;
86
+ private createNormalizer;
87
+ private onData;
88
+ private onExit;
89
+ }
@@ -0,0 +1,12 @@
1
+ import type { LogLine, WaitParams, WaitReason } from "@opencode-cockpit/protocol/shell";
2
+ import type { Shell } from "./shell.ts";
3
+ export interface WaitOutcome {
4
+ reason: WaitReason;
5
+ match?: LogLine;
6
+ }
7
+ export declare const PORT_POLL_MS = 250;
8
+ /**
9
+ * Races every condition in `until` plus the timeout. Exit always ends a wait: once the process is
10
+ * gone no pattern, port or idle condition can still become true.
11
+ */
12
+ export declare function waitFor(shell: Shell, params: WaitParams, compile: (p: string, i: boolean) => RegExp): Promise<WaitOutcome>;
@@ -0,0 +1,17 @@
1
+ import type { WatchRule } from "@opencode-cockpit/protocol/shell";
2
+ export interface Preset {
3
+ name: string;
4
+ /** Matched against the command line to pick a preset automatically. */
5
+ match?: string;
6
+ rule: WatchRule;
7
+ }
8
+ /**
9
+ * Watch rules for common tools, as data rather than parsers: `done` marks the end of a run, `fail`
10
+ * and `ok` say how it went. They are deliberately conservative — matching a tool's summary line,
11
+ * not its full output — and a caller can always pass its own rule instead. Adding a tool is a row
12
+ * here, and a wrong guess costs a status, never a crash.
13
+ */
14
+ export declare const PRESETS: Preset[];
15
+ export declare function presetByName(name: string): Preset | undefined;
16
+ /** First preset whose `match` fits the command, e.g. `npm run dev` → nothing, `tsc --watch` → tsc. */
17
+ export declare function presetForCommand(command: string): Preset | undefined;
@@ -0,0 +1,43 @@
1
+ import type { WatchRule, WatchState, WatchStatus } from "@opencode-cockpit/protocol/shell";
2
+ export interface CompiledRule {
3
+ done?: RegExp;
4
+ fail?: RegExp;
5
+ ok?: RegExp;
6
+ idleMs?: number;
7
+ }
8
+ export declare function compileRule(rule: WatchRule): CompiledRule;
9
+ export interface WatchChange {
10
+ previous: WatchStatus;
11
+ current: WatchStatus;
12
+ summary?: string;
13
+ }
14
+ /**
15
+ * Tracks the health of one shell from its log.
16
+ *
17
+ * A run ends when `done` matches (or, without it, after `idleSeconds` of silence); the run's status
18
+ * is then `fail` if anything matched `fail`, `ok` if anything matched `ok`, and otherwise whatever
19
+ * it was. Only status changes are reported — a watcher that recompiles a thousand times says
20
+ * nothing until something actually breaks or gets fixed. A new failing line during an already
21
+ * failing run is reported too, since it is different news.
22
+ */
23
+ export declare class Watcher {
24
+ private readonly rule;
25
+ private readonly preset?;
26
+ private status;
27
+ private summaryText;
28
+ private runs;
29
+ private since;
30
+ private sawFail;
31
+ private sawOk;
32
+ constructor(rule: CompiledRule, preset?: string | undefined);
33
+ get idleMs(): number | undefined;
34
+ state(): WatchState;
35
+ /** Feeds one committed log line. Returns a change when the reported status or failure changed. */
36
+ line(text: string, now?: number): WatchChange | undefined;
37
+ /** Ends the current run because output went quiet (rules without a `done` pattern). */
38
+ idle(now?: number): WatchChange | undefined;
39
+ /** The process ended: a watched program that stops is a failure unless it exited cleanly. */
40
+ exited(exitCode: number | undefined, signal: string | undefined, now?: number): WatchChange | undefined;
41
+ private settle;
42
+ private apply;
43
+ }
@@ -1,197 +0,0 @@
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
- }
@@ -1,6 +0,0 @@
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)
@@ -1,44 +0,0 @@
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
- }
@@ -1,45 +0,0 @@
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
- }
@@ -1,40 +0,0 @@
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
- }