@opencode-cockpit/daemon 0.1.3 → 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,59 @@
|
|
|
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
|
+
|
|
6
|
+
/** Native PTY via `Bun.spawn({ terminal })` (Bun ≥ 1.3.5). The child leads its own session and group. */
|
|
7
|
+
export const bunPtyBackend = {
|
|
8
|
+
spawn(options) {
|
|
9
|
+
const proc = Bun.spawn([options.command, ...options.args], {
|
|
10
|
+
cwd: options.cwd,
|
|
11
|
+
env: options.env,
|
|
12
|
+
terminal: {
|
|
13
|
+
cols: options.cols,
|
|
14
|
+
rows: options.rows,
|
|
15
|
+
data(_terminal, chunk) {
|
|
16
|
+
// Bun reuses the buffer between callbacks.
|
|
17
|
+
options.onData(chunk.slice());
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
const pid = proc.pid;
|
|
22
|
+
const exited = proc.exited.then(() => ({
|
|
23
|
+
exitCode: proc.signalCode ? null : proc.exitCode,
|
|
24
|
+
signal: proc.signalCode ?? null
|
|
25
|
+
}));
|
|
26
|
+
return {
|
|
27
|
+
pid,
|
|
28
|
+
exited,
|
|
29
|
+
write: data => proc.terminal.write(data),
|
|
30
|
+
resize: (cols, rows) => proc.terminal.resize(cols, rows),
|
|
31
|
+
signal(signal) {
|
|
32
|
+
try {
|
|
33
|
+
process.kill(-pid, signal);
|
|
34
|
+
} catch {
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, signal);
|
|
37
|
+
} catch {
|
|
38
|
+
// already gone
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
groupAlive() {
|
|
43
|
+
try {
|
|
44
|
+
process.kill(-pid, 0);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
close() {
|
|
51
|
+
try {
|
|
52
|
+
proc.terminal.close();
|
|
53
|
+
} catch {
|
|
54
|
+
// already closed
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
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 class ProcessRegistry {
|
|
7
|
+
entries = new Map();
|
|
8
|
+
constructor(file, log) {
|
|
9
|
+
this.file = file;
|
|
10
|
+
this.log = log;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Kill leftovers from a previous daemon. Returns how many process groups were reaped. */
|
|
14
|
+
reap() {
|
|
15
|
+
let previous = [];
|
|
16
|
+
try {
|
|
17
|
+
previous = JSON.parse(readFileSync(this.file, "utf8"));
|
|
18
|
+
} catch {
|
|
19
|
+
// no registry yet, or unreadable: nothing to reap
|
|
20
|
+
}
|
|
21
|
+
let reaped = 0;
|
|
22
|
+
for (const entry of previous) {
|
|
23
|
+
const started = processStartTime(entry.pid);
|
|
24
|
+
if (!started || started !== entry.started) continue; // gone, or the pid now belongs to someone else
|
|
25
|
+
signalGroup(entry.pid, "SIGKILL");
|
|
26
|
+
reaped++;
|
|
27
|
+
this.log.warn("reaped orphaned shell", {
|
|
28
|
+
id: entry.id,
|
|
29
|
+
pid: entry.pid,
|
|
30
|
+
command: entry.command
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
this.entries.clear();
|
|
34
|
+
this.flush();
|
|
35
|
+
return reaped;
|
|
36
|
+
}
|
|
37
|
+
add(id, pid, command) {
|
|
38
|
+
const started = processStartTime(pid);
|
|
39
|
+
if (!started) return;
|
|
40
|
+
this.entries.set(id, {
|
|
41
|
+
id,
|
|
42
|
+
pid,
|
|
43
|
+
started,
|
|
44
|
+
command
|
|
45
|
+
});
|
|
46
|
+
this.flush();
|
|
47
|
+
}
|
|
48
|
+
remove(id) {
|
|
49
|
+
if (this.entries.delete(id)) this.flush();
|
|
50
|
+
}
|
|
51
|
+
flush() {
|
|
52
|
+
const tmp = `${this.file}.tmp`;
|
|
53
|
+
try {
|
|
54
|
+
writeFileSync(tmp, JSON.stringify([...this.entries.values()]), {
|
|
55
|
+
mode: 0o600
|
|
56
|
+
});
|
|
57
|
+
renameSync(tmp, this.file);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
this.log.warn("could not write process registry", {
|
|
60
|
+
err: String(err)
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function processStartTime(pid) {
|
|
66
|
+
const result = Bun.spawnSync(["ps", "-o", "lstart=", "-p", String(pid)], {
|
|
67
|
+
stdout: "pipe",
|
|
68
|
+
stderr: "ignore"
|
|
69
|
+
});
|
|
70
|
+
const text = result.stdout.toString().trim();
|
|
71
|
+
return result.exitCode === 0 && text ? text : undefined;
|
|
72
|
+
}
|
|
73
|
+
function signalGroup(pid, signal) {
|
|
74
|
+
try {
|
|
75
|
+
process.kill(-pid, signal);
|
|
76
|
+
} catch {
|
|
77
|
+
try {
|
|
78
|
+
process.kill(pid, signal);
|
|
79
|
+
} catch {
|
|
80
|
+
// already gone
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { LineLog } from "./output/line-log.js";
|
|
2
|
+
import { OutputNormalizer } from "./output/normalizer.js";
|
|
3
|
+
import { RawRing } from "./output/raw-ring.js";
|
|
4
|
+
import { Screen } from "./output/screen.js";
|
|
5
|
+
const ERROR_LINE = /\b(error|err!|failed|failure|fatal|panic|exception|traceback)\b|✗|✖/i;
|
|
6
|
+
/**
|
|
7
|
+
* One shell: a command, its PTY, and the three output views (ADR 0003).
|
|
8
|
+
* Survives restarts: `run` increments and output views continue, separated by a marker line.
|
|
9
|
+
*/
|
|
10
|
+
export class Shell {
|
|
11
|
+
status = "running";
|
|
12
|
+
run = 0;
|
|
13
|
+
/** First log line number belonging to the current run. */
|
|
14
|
+
runStartLine = 1;
|
|
15
|
+
lastOutputAt = Date.now();
|
|
16
|
+
listeners = new Set();
|
|
17
|
+
startedAt = 0;
|
|
18
|
+
stopRequested = false;
|
|
19
|
+
exitPromise = Promise.resolve();
|
|
20
|
+
constructor(spec, backend, limits) {
|
|
21
|
+
this.spec = spec;
|
|
22
|
+
this.backend = backend;
|
|
23
|
+
this.log = new LineLog(limits.logChars);
|
|
24
|
+
this.raw = new RawRing(limits.rawBytes);
|
|
25
|
+
this.screen = new Screen(spec.cols, spec.rows, limits.scrollback);
|
|
26
|
+
this.normalizer = this.createNormalizer();
|
|
27
|
+
}
|
|
28
|
+
get id() {
|
|
29
|
+
return this.spec.id;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The line currently being written (e.g. a prompt awaiting input); empty when none. */
|
|
33
|
+
get partialLine() {
|
|
34
|
+
return this.normalizer.partial;
|
|
35
|
+
}
|
|
36
|
+
get running() {
|
|
37
|
+
return this.status === "running";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Resolves when the current run has fully exited and been accounted for. */
|
|
41
|
+
get exited() {
|
|
42
|
+
return this.exitPromise;
|
|
43
|
+
}
|
|
44
|
+
subscribe(listener) {
|
|
45
|
+
this.listeners.add(listener);
|
|
46
|
+
return () => this.listeners.delete(listener);
|
|
47
|
+
}
|
|
48
|
+
start() {
|
|
49
|
+
if (this.running && this.pty) throw new Error("shell is already running");
|
|
50
|
+
this.run++;
|
|
51
|
+
if (this.run > 1) {
|
|
52
|
+
this.normalizer.flush();
|
|
53
|
+
this.log.append(`──── restart (run ${this.run}) ────`);
|
|
54
|
+
this.screen.reset();
|
|
55
|
+
}
|
|
56
|
+
this.runStartLine = this.log.lastLine + 1;
|
|
57
|
+
this.status = "running";
|
|
58
|
+
this.startedAt = Date.now();
|
|
59
|
+
this.lastOutputAt = this.startedAt;
|
|
60
|
+
this.endedAt = undefined;
|
|
61
|
+
this.exit = undefined;
|
|
62
|
+
this.error = undefined;
|
|
63
|
+
this.summary = undefined;
|
|
64
|
+
this.stopRequested = false;
|
|
65
|
+
try {
|
|
66
|
+
this.pty = this.backend.spawn({
|
|
67
|
+
command: this.spec.command,
|
|
68
|
+
args: this.spec.args,
|
|
69
|
+
cwd: this.spec.cwd,
|
|
70
|
+
env: this.spec.env,
|
|
71
|
+
cols: this.spec.cols,
|
|
72
|
+
rows: this.spec.rows,
|
|
73
|
+
onData: chunk => this.onData(chunk)
|
|
74
|
+
});
|
|
75
|
+
} catch (err) {
|
|
76
|
+
this.pty = undefined;
|
|
77
|
+
this.status = "failed";
|
|
78
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
79
|
+
this.endedAt = Date.now();
|
|
80
|
+
this.log.append(`[cockpit] failed to start: ${this.error}`);
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
const pty = this.pty;
|
|
84
|
+
this.exitPromise = pty.exited.then(exit => this.onExit(pty, exit));
|
|
85
|
+
if (this.spec.timeoutMs) {
|
|
86
|
+
this.timeout = setTimeout(() => void this.stop("SIGTERM", 3000), this.spec.timeoutMs);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
write(data) {
|
|
90
|
+
if (!this.running || !this.pty) throw new Error(`shell is ${this.status}`);
|
|
91
|
+
return this.pty.write(data);
|
|
92
|
+
}
|
|
93
|
+
resize(cols, rows) {
|
|
94
|
+
this.spec.cols = cols;
|
|
95
|
+
this.spec.rows = rows;
|
|
96
|
+
this.screen.resize(cols, rows);
|
|
97
|
+
if (this.running) this.pty?.resize(cols, rows);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Signal the group, escalate to SIGKILL after `graceMs`, and reap stragglers. */
|
|
101
|
+
async stop(signal = "SIGTERM", graceMs = 3000) {
|
|
102
|
+
const pty = this.pty;
|
|
103
|
+
if (!pty || !this.running) return;
|
|
104
|
+
this.stopRequested = true;
|
|
105
|
+
pty.signal(signal);
|
|
106
|
+
const exited = await Promise.race([this.exitPromise.then(() => true), Bun.sleep(graceMs).then(() => false)]);
|
|
107
|
+
if (!exited) {
|
|
108
|
+
pty.signal("SIGKILL");
|
|
109
|
+
await this.exitPromise;
|
|
110
|
+
}
|
|
111
|
+
if (pty.groupAlive()) pty.signal("SIGKILL");
|
|
112
|
+
}
|
|
113
|
+
async snapshot() {
|
|
114
|
+
return this.screen.snapshot();
|
|
115
|
+
}
|
|
116
|
+
info() {
|
|
117
|
+
const info = {
|
|
118
|
+
id: this.spec.id,
|
|
119
|
+
title: this.spec.title,
|
|
120
|
+
command: this.spec.command,
|
|
121
|
+
args: this.spec.args,
|
|
122
|
+
cwd: this.spec.cwd,
|
|
123
|
+
owner: this.spec.owner,
|
|
124
|
+
status: this.status,
|
|
125
|
+
run: this.run,
|
|
126
|
+
startedAt: this.startedAt,
|
|
127
|
+
cols: this.spec.cols,
|
|
128
|
+
rows: this.spec.rows,
|
|
129
|
+
lines: {
|
|
130
|
+
first: this.log.firstLine,
|
|
131
|
+
last: this.log.lastLine
|
|
132
|
+
},
|
|
133
|
+
bytes: this.raw.end
|
|
134
|
+
};
|
|
135
|
+
if (this.pty) info.pid = this.pty.pid;
|
|
136
|
+
if (this.exit?.exitCode != null) info.exitCode = this.exit.exitCode;
|
|
137
|
+
if (this.exit?.signal) info.signal = this.exit.signal;
|
|
138
|
+
if (this.error) info.error = this.error;
|
|
139
|
+
if (this.summary) info.summary = this.summary;
|
|
140
|
+
if (this.endedAt) info.endedAt = this.endedAt;
|
|
141
|
+
return info;
|
|
142
|
+
}
|
|
143
|
+
dispose() {
|
|
144
|
+
clearTimeout(this.timeout);
|
|
145
|
+
this.listeners.clear();
|
|
146
|
+
this.pty?.close();
|
|
147
|
+
this.screen.dispose();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Last error-looking line of the current run, else its last non-empty line. */
|
|
151
|
+
summarize() {
|
|
152
|
+
const from = Math.max(this.runStartLine, this.log.lastLine - 200 + 1);
|
|
153
|
+
let last;
|
|
154
|
+
for (let n = this.log.lastLine; n >= from; n--) {
|
|
155
|
+
const text = this.log.get(n)?.trim();
|
|
156
|
+
if (!text) continue;
|
|
157
|
+
last ??= text;
|
|
158
|
+
if (ERROR_LINE.test(text)) return text.slice(0, 300);
|
|
159
|
+
}
|
|
160
|
+
return last?.slice(0, 300);
|
|
161
|
+
}
|
|
162
|
+
createNormalizer() {
|
|
163
|
+
return new OutputNormalizer(text => {
|
|
164
|
+
const line = this.log.append(text);
|
|
165
|
+
for (const l of this.listeners) l.line?.(line);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
onData(chunk) {
|
|
169
|
+
this.lastOutputAt = Date.now();
|
|
170
|
+
const offset = this.raw.append(chunk);
|
|
171
|
+
this.screen.write(chunk);
|
|
172
|
+
this.normalizer.push(chunk);
|
|
173
|
+
const partial = this.normalizer.partial;
|
|
174
|
+
for (const l of this.listeners) {
|
|
175
|
+
l.data?.(offset, chunk);
|
|
176
|
+
if (partial) l.partial?.(partial);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
onExit(pty, exit) {
|
|
180
|
+
if (this.pty !== pty) return; // a newer run replaced this one
|
|
181
|
+
clearTimeout(this.timeout);
|
|
182
|
+
this.normalizer.flush();
|
|
183
|
+
this.exit = exit;
|
|
184
|
+
this.endedAt = Date.now();
|
|
185
|
+
this.status = this.stopRequested || exit.signal ? "killed" : "exited";
|
|
186
|
+
this.summary = this.summarize();
|
|
187
|
+
// Session leader is gone; make sure nothing it left behind keeps running.
|
|
188
|
+
if (pty.groupAlive()) pty.signal("SIGHUP");
|
|
189
|
+
pty.close();
|
|
190
|
+
const info = this.info();
|
|
191
|
+
for (const l of this.listeners) l.exit?.(info);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { probePort } from "./port-probe.js";
|
|
2
|
+
export const PORT_POLL_MS = 250;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Races every condition in `until` plus the timeout. Exit always ends a wait: once the process is
|
|
6
|
+
* gone no pattern, port or idle condition can still become true.
|
|
7
|
+
*/
|
|
8
|
+
export function waitFor(shell, params, compile) {
|
|
9
|
+
const {
|
|
10
|
+
until,
|
|
11
|
+
timeoutMs
|
|
12
|
+
} = params;
|
|
13
|
+
return new Promise(resolve => {
|
|
14
|
+
const cleanups = [];
|
|
15
|
+
let done = false;
|
|
16
|
+
const finish = outcome => {
|
|
17
|
+
if (done) return;
|
|
18
|
+
done = true;
|
|
19
|
+
for (const cleanup of cleanups) cleanup();
|
|
20
|
+
resolve(outcome);
|
|
21
|
+
};
|
|
22
|
+
const regex = until.pattern !== undefined ? compile(until.pattern, until.ignoreCase ?? false) : undefined;
|
|
23
|
+
|
|
24
|
+
// Lines already written count: "wait until ready" must succeed if it is ready already.
|
|
25
|
+
if (regex) {
|
|
26
|
+
const after = params.after ?? shell.runStartLine - 1;
|
|
27
|
+
const existing = shell.log.read({
|
|
28
|
+
after,
|
|
29
|
+
tail: 0,
|
|
30
|
+
limit: Number.MAX_SAFE_INTEGER,
|
|
31
|
+
grep: regex
|
|
32
|
+
});
|
|
33
|
+
const first = existing.lines[0];
|
|
34
|
+
if (first) return finish({
|
|
35
|
+
reason: "pattern",
|
|
36
|
+
match: first
|
|
37
|
+
});
|
|
38
|
+
// A prompt already on screen has no newline yet, so it is not in the log.
|
|
39
|
+
const partial = shell.partialLine;
|
|
40
|
+
if (partial && regex.test(partial)) {
|
|
41
|
+
return finish({
|
|
42
|
+
reason: "pattern",
|
|
43
|
+
match: {
|
|
44
|
+
n: shell.log.lastLine + 1,
|
|
45
|
+
text: partial
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!shell.running) return finish({
|
|
51
|
+
reason: "exit"
|
|
52
|
+
});
|
|
53
|
+
let idleTimer;
|
|
54
|
+
const armIdle = () => {
|
|
55
|
+
if (until.idleMs === undefined) return;
|
|
56
|
+
clearTimeout(idleTimer);
|
|
57
|
+
const remaining = Math.max(0, until.idleMs - (Date.now() - shell.lastOutputAt));
|
|
58
|
+
idleTimer = setTimeout(() => finish({
|
|
59
|
+
reason: "idle"
|
|
60
|
+
}), remaining);
|
|
61
|
+
};
|
|
62
|
+
armIdle();
|
|
63
|
+
cleanups.push(() => clearTimeout(idleTimer));
|
|
64
|
+
cleanups.push(shell.subscribe({
|
|
65
|
+
line(line) {
|
|
66
|
+
if (regex?.test(line.text)) finish({
|
|
67
|
+
reason: "pattern",
|
|
68
|
+
match: line
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
partial(text) {
|
|
72
|
+
if (regex?.test(text)) finish({
|
|
73
|
+
reason: "pattern",
|
|
74
|
+
match: {
|
|
75
|
+
n: shell.log.lastLine + 1,
|
|
76
|
+
text
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
data: () => armIdle(),
|
|
81
|
+
exit: () => finish({
|
|
82
|
+
reason: "exit"
|
|
83
|
+
})
|
|
84
|
+
}));
|
|
85
|
+
if (until.port !== undefined) {
|
|
86
|
+
const port = until.port;
|
|
87
|
+
const host = until.host ?? "127.0.0.1";
|
|
88
|
+
let polling = true;
|
|
89
|
+
const poll = async () => {
|
|
90
|
+
while (polling && !done) {
|
|
91
|
+
if (await probePort(port, host)) return finish({
|
|
92
|
+
reason: "port"
|
|
93
|
+
});
|
|
94
|
+
await Bun.sleep(PORT_POLL_MS);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
void poll();
|
|
98
|
+
cleanups.push(() => {
|
|
99
|
+
polling = false;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const timer = setTimeout(() => finish({
|
|
103
|
+
reason: "timeout"
|
|
104
|
+
}), timeoutMs);
|
|
105
|
+
cleanups.push(() => clearTimeout(timer));
|
|
106
|
+
});
|
|
107
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opencode-cockpit/daemon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "cockpitd: the process host behind opencode-cockpit (PTY shells, clean logs, wait conditions)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,12 +21,19 @@
|
|
|
21
21
|
"terminal"
|
|
22
22
|
],
|
|
23
23
|
"exports": {
|
|
24
|
-
".":
|
|
25
|
-
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./types/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./main": {
|
|
29
|
+
"types": "./types/main.d.ts",
|
|
30
|
+
"default": "./dist/main.js"
|
|
31
|
+
},
|
|
26
32
|
"./package.json": "./package.json"
|
|
27
33
|
},
|
|
28
34
|
"files": [
|
|
29
|
-
"
|
|
35
|
+
"dist",
|
|
36
|
+
"types",
|
|
30
37
|
"README.md",
|
|
31
38
|
"LICENSE"
|
|
32
39
|
],
|
|
@@ -34,7 +41,7 @@
|
|
|
34
41
|
"access": "public"
|
|
35
42
|
},
|
|
36
43
|
"dependencies": {
|
|
37
|
-
"@opencode-cockpit/protocol": "0.1.
|
|
44
|
+
"@opencode-cockpit/protocol": "0.1.5",
|
|
38
45
|
"@xterm/headless": "6.0.0"
|
|
39
46
|
},
|
|
40
47
|
"engines": {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type CockpitPaths } from "@opencode-cockpit/protocol";
|
|
2
|
+
import { type Level, type Logger } from "./logger.ts";
|
|
3
|
+
import type { Module } from "./module.ts";
|
|
4
|
+
export interface DaemonOptions {
|
|
5
|
+
paths: CockpitPaths;
|
|
6
|
+
modules: Module[];
|
|
7
|
+
/** Shut down after this long with no clients and no busy module. 0 disables. */
|
|
8
|
+
idleTimeoutMs?: number;
|
|
9
|
+
logLevel?: Level;
|
|
10
|
+
/** Log to the log file (default) or stderr. */
|
|
11
|
+
logToFile?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare const DAEMON_VERSION: string;
|
|
14
|
+
/** Build id of this daemon's own code; computed once, matches what clients compute for the entry. */
|
|
15
|
+
export declare const DAEMON_BUILD: string;
|
|
16
|
+
export declare class Daemon {
|
|
17
|
+
private readonly options;
|
|
18
|
+
readonly log: Logger;
|
|
19
|
+
private readonly router;
|
|
20
|
+
private readonly server;
|
|
21
|
+
private readonly startedAt;
|
|
22
|
+
private idleTimer;
|
|
23
|
+
private idleCheck;
|
|
24
|
+
private stopping;
|
|
25
|
+
private resolveStopped;
|
|
26
|
+
/** Resolves once the daemon has fully shut down. */
|
|
27
|
+
readonly stopped: Promise<void>;
|
|
28
|
+
constructor(options: DaemonOptions);
|
|
29
|
+
start(): Promise<void>;
|
|
30
|
+
stop(reason?: string): Promise<void>;
|
|
31
|
+
private busy;
|
|
32
|
+
private refreshIdle;
|
|
33
|
+
/** Refuse to start if a live daemon owns the socket; otherwise clear a stale one. */
|
|
34
|
+
private claimSocket;
|
|
35
|
+
private registerCore;
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type Level = "debug" | "info" | "warn" | "error";
|
|
2
|
+
export interface Logger {
|
|
3
|
+
debug(msg: string, fields?: Record<string, unknown>): void;
|
|
4
|
+
info(msg: string, fields?: Record<string, unknown>): void;
|
|
5
|
+
warn(msg: string, fields?: Record<string, unknown>): void;
|
|
6
|
+
error(msg: string, fields?: Record<string, unknown>): void;
|
|
7
|
+
child(scope: string): Logger;
|
|
8
|
+
}
|
|
9
|
+
/** JSON-lines logger. Writes synchronously so the last lines survive a crash. */
|
|
10
|
+
export declare function createLogger(file: string | undefined, level?: Level, scope?: string): Logger;
|
|
11
|
+
export declare const silentLogger: Logger;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { MethodName, Methods, ParsedParamsOf, ResultOf } from "@opencode-cockpit/protocol";
|
|
2
|
+
import type { Logger } from "./logger.ts";
|
|
3
|
+
/** A connected client as seen by modules. */
|
|
4
|
+
export interface Peer {
|
|
5
|
+
readonly id: number;
|
|
6
|
+
readonly name: string;
|
|
7
|
+
/** Send an event to this peer only, regardless of its subscriptions. */
|
|
8
|
+
send(topic: string, data: unknown): void;
|
|
9
|
+
/** Run when the peer disconnects. */
|
|
10
|
+
onClose(fn: () => void): void;
|
|
11
|
+
/** Topic patterns: exact (`shell.exited`), namespace (`shell.*`) or everything (`*`). */
|
|
12
|
+
readonly topics: Set<string>;
|
|
13
|
+
greet(name: string): void;
|
|
14
|
+
}
|
|
15
|
+
export interface CallContext {
|
|
16
|
+
peer: Peer;
|
|
17
|
+
}
|
|
18
|
+
export interface ModuleContext {
|
|
19
|
+
log: Logger;
|
|
20
|
+
/** Broadcast to every peer subscribed to `topic`. */
|
|
21
|
+
emit(topic: string, data: unknown): void;
|
|
22
|
+
}
|
|
23
|
+
type Handler<M extends MethodName> = (params: ParsedParamsOf<Methods, M>, call: CallContext) => Promise<ResultOf<Methods, M>> | ResultOf<Methods, M>;
|
|
24
|
+
/** Handlers for the methods under one namespace, typed from the protocol contract. */
|
|
25
|
+
export type MethodTable<NS extends string> = {
|
|
26
|
+
[M in MethodName as M extends `${NS}.${infer Rest}` ? Rest : never]: Handler<M>;
|
|
27
|
+
};
|
|
28
|
+
export interface Module<NS extends string = string> {
|
|
29
|
+
readonly name: NS;
|
|
30
|
+
/** Typed per namespace; erased to a plain record when modules are handled generically. */
|
|
31
|
+
readonly methods: string extends NS ? object : MethodTable<NS>;
|
|
32
|
+
start(ctx: ModuleContext): Promise<void>;
|
|
33
|
+
stop(): Promise<void>;
|
|
34
|
+
/** While true the daemon will not shut down for idleness. */
|
|
35
|
+
busy(): boolean;
|
|
36
|
+
}
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CallContext, Module } from "./module.ts";
|
|
2
|
+
type AnyHandler = (params: unknown, call: CallContext) => unknown;
|
|
3
|
+
/** Validates params against the protocol contract and dispatches to module handlers. */
|
|
4
|
+
export declare class Router {
|
|
5
|
+
private readonly handlers;
|
|
6
|
+
add(name: string, handler: AnyHandler): void;
|
|
7
|
+
addModule(module: Module): void;
|
|
8
|
+
has(name: string): boolean;
|
|
9
|
+
dispatch(name: string, params: unknown, call: CallContext): Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Socket } from "bun";
|
|
2
|
+
import type { Logger } from "./logger.ts";
|
|
3
|
+
import type { Peer } from "./module.ts";
|
|
4
|
+
import type { Router } from "./router.ts";
|
|
5
|
+
interface ConnState {
|
|
6
|
+
peer: PeerImpl;
|
|
7
|
+
}
|
|
8
|
+
declare class PeerImpl implements Peer {
|
|
9
|
+
readonly id: number;
|
|
10
|
+
private readonly socket;
|
|
11
|
+
private readonly log;
|
|
12
|
+
name: string;
|
|
13
|
+
greeted: boolean;
|
|
14
|
+
readonly topics: Set<string>;
|
|
15
|
+
private readonly closers;
|
|
16
|
+
private queue;
|
|
17
|
+
private queued;
|
|
18
|
+
closed: boolean;
|
|
19
|
+
constructor(id: number, socket: Socket<ConnState>, log: Logger);
|
|
20
|
+
greet(name: string): void;
|
|
21
|
+
onClose(fn: () => void): void;
|
|
22
|
+
send(topic: string, data: unknown): void;
|
|
23
|
+
subscribed(topic: string): boolean;
|
|
24
|
+
write(message: unknown): void;
|
|
25
|
+
drain(): void;
|
|
26
|
+
close(): void;
|
|
27
|
+
private enqueue;
|
|
28
|
+
}
|
|
29
|
+
export interface RpcServerHooks {
|
|
30
|
+
onConnect(count: number): void;
|
|
31
|
+
onDisconnect(count: number): void;
|
|
32
|
+
}
|
|
33
|
+
export declare class RpcServer {
|
|
34
|
+
private readonly router;
|
|
35
|
+
private readonly hooks;
|
|
36
|
+
private readonly log;
|
|
37
|
+
private listener;
|
|
38
|
+
private readonly peers;
|
|
39
|
+
private nextId;
|
|
40
|
+
constructor(router: Router, hooks: RpcServerHooks, log: Logger);
|
|
41
|
+
get clientCount(): number;
|
|
42
|
+
listen(path: string): void;
|
|
43
|
+
broadcast(topic: string, data: unknown): void;
|
|
44
|
+
stop(): void;
|
|
45
|
+
private drop;
|
|
46
|
+
private handleLine;
|
|
47
|
+
private invoke;
|
|
48
|
+
}
|
|
49
|
+
export type { PeerImpl };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { DAEMON_BUILD, DAEMON_VERSION, Daemon, type DaemonOptions } from "./core/daemon.ts"
|
|
2
|
-
export type { CallContext, MethodTable, Module, ModuleContext, Peer } from "./core/module.ts"
|
|
3
|
-
export { createModules, type ModuleOptions } from "./modules/index.ts"
|
|
4
|
-
export { ShellModule, type ShellModuleOptions } from "./modules/shell/module.ts"
|
|
5
|
-
export type { PtyBackend, PtyProcess, PtySpawnOptions } from "./modules/shell/pty.ts"
|
|
1
|
+
export { DAEMON_BUILD, DAEMON_VERSION, Daemon, type DaemonOptions } from "./core/daemon.ts";
|
|
2
|
+
export type { CallContext, MethodTable, Module, ModuleContext, Peer } from "./core/module.ts";
|
|
3
|
+
export { createModules, type ModuleOptions } from "./modules/index.ts";
|
|
4
|
+
export { ShellModule, type ShellModuleOptions } from "./modules/shell/module.ts";
|
|
5
|
+
export type { PtyBackend, PtyProcess, PtySpawnOptions } from "./modules/shell/pty.ts";
|
package/types/main.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Module } from "../core/module.ts";
|
|
2
|
+
import { type ShellModuleOptions } from "./shell/module.ts";
|
|
3
|
+
export interface ModuleOptions {
|
|
4
|
+
shell?: ShellModuleOptions;
|
|
5
|
+
}
|
|
6
|
+
/** Every capability the daemon hosts. Add new modules here. */
|
|
7
|
+
export declare function createModules(options?: ModuleOptions): Module[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function newShellId(): string;
|