@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.
Files changed (60) hide show
  1. package/dist/core/daemon.js +193 -0
  2. package/dist/core/errors.js +4 -0
  3. package/dist/core/logger.js +45 -0
  4. package/dist/core/module.js +1 -0
  5. package/dist/core/router.js +33 -0
  6. package/dist/core/server.js +211 -0
  7. package/dist/index.js +3 -0
  8. package/dist/main.js +40 -0
  9. package/dist/modules/index.js +5 -0
  10. package/dist/modules/shell/ids.js +7 -0
  11. package/dist/modules/shell/module.js +334 -0
  12. package/dist/modules/shell/output/line-log.js +76 -0
  13. package/dist/modules/shell/output/normalizer.js +161 -0
  14. package/dist/modules/shell/output/raw-ring.js +49 -0
  15. package/dist/modules/shell/output/screen.js +45 -0
  16. package/dist/modules/shell/port-probe.js +30 -0
  17. package/dist/modules/shell/pty.js +59 -0
  18. package/dist/modules/shell/registry.js +83 -0
  19. package/dist/modules/shell/shell.js +193 -0
  20. package/dist/modules/shell/wait.js +107 -0
  21. package/package.json +12 -5
  22. package/types/core/daemon.d.ts +36 -0
  23. package/types/core/errors.d.ts +4 -0
  24. package/types/core/logger.d.ts +11 -0
  25. package/types/core/module.d.ts +37 -0
  26. package/types/core/router.d.ts +11 -0
  27. package/types/core/server.d.ts +49 -0
  28. package/{src/index.ts → types/index.d.ts} +5 -5
  29. package/types/main.d.ts +2 -0
  30. package/types/modules/index.d.ts +7 -0
  31. package/types/modules/shell/ids.d.ts +1 -0
  32. package/types/modules/shell/module.d.ts +43 -0
  33. package/types/modules/shell/output/line-log.d.ts +36 -0
  34. package/types/modules/shell/output/normalizer.d.ts +32 -0
  35. package/types/modules/shell/output/raw-ring.d.ts +19 -0
  36. package/types/modules/shell/output/screen.d.ts +12 -0
  37. package/types/modules/shell/port-probe.d.ts +2 -0
  38. package/types/modules/shell/pty.d.ts +33 -0
  39. package/types/modules/shell/registry.d.ts +17 -0
  40. package/types/modules/shell/shell.d.ts +77 -0
  41. package/types/modules/shell/wait.d.ts +12 -0
  42. package/src/core/daemon.ts +0 -197
  43. package/src/core/errors.ts +0 -6
  44. package/src/core/logger.ts +0 -44
  45. package/src/core/module.ts +0 -45
  46. package/src/core/router.ts +0 -40
  47. package/src/core/server.ts +0 -223
  48. package/src/main.ts +0 -36
  49. package/src/modules/index.ts +0 -11
  50. package/src/modules/shell/ids.ts +0 -8
  51. package/src/modules/shell/module.ts +0 -323
  52. package/src/modules/shell/output/line-log.ts +0 -92
  53. package/src/modules/shell/output/normalizer.ts +0 -172
  54. package/src/modules/shell/output/raw-ring.ts +0 -46
  55. package/src/modules/shell/output/screen.ts +0 -44
  56. package/src/modules/shell/port-probe.ts +0 -30
  57. package/src/modules/shell/pty.ts +0 -98
  58. package/src/modules/shell/registry.ts +0 -86
  59. package/src/modules/shell/shell.ts +0 -252
  60. package/src/modules/shell/wait.ts +0 -91
@@ -0,0 +1,334 @@
1
+ import { invalidParams, invalidState, notFound } from "../../core/errors.js";
2
+ import { silentLogger } from "../../core/logger.js";
3
+ import { newShellId } from "./ids.js";
4
+ import { bunPtyBackend } from "./pty.js";
5
+ import { ProcessRegistry } from "./registry.js";
6
+ import { Shell } from "./shell.js";
7
+ import { waitFor } from "./wait.js";
8
+ const DEFAULT_LIMITS = {
9
+ logChars: 4_000_000,
10
+ rawBytes: 1_000_000,
11
+ scrollback: 2000
12
+ };
13
+ export class ShellModule {
14
+ name = "shell";
15
+ shells = new Map();
16
+ attachments = new Map(); // `${peer.id}:${shellId}` → detach
17
+
18
+ log = silentLogger;
19
+ emit = () => {};
20
+ constructor(options = {}) {
21
+ this.options = options;
22
+ this.backend = options.backend ?? bunPtyBackend;
23
+ this.limits = {
24
+ ...DEFAULT_LIMITS,
25
+ ...options.limits
26
+ };
27
+ }
28
+ async start(ctx) {
29
+ this.log = ctx.log;
30
+ this.emit = ctx.emit;
31
+ if (this.options.registryFile) {
32
+ this.registry = new ProcessRegistry(this.options.registryFile, ctx.log);
33
+ const reaped = this.registry.reap();
34
+ if (reaped > 0) ctx.log.warn("cleaned up shells left by a previous daemon", {
35
+ reaped
36
+ });
37
+ }
38
+ }
39
+ async stop() {
40
+ await Promise.all([...this.shells.values()].map(s => s.stop("SIGTERM", 2000).catch(() => {})));
41
+ for (const detach of this.attachments.values()) detach();
42
+ for (const shell of this.shells.values()) shell.dispose();
43
+ this.attachments.clear();
44
+ this.shells.clear();
45
+ }
46
+ busy() {
47
+ for (const shell of this.shells.values()) if (shell.running) return true;
48
+ return false;
49
+ }
50
+ methods = {
51
+ start: params => this.startShell(params),
52
+ list: params => {
53
+ const owner = params.owner;
54
+ return [...this.shells.values()].filter(s => params.includeExited || s.running).filter(s => !owner?.project || s.spec.owner.project === owner.project).filter(s => !owner?.session || s.spec.owner.session === owner.session).map(s => s.info());
55
+ },
56
+ get: ({
57
+ id
58
+ }) => this.require(id).info(),
59
+ read: ({
60
+ id,
61
+ after,
62
+ tail,
63
+ limit,
64
+ grep,
65
+ ignoreCase
66
+ }) => {
67
+ const shell = this.require(id);
68
+ const page = shell.log.read({
69
+ after,
70
+ tail,
71
+ limit,
72
+ grep: grep === undefined ? undefined : compilePattern(grep, ignoreCase)
73
+ });
74
+ return {
75
+ ...page,
76
+ status: shell.status
77
+ };
78
+ },
79
+ screen: ({
80
+ id
81
+ }) => this.require(id).snapshot(),
82
+ write: ({
83
+ id,
84
+ data
85
+ }) => {
86
+ const shell = this.require(id);
87
+ if (!shell.running) throw invalidState(`shell ${id} is ${shell.status}`);
88
+ return {
89
+ bytes: shell.write(data)
90
+ };
91
+ },
92
+ resize: ({
93
+ id,
94
+ cols,
95
+ rows
96
+ }) => {
97
+ this.require(id).resize(cols, rows);
98
+ return {};
99
+ },
100
+ wait: async params => {
101
+ const shell = this.require(params.id);
102
+ const outcome = await waitFor(shell, params, compilePattern);
103
+ return {
104
+ ...outcome,
105
+ info: shell.info()
106
+ };
107
+ },
108
+ stop: async ({
109
+ id,
110
+ signal,
111
+ graceMs
112
+ }) => {
113
+ const shell = this.require(id);
114
+ await shell.stop(signal, graceMs);
115
+ await shell.exited;
116
+ return shell.info();
117
+ },
118
+ restart: async ({
119
+ id
120
+ }) => {
121
+ const shell = this.require(id);
122
+ if (shell.running) {
123
+ await shell.stop("SIGTERM", 3000);
124
+ await shell.exited;
125
+ }
126
+ this.spawn(shell);
127
+ return shell.info();
128
+ },
129
+ remove: async ({
130
+ id
131
+ }) => {
132
+ const shell = this.require(id);
133
+ if (shell.running) {
134
+ await shell.stop("SIGTERM", 3000);
135
+ await shell.exited;
136
+ }
137
+ this.forget(shell);
138
+ return {};
139
+ },
140
+ attach: ({
141
+ id,
142
+ fromOffset
143
+ }, {
144
+ peer
145
+ }) => {
146
+ const shell = this.require(id);
147
+ this.detach(peer, id);
148
+ const replay = shell.raw.since(fromOffset ?? 0);
149
+ this.attachStream(peer, shell);
150
+ return {
151
+ offset: replay.offset,
152
+ replay: Buffer.from(replay.bytes).toString("base64")
153
+ };
154
+ },
155
+ clear: ({
156
+ owner,
157
+ finishedBeforeMs
158
+ }) => {
159
+ const cutoff = Date.now() - (finishedBeforeMs ?? 0);
160
+ const removed = [];
161
+ for (const shell of [...this.shells.values()]) {
162
+ if (shell.running) continue;
163
+ const info = shell.info();
164
+ if (owner?.project && info.owner.project !== owner.project) continue;
165
+ if (owner?.session && info.owner.session !== owner.session) continue;
166
+ if ((info.endedAt ?? 0) > cutoff) continue;
167
+ this.forget(shell);
168
+ removed.push(info.id);
169
+ }
170
+ return {
171
+ removed
172
+ };
173
+ },
174
+ detach: ({
175
+ id
176
+ }, {
177
+ peer
178
+ }) => {
179
+ this.detach(peer, id);
180
+ return {};
181
+ }
182
+ };
183
+ startShell(params) {
184
+ if (params.reuse) {
185
+ const previous = this.findReusable(params);
186
+ if (previous) {
187
+ Object.assign(previous.spec, {
188
+ env: this.environment(params.env),
189
+ title: params.title ?? previous.spec.title,
190
+ timeoutMs: params.timeoutMs,
191
+ owner: params.owner
192
+ });
193
+ this.spawn(previous);
194
+ return previous.info();
195
+ }
196
+ }
197
+ const shell = new Shell({
198
+ id: this.uniqueId(),
199
+ command: params.command,
200
+ args: params.args,
201
+ cwd: params.cwd,
202
+ env: this.environment(params.env),
203
+ title: params.title ?? [params.command, ...params.args].join(" ").slice(0, 200),
204
+ cols: params.cols,
205
+ rows: params.rows,
206
+ owner: params.owner,
207
+ timeoutMs: params.timeoutMs
208
+ }, this.backend, this.limits);
209
+ this.shells.set(shell.id, shell);
210
+ shell.subscribe({
211
+ exit: info => this.onExit(info)
212
+ });
213
+ this.spawn(shell);
214
+ this.pruneFinished();
215
+ return shell.info();
216
+ }
217
+ findReusable(params) {
218
+ const args = JSON.stringify(params.args);
219
+ let match;
220
+ for (const shell of this.shells.values()) {
221
+ const spec = shell.spec;
222
+ if (!shell.running && spec.command === params.command && JSON.stringify(spec.args) === args && spec.cwd === params.cwd && spec.owner.project === params.owner.project && spec.owner.session === params.owner.session && (!match || shell.info().startedAt > match.info().startedAt)) {
223
+ match = shell;
224
+ }
225
+ }
226
+ return match;
227
+ }
228
+ spawn(shell) {
229
+ try {
230
+ shell.start();
231
+ } catch (err) {
232
+ const info = shell.info();
233
+ this.log.warn("spawn failed", {
234
+ id: shell.id,
235
+ command: shell.spec.command,
236
+ err: String(err)
237
+ });
238
+ this.emit("shell.exited", info);
239
+ return;
240
+ }
241
+ const pid = shell.info().pid;
242
+ if (pid) this.registry?.add(shell.id, pid, shell.spec.command);
243
+ this.log.info("shell started", {
244
+ id: shell.id,
245
+ command: shell.spec.command,
246
+ pid
247
+ });
248
+ this.emit("shell.started", shell.info());
249
+ }
250
+ onExit(info) {
251
+ this.registry?.remove(info.id);
252
+ this.log.info("shell ended", {
253
+ id: info.id,
254
+ status: info.status,
255
+ exitCode: info.exitCode,
256
+ signal: info.signal
257
+ });
258
+ this.emit("shell.exited", info);
259
+ }
260
+ attachStream(peer, shell) {
261
+ const key = `${peer.id}:${shell.id}`;
262
+ const flushMs = this.options.outputFlushMs ?? 16;
263
+ let pending = [];
264
+ let pendingOffset = 0;
265
+ let timer;
266
+ const flush = () => {
267
+ timer = undefined;
268
+ if (pending.length === 0) return;
269
+ const data = Buffer.concat(pending).toString("base64");
270
+ peer.send("shell.output", {
271
+ id: shell.id,
272
+ offset: pendingOffset,
273
+ data
274
+ });
275
+ pending = [];
276
+ };
277
+ const unsubscribe = shell.subscribe({
278
+ data(offset, chunk) {
279
+ if (pending.length === 0) pendingOffset = offset;
280
+ pending.push(chunk);
281
+ timer ??= setTimeout(flush, flushMs);
282
+ }
283
+ });
284
+ const detach = () => {
285
+ clearTimeout(timer);
286
+ flush();
287
+ unsubscribe();
288
+ this.attachments.delete(key);
289
+ };
290
+ this.attachments.set(key, detach);
291
+ peer.onClose(detach);
292
+ }
293
+ detach(peer, id) {
294
+ this.attachments.get(`${peer.id}:${id}`)?.();
295
+ }
296
+ forget(shell) {
297
+ for (const [key, detach] of this.attachments) if (key.endsWith(`:${shell.id}`)) detach();
298
+ shell.dispose();
299
+ this.shells.delete(shell.id);
300
+ this.emit("shell.removed", {
301
+ id: shell.id
302
+ });
303
+ }
304
+ pruneFinished() {
305
+ const max = this.options.maxFinished ?? 50;
306
+ const finished = [...this.shells.values()].filter(s => !s.running);
307
+ for (const shell of finished.slice(0, Math.max(0, finished.length - max))) this.forget(shell);
308
+ }
309
+ require(id) {
310
+ const shell = this.shells.get(id);
311
+ if (!shell) throw notFound(`shell ${id}`);
312
+ return shell;
313
+ }
314
+ uniqueId() {
315
+ let id = newShellId();
316
+ while (this.shells.has(id)) id = newShellId();
317
+ return id;
318
+ }
319
+ environment(extra) {
320
+ const env = {};
321
+ for (const [k, v] of Object.entries(this.options.baseEnv ?? process.env)) if (v !== undefined) env[k] = v;
322
+ env.TERM ??= "xterm-256color";
323
+ env.COLORTERM ??= "truecolor";
324
+ Object.assign(env, extra);
325
+ return env;
326
+ }
327
+ }
328
+ export function compilePattern(pattern, ignoreCase) {
329
+ try {
330
+ return new RegExp(pattern, ignoreCase ? "i" : "");
331
+ } catch (err) {
332
+ throw invalidParams(`invalid pattern: ${err instanceof Error ? err.message : String(err)}`);
333
+ }
334
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Committed lines with monotonic numbering (1-based) and a character budget.
3
+ * Eviction drops the oldest lines and advances `firstLine`; numbers are never reused, so cursors
4
+ * held by clients stay meaningful after eviction.
5
+ */
6
+ export class LineLog {
7
+ lines = [];
8
+ head = 0;
9
+ chars = 0;
10
+ first = 1;
11
+ constructor(maxChars = 4_000_000) {
12
+ this.maxChars = maxChars;
13
+ }
14
+
15
+ /** Number of the oldest retained line (equals `lastLine + 1` when empty). */
16
+ get firstLine() {
17
+ return this.first;
18
+ }
19
+
20
+ /** Number of the newest line, or `firstLine - 1` when empty. */
21
+ get lastLine() {
22
+ return this.first + (this.lines.length - this.head) - 1;
23
+ }
24
+ append(text) {
25
+ this.lines.push(text);
26
+ this.chars += text.length + 1;
27
+ const line = {
28
+ n: this.lastLine,
29
+ text
30
+ };
31
+ this.evict();
32
+ return line;
33
+ }
34
+ get(n) {
35
+ if (n < this.first || n > this.lastLine) return undefined;
36
+ return this.lines[this.head + (n - this.first)];
37
+ }
38
+ read(query) {
39
+ const last = this.lastLine;
40
+ const requestedStart = query.after !== undefined ? query.after + 1 : Math.max(1, last - query.tail + 1);
41
+ const start = Math.max(requestedStart, this.first);
42
+ const truncated = requestedStart < this.first && last >= requestedStart;
43
+ const out = [];
44
+ let n = start;
45
+ for (; n <= last && out.length < query.limit; n++) {
46
+ const text = this.lines[this.head + (n - this.first)];
47
+ if (query.grep && !query.grep.test(text)) continue;
48
+ out.push({
49
+ n,
50
+ text
51
+ });
52
+ }
53
+ const scannedTo = n - 1;
54
+ return {
55
+ lines: out,
56
+ firstLine: this.first,
57
+ lastLine: last,
58
+ nextCursor: Math.max(scannedTo, start - 1),
59
+ truncated,
60
+ hasMore: scannedTo < last
61
+ };
62
+ }
63
+ evict() {
64
+ while (this.chars > this.maxChars && this.head < this.lines.length - 1) {
65
+ const dropped = this.lines[this.head];
66
+ this.chars -= dropped.length + 1;
67
+ this.head++;
68
+ this.first++;
69
+ }
70
+ // Compact occasionally so the backing array does not grow without bound.
71
+ if (this.head > 4096 && this.head * 2 > this.lines.length) {
72
+ this.lines = this.lines.slice(this.head);
73
+ this.head = 0;
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,161 @@
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
+
10
+ const ESC = 0x1b;
11
+ const BEL = 0x07;
12
+ const TAB_WIDTH = 8;
13
+ var State = /*#__PURE__*/function (State) {
14
+ State[State["Ground"] = 0] = "Ground";
15
+ State[State["Escape"] = 1] = "Escape";
16
+ State[State["EscapeIntermediate"] = 2] = "EscapeIntermediate";
17
+ State[State["Csi"] = 3] = "Csi";
18
+ State[State["Osc"] = 4] = "Osc";
19
+ State[State["OscEscape"] = 5] = "OscEscape";
20
+ return State;
21
+ }(State || {});
22
+ export class OutputNormalizer {
23
+ decoder = new TextDecoder();
24
+ state = State.Ground;
25
+ csiParams = "";
26
+ cells = [];
27
+ col = 0;
28
+ constructor(commit, options = {}) {
29
+ this.commit = commit;
30
+ this.maxLineLength = options.maxLineLength ?? 10_000;
31
+ }
32
+
33
+ /** Text of the line currently being written (not yet terminated by a newline). */
34
+ get partial() {
35
+ return this.render();
36
+ }
37
+ push(chunk) {
38
+ const text = typeof chunk === "string" ? chunk : this.decoder.decode(chunk, {
39
+ stream: true
40
+ });
41
+ for (const char of text) this.step(char);
42
+ }
43
+
44
+ /** Commit the partial line, if any. Call when the stream ends. */
45
+ flush() {
46
+ const tail = this.render();
47
+ if (tail.length > 0) this.commit(tail);
48
+ this.cells = [];
49
+ this.col = 0;
50
+ }
51
+ step(char) {
52
+ const code = char.codePointAt(0) ?? 0;
53
+ switch (this.state) {
54
+ case State.Ground:
55
+ this.ground(char, code);
56
+ return;
57
+ case State.Escape:
58
+ if (code === 0x5b) {
59
+ this.state = State.Csi;
60
+ this.csiParams = "";
61
+ } else if (code === 0x5d) {
62
+ this.state = State.Osc;
63
+ } else if (code >= 0x20 && code <= 0x2f) {
64
+ this.state = State.EscapeIntermediate;
65
+ } else {
66
+ this.state = State.Ground;
67
+ }
68
+ return;
69
+ case State.EscapeIntermediate:
70
+ if (code >= 0x30 && code <= 0x7e) this.state = State.Ground;
71
+ return;
72
+ case State.Csi:
73
+ if (code >= 0x40 && code <= 0x7e) {
74
+ this.csi(char, this.csiParams);
75
+ this.state = State.Ground;
76
+ } else {
77
+ this.csiParams += char;
78
+ }
79
+ return;
80
+ case State.Osc:
81
+ if (code === BEL) this.state = State.Ground;else if (code === ESC) this.state = State.OscEscape;
82
+ return;
83
+ case State.OscEscape:
84
+ this.state = code === 0x5c ? State.Ground : State.Osc;
85
+ return;
86
+ }
87
+ }
88
+ ground(char, code) {
89
+ if (code === ESC) {
90
+ this.state = State.Escape;
91
+ return;
92
+ }
93
+ if (char === "\n") {
94
+ this.commit(this.render());
95
+ this.cells = [];
96
+ this.col = 0;
97
+ return;
98
+ }
99
+ if (char === "\r") {
100
+ this.col = 0;
101
+ return;
102
+ }
103
+ if (char === "\b") {
104
+ this.col = Math.max(0, this.col - 1);
105
+ return;
106
+ }
107
+ if (char === "\t") {
108
+ const next = (Math.floor(this.col / TAB_WIDTH) + 1) * TAB_WIDTH;
109
+ while (this.col < next) this.put(" ");
110
+ return;
111
+ }
112
+ if (code < 0x20 || code === 0x7f) return;
113
+ this.put(char);
114
+ }
115
+ csi(final, raw) {
116
+ const params = raw.replace(/^[?>=!]/, "");
117
+ const first = Number.parseInt(params.split(";")[0] ?? "", 10);
118
+ const n = Number.isNaN(first) ? undefined : first;
119
+ switch (final) {
120
+ case "K":
121
+ // erase in line
122
+ if (n === undefined || n === 0) this.cells.length = Math.min(this.cells.length, this.col);else if (n === 1) for (let i = 0; i <= this.col && i < this.cells.length; i++) this.cells[i] = " ";else if (n === 2) this.cells = [];
123
+ return;
124
+ case "G":
125
+ // cursor horizontal absolute (1-based)
126
+ this.col = Math.max(0, (n ?? 1) - 1);
127
+ return;
128
+ case "C":
129
+ // cursor forward
130
+ this.col += Math.max(1, n ?? 1);
131
+ return;
132
+ case "D":
133
+ // cursor back
134
+ this.col = Math.max(0, this.col - Math.max(1, n ?? 1));
135
+ return;
136
+ case "J":
137
+ // erase display: the current line is all this view can clear
138
+ if (n === 2 || n === 3) {
139
+ this.cells = [];
140
+ this.col = 0;
141
+ }
142
+ return;
143
+ default:
144
+ return;
145
+ // colours (m) and everything else carry no line text
146
+ }
147
+ }
148
+ put(char) {
149
+ while (this.cells.length < this.col) this.cells.push(" ");
150
+ this.cells[this.col] = char;
151
+ this.col++;
152
+ if (this.cells.length >= this.maxLineLength) {
153
+ this.commit(this.render());
154
+ this.cells = [];
155
+ this.col = 0;
156
+ }
157
+ }
158
+ render() {
159
+ return this.cells.join("").trimEnd();
160
+ }
161
+ }
@@ -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,45 @@
1
+ import { Terminal } from "@xterm/headless";
2
+
3
+ /** Full VT emulation of a shell's output: what a human would see right now (ADR 0003). */
4
+ export class Screen {
5
+ constructor(cols, rows, scrollback = 2000) {
6
+ this.term = new Terminal({
7
+ cols,
8
+ rows,
9
+ scrollback,
10
+ allowProposedApi: true
11
+ });
12
+ }
13
+ write(chunk) {
14
+ this.term.write(chunk);
15
+ }
16
+ resize(cols, rows) {
17
+ this.term.resize(cols, rows);
18
+ }
19
+ reset() {
20
+ this.term.reset();
21
+ }
22
+
23
+ /** Waits until every pending write has been parsed, then renders the viewport. */
24
+ async snapshot() {
25
+ await new Promise(resolve => this.term.write("", resolve));
26
+ const buffer = this.term.buffer.active;
27
+ const rows = [];
28
+ for (let y = 0; y < this.term.rows; y++) {
29
+ rows.push(buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? "");
30
+ }
31
+ while (rows.length > 0 && rows[rows.length - 1] === "") rows.pop();
32
+ return {
33
+ text: rows.join("\n"),
34
+ cols: this.term.cols,
35
+ rows: this.term.rows,
36
+ cursor: {
37
+ x: buffer.cursorX,
38
+ y: buffer.cursorY
39
+ }
40
+ };
41
+ }
42
+ dispose() {
43
+ this.term.dispose();
44
+ }
45
+ }
@@ -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
+ }