@opencode-cockpit/daemon 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/daemon.js +193 -0
- package/dist/core/errors.js +4 -0
- package/dist/core/logger.js +45 -0
- package/dist/core/module.js +1 -0
- package/dist/core/router.js +33 -0
- package/dist/core/server.js +211 -0
- package/dist/index.js +3 -0
- package/dist/main.js +40 -0
- package/dist/modules/index.js +5 -0
- package/dist/modules/shell/ids.js +7 -0
- package/dist/modules/shell/module.js +334 -0
- package/dist/modules/shell/output/line-log.js +76 -0
- package/dist/modules/shell/output/normalizer.js +161 -0
- package/dist/modules/shell/output/raw-ring.js +49 -0
- package/dist/modules/shell/output/screen.js +45 -0
- package/dist/modules/shell/port-probe.js +30 -0
- package/dist/modules/shell/pty.js +59 -0
- package/dist/modules/shell/registry.js +83 -0
- package/dist/modules/shell/shell.js +193 -0
- package/dist/modules/shell/wait.js +107 -0
- package/package.json +12 -5
- package/types/core/daemon.d.ts +36 -0
- package/types/core/errors.d.ts +4 -0
- package/types/core/logger.d.ts +11 -0
- package/types/core/module.d.ts +37 -0
- package/types/core/router.d.ts +11 -0
- package/types/core/server.d.ts +49 -0
- package/{src/index.ts → types/index.d.ts} +5 -5
- package/types/main.d.ts +2 -0
- package/types/modules/index.d.ts +7 -0
- package/types/modules/shell/ids.d.ts +1 -0
- package/types/modules/shell/module.d.ts +43 -0
- package/types/modules/shell/output/line-log.d.ts +36 -0
- package/types/modules/shell/output/normalizer.d.ts +32 -0
- package/types/modules/shell/output/raw-ring.d.ts +19 -0
- package/types/modules/shell/output/screen.d.ts +12 -0
- package/types/modules/shell/port-probe.d.ts +2 -0
- package/types/modules/shell/pty.d.ts +33 -0
- package/types/modules/shell/registry.d.ts +17 -0
- package/types/modules/shell/shell.d.ts +77 -0
- package/types/modules/shell/wait.d.ts +12 -0
- package/src/core/daemon.ts +0 -197
- package/src/core/errors.ts +0 -6
- package/src/core/logger.ts +0 -44
- package/src/core/module.ts +0 -45
- package/src/core/router.ts +0 -40
- package/src/core/server.ts +0 -223
- package/src/main.ts +0 -36
- package/src/modules/index.ts +0 -11
- package/src/modules/shell/ids.ts +0 -8
- package/src/modules/shell/module.ts +0 -323
- package/src/modules/shell/output/line-log.ts +0 -92
- package/src/modules/shell/output/normalizer.ts +0 -172
- package/src/modules/shell/output/raw-ring.ts +0 -46
- package/src/modules/shell/output/screen.ts +0 -44
- package/src/modules/shell/port-probe.ts +0 -30
- package/src/modules/shell/pty.ts +0 -98
- package/src/modules/shell/registry.ts +0 -86
- package/src/modules/shell/shell.ts +0 -252
- package/src/modules/shell/wait.ts +0 -91
|
@@ -0,0 +1,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,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>;
|
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
|
-
}
|