@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,43 @@
1
+ import type { MethodTable, Module, ModuleContext } from "../../core/module.ts";
2
+ import { type PtyBackend } from "./pty.ts";
3
+ import { type ShellLimits } from "./shell.ts";
4
+ export interface ShellModuleOptions {
5
+ backend?: PtyBackend;
6
+ limits?: Partial<ShellLimits>;
7
+ /** Oldest finished shells are forgotten beyond this many. */
8
+ maxFinished?: number;
9
+ /** Base environment for spawned processes (defaults to the daemon's). */
10
+ baseEnv?: Record<string, string | undefined>;
11
+ /** Coalesce output events per attached peer for this long. */
12
+ outputFlushMs?: number;
13
+ /** Where to record owned process groups so a restarted daemon can reap orphans. */
14
+ registryFile?: string;
15
+ }
16
+ export declare class ShellModule implements Module<"shell"> {
17
+ private readonly options;
18
+ readonly name: "shell";
19
+ private readonly shells;
20
+ private readonly attachments;
21
+ private readonly backend;
22
+ private readonly limits;
23
+ private log;
24
+ private registry;
25
+ private emit;
26
+ constructor(options?: ShellModuleOptions);
27
+ start(ctx: ModuleContext): Promise<void>;
28
+ stop(): Promise<void>;
29
+ busy(): boolean;
30
+ readonly methods: MethodTable<"shell">;
31
+ private startShell;
32
+ private findReusable;
33
+ private spawn;
34
+ private onExit;
35
+ private attachStream;
36
+ private detach;
37
+ private forget;
38
+ private pruneFinished;
39
+ private require;
40
+ private uniqueId;
41
+ private environment;
42
+ }
43
+ export declare function compilePattern(pattern: string, ignoreCase: boolean): RegExp;
@@ -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,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,77 @@
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
+ export interface ShellSpec {
7
+ id: string;
8
+ command: string;
9
+ args: string[];
10
+ cwd: string;
11
+ env: Record<string, string>;
12
+ title: string;
13
+ cols: number;
14
+ rows: number;
15
+ owner: Owner;
16
+ timeoutMs?: number;
17
+ }
18
+ export interface ShellLimits {
19
+ logChars: number;
20
+ rawBytes: number;
21
+ scrollback: number;
22
+ }
23
+ export interface ShellListener {
24
+ data?(offset: number, chunk: Uint8Array): void;
25
+ line?(line: LogLine): void;
26
+ /** The in-progress line changed (prompts that never end in a newline). */
27
+ partial?(text: string): void;
28
+ exit?(info: ShellInfo): void;
29
+ }
30
+ /**
31
+ * One shell: a command, its PTY, and the three output views (ADR 0003).
32
+ * Survives restarts: `run` increments and output views continue, separated by a marker line.
33
+ */
34
+ export declare class Shell {
35
+ readonly spec: ShellSpec;
36
+ private readonly backend;
37
+ readonly log: LineLog;
38
+ readonly raw: RawRing;
39
+ readonly screen: Screen;
40
+ status: ShellStatus;
41
+ run: number;
42
+ /** First log line number belonging to the current run. */
43
+ runStartLine: number;
44
+ lastOutputAt: number;
45
+ private pty;
46
+ private normalizer;
47
+ private listeners;
48
+ private startedAt;
49
+ private endedAt;
50
+ private exit;
51
+ private error;
52
+ private summary;
53
+ private stopRequested;
54
+ private timeout;
55
+ private exitPromise;
56
+ constructor(spec: ShellSpec, backend: PtyBackend, limits: ShellLimits);
57
+ get id(): string;
58
+ /** The line currently being written (e.g. a prompt awaiting input); empty when none. */
59
+ get partialLine(): string;
60
+ get running(): boolean;
61
+ /** Resolves when the current run has fully exited and been accounted for. */
62
+ get exited(): Promise<void>;
63
+ subscribe(listener: ShellListener): () => void;
64
+ start(): void;
65
+ write(data: string): number;
66
+ resize(cols: number, rows: number): void;
67
+ /** Signal the group, escalate to SIGKILL after `graceMs`, and reap stragglers. */
68
+ stop(signal?: NodeJS.Signals, graceMs?: number): Promise<void>;
69
+ snapshot(): Promise<ScreenResult>;
70
+ info(): ShellInfo;
71
+ dispose(): void;
72
+ /** Last error-looking line of the current run, else its last non-empty line. */
73
+ private summarize;
74
+ private createNormalizer;
75
+ private onData;
76
+ private onExit;
77
+ }
@@ -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>;
@@ -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
- }