@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.
- 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 +41 -0
- package/dist/modules/index.js +5 -0
- package/dist/modules/shell/ids.js +7 -0
- package/dist/modules/shell/methods.js +186 -0
- package/dist/modules/shell/module.js +259 -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/palette.js +19 -0
- package/dist/modules/shell/output/raw-ring.js +49 -0
- package/dist/modules/shell/output/screen.js +102 -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 +242 -0
- package/dist/modules/shell/wait.js +107 -0
- package/dist/modules/shell/watch/presets.js +299 -0
- package/dist/modules/shell/watch/watcher.js +82 -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/methods.d.ts +7 -0
- package/types/modules/shell/module.d.ts +68 -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/palette.d.ts +2 -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 +89 -0
- package/types/modules/shell/wait.d.ts +12 -0
- package/types/modules/shell/watch/presets.d.ts +17 -0
- package/types/modules/shell/watch/watcher.d.ts +43 -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,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,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
|
+
}
|
package/src/core/daemon.ts
DELETED
|
@@ -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
|
-
}
|
package/src/core/errors.ts
DELETED
|
@@ -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)
|
package/src/core/logger.ts
DELETED
|
@@ -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
|
-
}
|
package/src/core/module.ts
DELETED
|
@@ -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
|
-
}
|
package/src/core/router.ts
DELETED
|
@@ -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
|
-
}
|