@opencode-cockpit/daemon 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/core/daemon.js +193 -0
  2. package/dist/core/errors.js +4 -0
  3. package/dist/core/logger.js +45 -0
  4. package/dist/core/module.js +1 -0
  5. package/dist/core/router.js +33 -0
  6. package/dist/core/server.js +211 -0
  7. package/dist/index.js +3 -0
  8. package/dist/main.js +41 -0
  9. package/dist/modules/index.js +5 -0
  10. package/dist/modules/shell/ids.js +7 -0
  11. package/dist/modules/shell/methods.js +186 -0
  12. package/dist/modules/shell/module.js +259 -0
  13. package/dist/modules/shell/output/line-log.js +76 -0
  14. package/dist/modules/shell/output/normalizer.js +161 -0
  15. package/dist/modules/shell/output/palette.js +19 -0
  16. package/dist/modules/shell/output/raw-ring.js +49 -0
  17. package/dist/modules/shell/output/screen.js +102 -0
  18. package/dist/modules/shell/port-probe.js +30 -0
  19. package/dist/modules/shell/pty.js +59 -0
  20. package/dist/modules/shell/registry.js +83 -0
  21. package/dist/modules/shell/shell.js +242 -0
  22. package/dist/modules/shell/wait.js +107 -0
  23. package/dist/modules/shell/watch/presets.js +299 -0
  24. package/dist/modules/shell/watch/watcher.js +82 -0
  25. package/package.json +12 -5
  26. package/types/core/daemon.d.ts +36 -0
  27. package/types/core/errors.d.ts +4 -0
  28. package/types/core/logger.d.ts +11 -0
  29. package/types/core/module.d.ts +37 -0
  30. package/types/core/router.d.ts +11 -0
  31. package/types/core/server.d.ts +49 -0
  32. package/{src/index.ts → types/index.d.ts} +5 -5
  33. package/types/main.d.ts +2 -0
  34. package/types/modules/index.d.ts +7 -0
  35. package/types/modules/shell/ids.d.ts +1 -0
  36. package/types/modules/shell/methods.d.ts +7 -0
  37. package/types/modules/shell/module.d.ts +68 -0
  38. package/types/modules/shell/output/line-log.d.ts +36 -0
  39. package/types/modules/shell/output/normalizer.d.ts +32 -0
  40. package/types/modules/shell/output/palette.d.ts +2 -0
  41. package/types/modules/shell/output/raw-ring.d.ts +19 -0
  42. package/types/modules/shell/output/screen.d.ts +12 -0
  43. package/types/modules/shell/port-probe.d.ts +2 -0
  44. package/types/modules/shell/pty.d.ts +33 -0
  45. package/types/modules/shell/registry.d.ts +17 -0
  46. package/types/modules/shell/shell.d.ts +89 -0
  47. package/types/modules/shell/wait.d.ts +12 -0
  48. package/types/modules/shell/watch/presets.d.ts +17 -0
  49. package/types/modules/shell/watch/watcher.d.ts +43 -0
  50. package/src/core/daemon.ts +0 -197
  51. package/src/core/errors.ts +0 -6
  52. package/src/core/logger.ts +0 -44
  53. package/src/core/module.ts +0 -45
  54. package/src/core/router.ts +0 -40
  55. package/src/core/server.ts +0 -223
  56. package/src/main.ts +0 -36
  57. package/src/modules/index.ts +0 -11
  58. package/src/modules/shell/ids.ts +0 -8
  59. package/src/modules/shell/module.ts +0 -323
  60. package/src/modules/shell/output/line-log.ts +0 -92
  61. package/src/modules/shell/output/normalizer.ts +0 -172
  62. package/src/modules/shell/output/raw-ring.ts +0 -46
  63. package/src/modules/shell/output/screen.ts +0 -44
  64. package/src/modules/shell/port-probe.ts +0 -30
  65. package/src/modules/shell/pty.ts +0 -98
  66. package/src/modules/shell/registry.ts +0 -86
  67. package/src/modules/shell/shell.ts +0 -252
  68. package/src/modules/shell/wait.ts +0 -91
@@ -0,0 +1,49 @@
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 class RawRing {
6
+ chunks = [];
7
+ size = 0;
8
+ start = 0;
9
+ constructor(maxBytes = 1_000_000) {
10
+ this.maxBytes = maxBytes;
11
+ }
12
+
13
+ /** Absolute offset one past the last byte ever written. */
14
+ get end() {
15
+ return this.start + this.size;
16
+ }
17
+ append(chunk) {
18
+ const offset = this.end;
19
+ this.chunks.push(chunk);
20
+ this.size += chunk.byteLength;
21
+ while (this.size > this.maxBytes && this.chunks.length > 1) {
22
+ const dropped = this.chunks.shift();
23
+ this.size -= dropped.byteLength;
24
+ this.start += dropped.byteLength;
25
+ }
26
+ return offset;
27
+ }
28
+
29
+ /** Bytes from `offset` (clamped to what is retained) to the end. */
30
+ since(offset = 0) {
31
+ const from = Math.max(offset, this.start);
32
+ const out = new Uint8Array(this.end - from);
33
+ let cursor = this.start;
34
+ let written = 0;
35
+ for (const chunk of this.chunks) {
36
+ const chunkEnd = cursor + chunk.byteLength;
37
+ if (chunkEnd > from) {
38
+ const slice = chunk.subarray(Math.max(0, from - cursor));
39
+ out.set(slice, written);
40
+ written += slice.byteLength;
41
+ }
42
+ cursor = chunkEnd;
43
+ }
44
+ return {
45
+ offset: from,
46
+ bytes: out
47
+ };
48
+ }
49
+ }
@@ -0,0 +1,102 @@
1
+ import { Terminal } from "@xterm/headless";
2
+ import { paletteColor, rgbColor } from "./palette.js";
3
+
4
+ /** Full VT emulation of a shell's output: what a human would see right now (ADR 0003). */
5
+ export class Screen {
6
+ constructor(cols, rows, scrollback = 2000) {
7
+ this.term = new Terminal({
8
+ cols,
9
+ rows,
10
+ scrollback,
11
+ allowProposedApi: true
12
+ });
13
+ }
14
+ write(chunk) {
15
+ this.term.write(chunk);
16
+ }
17
+ resize(cols, rows) {
18
+ this.term.resize(cols, rows);
19
+ }
20
+ reset() {
21
+ this.term.reset();
22
+ }
23
+
24
+ /** Waits until every pending write has been parsed, then renders the viewport. */
25
+ async snapshot() {
26
+ await new Promise(resolve => this.term.write("", resolve));
27
+ const buffer = this.term.buffer.active;
28
+ const rows = [];
29
+ const styled = [];
30
+ for (let y = 0; y < this.term.rows; y++) {
31
+ const line = buffer.getLine(buffer.baseY + y);
32
+ rows.push(line?.translateToString(true) ?? "");
33
+ styled.push(line ? styleRuns(line, this.term.cols) : []);
34
+ }
35
+ while (rows.length > 0 && rows[rows.length - 1] === "") {
36
+ rows.pop();
37
+ styled.pop();
38
+ }
39
+ return {
40
+ text: rows.join("\n"),
41
+ cols: this.term.cols,
42
+ rows: this.term.rows,
43
+ cursor: {
44
+ x: buffer.cursorX,
45
+ y: buffer.cursorY
46
+ },
47
+ styled
48
+ };
49
+ }
50
+ dispose() {
51
+ this.term.dispose();
52
+ }
53
+ }
54
+ /** Groups a row's cells into runs of identical style, trimming the trailing blank. */
55
+ function styleRuns(line, cols) {
56
+ const runs = [];
57
+ let current;
58
+ for (let x = 0; x < cols; x++) {
59
+ const cell = line.getCell(x);
60
+ if (!cell || cell.getWidth() === 0) continue;
61
+ const chars = cell.getChars() || " ";
62
+ const style = cellStyle(cell);
63
+ if (current && sameStyle(current, style)) current.text += chars;else {
64
+ current = {
65
+ ...style,
66
+ text: chars
67
+ };
68
+ runs.push(current);
69
+ }
70
+ }
71
+ // Drop trailing spaces so a mostly empty row costs nothing to send or paint.
72
+ while (runs.length > 0) {
73
+ const last = runs[runs.length - 1];
74
+ last.text = last.text.replace(/\s+$/, "");
75
+ if (last.text.length > 0) break;
76
+ runs.pop();
77
+ }
78
+ return runs;
79
+ }
80
+ function cellStyle(cell) {
81
+ const inverse = cell.isInverse() !== 0;
82
+ const fg = colorOf(cell, inverse ? "bg" : "fg");
83
+ const bg = colorOf(cell, inverse ? "fg" : "bg");
84
+ const style = {};
85
+ if (fg) style.fg = fg;
86
+ if (bg) style.bg = bg;
87
+ if (cell.isBold()) style.bold = true;
88
+ if (cell.isDim()) style.dim = true;
89
+ if (cell.isItalic()) style.italic = true;
90
+ if (cell.isUnderline()) style.underline = true;
91
+ return style;
92
+ }
93
+ function colorOf(cell, which) {
94
+ const isDefault = which === "fg" ? cell.isFgDefault() : cell.isBgDefault();
95
+ if (isDefault) return undefined;
96
+ const rgb = which === "fg" ? cell.isFgRGB() : cell.isBgRGB();
97
+ const value = which === "fg" ? cell.getFgColor() : cell.getBgColor();
98
+ return rgb ? rgbColor(value) : paletteColor(value);
99
+ }
100
+ function sameStyle(a, b) {
101
+ return a.fg === b.fg && a.bg === b.bg && a.bold === b.bold && a.dim === b.dim && a.italic === b.italic && a.underline === b.underline;
102
+ }
@@ -0,0 +1,30 @@
1
+ /** Resolves true once something accepts TCP connections on host:port. */
2
+ export async function probePort(port, host = "127.0.0.1", timeoutMs = 500) {
3
+ return new Promise(resolve => {
4
+ let settled = false;
5
+ const finish = ok => {
6
+ if (settled) return;
7
+ settled = true;
8
+ clearTimeout(timer);
9
+ resolve(ok);
10
+ };
11
+ const timer = setTimeout(() => finish(false), timeoutMs);
12
+ Bun.connect({
13
+ hostname: host,
14
+ port,
15
+ socket: {
16
+ open(socket) {
17
+ socket.end();
18
+ finish(true);
19
+ },
20
+ data() {},
21
+ error() {
22
+ finish(false);
23
+ },
24
+ connectError() {
25
+ finish(false);
26
+ }
27
+ }
28
+ }).catch(() => finish(false));
29
+ });
30
+ }
@@ -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,242 @@
1
+ import { createWriteStream, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { LineLog } from "./output/line-log.js";
4
+ import { OutputNormalizer } from "./output/normalizer.js";
5
+ import { RawRing } from "./output/raw-ring.js";
6
+ import { Screen } from "./output/screen.js";
7
+ const ERROR_LINE = /\b(error|err!|failed|failure|fatal|panic|exception|traceback)\b|✗|✖/i;
8
+ /**
9
+ * One shell: a command, its PTY, and the three output views (ADR 0003).
10
+ * Survives restarts: `run` increments and output views continue, separated by a marker line.
11
+ */
12
+ export class Shell {
13
+ status = "running";
14
+ run = 0;
15
+ /** First log line number belonging to the current run. */
16
+ runStartLine = 1;
17
+ lastOutputAt = Date.now();
18
+ /** Health rule attached to this shell, if any (see watch/watcher.ts). */
19
+
20
+ /** Called when the watcher's reported status changes; never per line. */
21
+
22
+ listeners = new Set();
23
+ startedAt = 0;
24
+
25
+ /** Why the daemon stopped it, when it was not a user or agent request. */
26
+
27
+ stopRequested = false;
28
+ exitPromise = Promise.resolve();
29
+ constructor(spec, backend, limits) {
30
+ this.spec = spec;
31
+ this.backend = backend;
32
+ this.log = new LineLog(limits.logChars);
33
+ this.raw = new RawRing(limits.rawBytes);
34
+ this.screen = new Screen(spec.cols, spec.rows, limits.scrollback);
35
+ this.normalizer = this.createNormalizer();
36
+ }
37
+ get id() {
38
+ return this.spec.id;
39
+ }
40
+
41
+ /** The line currently being written (e.g. a prompt awaiting input); empty when none. */
42
+ get partialLine() {
43
+ return this.normalizer.partial;
44
+ }
45
+ get running() {
46
+ return this.status === "running";
47
+ }
48
+
49
+ /** Resolves when the current run has fully exited and been accounted for. */
50
+ get exited() {
51
+ return this.exitPromise;
52
+ }
53
+ subscribe(listener) {
54
+ this.listeners.add(listener);
55
+ return () => this.listeners.delete(listener);
56
+ }
57
+ start() {
58
+ if (this.running && this.pty) throw new Error("shell is already running");
59
+ this.run++;
60
+ if (this.run > 1) {
61
+ this.normalizer.flush();
62
+ this.log.append(`──── restart (run ${this.run}) ────`);
63
+ this.screen.reset();
64
+ }
65
+ this.runStartLine = this.log.lastLine + 1;
66
+ this.stoppedBecause = undefined;
67
+ if (this.spec.logFile && !this.logWriter) {
68
+ mkdirSync(dirname(this.spec.logFile), {
69
+ recursive: true
70
+ });
71
+ const file = createWriteStream(this.spec.logFile, {
72
+ flags: "a",
73
+ mode: 0o600
74
+ });
75
+ file.on("error", () => {
76
+ this.logWriter = undefined;
77
+ });
78
+ this.logWriter = {
79
+ write: text => file.write(text),
80
+ end: () => file.end()
81
+ };
82
+ }
83
+ this.status = "running";
84
+ this.startedAt = Date.now();
85
+ this.lastOutputAt = this.startedAt;
86
+ this.endedAt = undefined;
87
+ this.exit = undefined;
88
+ this.error = undefined;
89
+ this.summary = undefined;
90
+ this.stopRequested = false;
91
+ try {
92
+ this.pty = this.backend.spawn({
93
+ command: this.spec.command,
94
+ args: this.spec.args,
95
+ cwd: this.spec.cwd,
96
+ env: this.spec.env,
97
+ cols: this.spec.cols,
98
+ rows: this.spec.rows,
99
+ onData: chunk => this.onData(chunk)
100
+ });
101
+ } catch (err) {
102
+ this.pty = undefined;
103
+ this.status = "failed";
104
+ this.error = err instanceof Error ? err.message : String(err);
105
+ this.endedAt = Date.now();
106
+ this.log.append(`[cockpit] failed to start: ${this.error}`);
107
+ throw err;
108
+ }
109
+ const pty = this.pty;
110
+ this.exitPromise = pty.exited.then(exit => this.onExit(pty, exit));
111
+ if (this.spec.timeoutMs) {
112
+ this.timeout = setTimeout(() => {
113
+ this.stoppedBecause = `reached its ${Math.round((this.spec.timeoutMs ?? 0) / 1000)}s time limit`;
114
+ void this.stop("SIGTERM", 3000);
115
+ }, this.spec.timeoutMs);
116
+ }
117
+ if (this.spec.idleTimeoutMs) {
118
+ const idleMs = this.spec.idleTimeoutMs;
119
+ this.idleTimer = setInterval(() => {
120
+ if (!this.running || Date.now() - this.lastOutputAt < idleMs) return;
121
+ this.stoppedBecause = `produced no output for ${Math.round(idleMs / 1000)}s`;
122
+ void this.stop("SIGTERM", 3000);
123
+ }, Math.max(500, Math.floor(idleMs / 4)));
124
+ }
125
+ }
126
+ write(data) {
127
+ if (!this.running || !this.pty) throw new Error(`shell is ${this.status}`);
128
+ return this.pty.write(data);
129
+ }
130
+ resize(cols, rows) {
131
+ this.spec.cols = cols;
132
+ this.spec.rows = rows;
133
+ this.screen.resize(cols, rows);
134
+ if (this.running) this.pty?.resize(cols, rows);
135
+ }
136
+
137
+ /** Signal the group, escalate to SIGKILL after `graceMs`, and reap stragglers. */
138
+ async stop(signal = "SIGTERM", graceMs = 3000) {
139
+ const pty = this.pty;
140
+ if (!pty || !this.running) return;
141
+ this.stopRequested = true;
142
+ pty.signal(signal);
143
+ const exited = await Promise.race([this.exitPromise.then(() => true), Bun.sleep(graceMs).then(() => false)]);
144
+ if (!exited) {
145
+ pty.signal("SIGKILL");
146
+ await this.exitPromise;
147
+ }
148
+ if (pty.groupAlive()) pty.signal("SIGKILL");
149
+ }
150
+ async snapshot() {
151
+ return this.screen.snapshot();
152
+ }
153
+ info() {
154
+ const info = {
155
+ id: this.spec.id,
156
+ title: this.spec.title,
157
+ command: this.spec.command,
158
+ args: this.spec.args,
159
+ cwd: this.spec.cwd,
160
+ owner: this.spec.owner,
161
+ status: this.status,
162
+ run: this.run,
163
+ startedAt: this.startedAt,
164
+ cols: this.spec.cols,
165
+ rows: this.spec.rows,
166
+ lines: {
167
+ first: this.log.firstLine,
168
+ last: this.log.lastLine
169
+ },
170
+ bytes: this.raw.end
171
+ };
172
+ if (this.pty) info.pid = this.pty.pid;
173
+ if (this.exit?.exitCode != null) info.exitCode = this.exit.exitCode;
174
+ if (this.exit?.signal) info.signal = this.exit.signal;
175
+ if (this.error) info.error = this.error;
176
+ if (this.summary) info.summary = this.summary;
177
+ if (this.spec.logFile) info.logFile = this.spec.logFile;
178
+ if (this.watcher) info.watch = this.watcher.state();
179
+ if (this.endedAt) info.endedAt = this.endedAt;
180
+ return info;
181
+ }
182
+ dispose() {
183
+ clearTimeout(this.timeout);
184
+ clearInterval(this.idleTimer);
185
+ this.logWriter?.end();
186
+ this.listeners.clear();
187
+ this.pty?.close();
188
+ this.screen.dispose();
189
+ }
190
+
191
+ /** Last error-looking line of the current run, else its last non-empty line. */
192
+ summarize() {
193
+ const from = Math.max(this.runStartLine, this.log.lastLine - 200 + 1);
194
+ let last;
195
+ for (let n = this.log.lastLine; n >= from; n--) {
196
+ const text = this.log.get(n)?.trim();
197
+ if (!text) continue;
198
+ last ??= text;
199
+ if (ERROR_LINE.test(text)) return text.slice(0, 300);
200
+ }
201
+ return last?.slice(0, 300);
202
+ }
203
+ createNormalizer() {
204
+ return new OutputNormalizer(text => {
205
+ const line = this.log.append(text);
206
+ this.logWriter?.write(`${text}\n`);
207
+ const change = this.watcher?.line(text);
208
+ if (change) this.onWatchChange?.(change);
209
+ for (const l of this.listeners) l.line?.(line);
210
+ });
211
+ }
212
+ onData(chunk) {
213
+ this.lastOutputAt = Date.now();
214
+ const offset = this.raw.append(chunk);
215
+ this.screen.write(chunk);
216
+ this.normalizer.push(chunk);
217
+ const partial = this.normalizer.partial;
218
+ for (const l of this.listeners) {
219
+ l.data?.(offset, chunk);
220
+ if (partial) l.partial?.(partial);
221
+ }
222
+ }
223
+ onExit(pty, exit) {
224
+ if (this.pty !== pty) return; // a newer run replaced this one
225
+ clearTimeout(this.timeout);
226
+ clearInterval(this.idleTimer);
227
+ this.logWriter?.end();
228
+ this.logWriter = undefined;
229
+ this.normalizer.flush();
230
+ this.exit = exit;
231
+ this.endedAt = Date.now();
232
+ this.status = this.stopRequested || exit.signal ? "killed" : "exited";
233
+ this.summary = this.stoppedBecause ? `stopped: ${this.stoppedBecause}` : this.summarize();
234
+ const ended = this.watcher?.exited(exit.exitCode ?? undefined, exit.signal ?? undefined);
235
+ if (ended) this.onWatchChange?.(ended);
236
+ // Session leader is gone; make sure nothing it left behind keeps running.
237
+ if (pty.groupAlive()) pty.signal("SIGHUP");
238
+ pty.close();
239
+ const info = this.info();
240
+ for (const l of this.listeners) l.exit?.(info);
241
+ }
242
+ }
@@ -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
+ }