@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,186 @@
1
+ import { invalidParams, invalidState } from "../../core/errors.js";
2
+ import { compilePattern } from "./module.js";
3
+ import { waitFor } from "./wait.js";
4
+ import { PRESETS, presetByName, presetForCommand } from "./watch/presets.js";
5
+ import { compileRule, Watcher } from "./watch/watcher.js";
6
+
7
+ /**
8
+ * The `shell.*` methods, kept apart from the module's lifecycle and bookkeeping so each file has
9
+ * one job: this one maps protocol calls onto the module, `module.ts` owns the shells.
10
+ */
11
+ export function shellMethods(module) {
12
+ return {
13
+ start: params => module.startShell(params),
14
+ list: params => {
15
+ const owner = params.owner;
16
+ return [...module.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());
17
+ },
18
+ get: ({
19
+ id
20
+ }) => module.require(id).info(),
21
+ read: ({
22
+ id,
23
+ after,
24
+ tail,
25
+ limit,
26
+ grep,
27
+ ignoreCase
28
+ }) => {
29
+ const shell = module.require(id);
30
+ const page = shell.log.read({
31
+ after,
32
+ tail,
33
+ limit,
34
+ grep: grep === undefined ? undefined : compilePattern(grep, ignoreCase)
35
+ });
36
+ return {
37
+ ...page,
38
+ status: shell.status
39
+ };
40
+ },
41
+ screen: ({
42
+ id
43
+ }) => module.require(id).snapshot(),
44
+ write: ({
45
+ id,
46
+ data
47
+ }) => {
48
+ const shell = module.require(id);
49
+ if (!shell.running) throw invalidState(`shell ${id} is ${shell.status}`);
50
+ return {
51
+ bytes: shell.write(data)
52
+ };
53
+ },
54
+ resize: ({
55
+ id,
56
+ cols,
57
+ rows
58
+ }) => {
59
+ module.require(id).resize(cols, rows);
60
+ return {};
61
+ },
62
+ wait: async params => {
63
+ const shell = module.require(params.id);
64
+ const outcome = await waitFor(shell, params, compilePattern);
65
+ return {
66
+ ...outcome,
67
+ info: shell.info()
68
+ };
69
+ },
70
+ stop: async ({
71
+ id,
72
+ signal,
73
+ graceMs
74
+ }) => {
75
+ const shell = module.require(id);
76
+ await shell.stop(signal, graceMs);
77
+ await shell.exited;
78
+ return shell.info();
79
+ },
80
+ restart: async ({
81
+ id
82
+ }) => {
83
+ const shell = module.require(id);
84
+ if (shell.running) {
85
+ await shell.stop("SIGTERM", 3000);
86
+ await shell.exited;
87
+ }
88
+ module.spawn(shell);
89
+ return shell.info();
90
+ },
91
+ remove: async ({
92
+ id
93
+ }) => {
94
+ const shell = module.require(id);
95
+ if (shell.running) {
96
+ await shell.stop("SIGTERM", 3000);
97
+ await shell.exited;
98
+ }
99
+ module.forget(shell);
100
+ return {};
101
+ },
102
+ attach: ({
103
+ id,
104
+ fromOffset
105
+ }, {
106
+ peer
107
+ }) => {
108
+ const shell = module.require(id);
109
+ module.detach(peer, id);
110
+ const replay = shell.raw.since(fromOffset ?? 0);
111
+ module.attachStream(peer, shell);
112
+ return {
113
+ offset: replay.offset,
114
+ replay: Buffer.from(replay.bytes).toString("base64")
115
+ };
116
+ },
117
+ clear: ({
118
+ owner,
119
+ finishedBeforeMs
120
+ }) => {
121
+ const cutoff = Date.now() - (finishedBeforeMs ?? 0);
122
+ const removed = [];
123
+ for (const shell of [...module.shells.values()]) {
124
+ if (shell.running) continue;
125
+ const info = shell.info();
126
+ if (owner?.project && info.owner.project !== owner.project) continue;
127
+ if (owner?.session && info.owner.session !== owner.session) continue;
128
+ if ((info.endedAt ?? 0) > cutoff) continue;
129
+ module.forget(shell);
130
+ removed.push(info.id);
131
+ }
132
+ return {
133
+ removed
134
+ };
135
+ },
136
+ watch: ({
137
+ id,
138
+ preset,
139
+ rule
140
+ }) => {
141
+ const shell = module.require(id);
142
+ const command = [shell.spec.command, ...shell.spec.args].join(" ");
143
+ const chosen = rule ? undefined : preset && preset !== "auto" ? presetByName(preset) ?? invalidParams(`unknown preset "${preset}"; call shell.presets for the list`) : presetForCommand(command);
144
+ if (chosen instanceof Error) throw chosen;
145
+ const watchRule = rule ?? chosen?.rule;
146
+ if (!watchRule) {
147
+ throw invalidParams(`no watch preset matches "${command.slice(0, 80)}"; pass a rule (done/fail/ok patterns) or a preset name`);
148
+ }
149
+ try {
150
+ shell.watcher = new Watcher(compileRule(watchRule), chosen?.name);
151
+ } catch (err) {
152
+ throw invalidParams(`invalid watch pattern: ${err instanceof Error ? err.message : String(err)}`);
153
+ }
154
+ shell.onWatchChange = change => {
155
+ module.emit("shell.watch", {
156
+ info: shell.info(),
157
+ ...change
158
+ });
159
+ };
160
+ module.armIdle(shell);
161
+ return shell.info();
162
+ },
163
+ unwatch: ({
164
+ id
165
+ }) => {
166
+ const shell = module.require(id);
167
+ shell.watcher = undefined;
168
+ shell.onWatchChange = undefined;
169
+ module.clearIdle(id);
170
+ return shell.info();
171
+ },
172
+ presets: () => PRESETS.map(preset => ({
173
+ name: preset.name,
174
+ match: preset.match,
175
+ rule: preset.rule
176
+ })),
177
+ detach: ({
178
+ id
179
+ }, {
180
+ peer
181
+ }) => {
182
+ module.detach(peer, id);
183
+ return {};
184
+ }
185
+ };
186
+ }
@@ -0,0 +1,259 @@
1
+ import { join } from "node:path";
2
+ import { invalidParams, notFound } from "../../core/errors.js";
3
+ import { silentLogger } from "../../core/logger.js";
4
+ import { newShellId } from "./ids.js";
5
+ import { shellMethods } from "./methods.js";
6
+ import { bunPtyBackend } from "./pty.js";
7
+ import { ProcessRegistry } from "./registry.js";
8
+ import { Shell } from "./shell.js";
9
+ const DEFAULT_LIMITS = {
10
+ logChars: 4_000_000,
11
+ rawBytes: 1_000_000,
12
+ scrollback: 2000
13
+ };
14
+ export class ShellModule {
15
+ name = "shell";
16
+ /** @internal */
17
+ shells = new Map();
18
+ /** @internal */
19
+ attachments = new Map(); // `${peer.id}:${shellId}` → detach
20
+
21
+ log = silentLogger;
22
+ idleTimers = new Map();
23
+ /** @internal */
24
+ emit = () => {};
25
+ constructor(options = {}) {
26
+ this.options = options;
27
+ this.backend = options.backend ?? bunPtyBackend;
28
+ this.limits = {
29
+ ...DEFAULT_LIMITS,
30
+ ...options.limits
31
+ };
32
+ }
33
+ async start(ctx) {
34
+ this.log = ctx.log;
35
+ this.emit = ctx.emit;
36
+ if (this.options.registryFile) {
37
+ this.registry = new ProcessRegistry(this.options.registryFile, ctx.log);
38
+ const reaped = this.registry.reap();
39
+ if (reaped > 0) ctx.log.warn("cleaned up shells left by a previous daemon", {
40
+ reaped
41
+ });
42
+ }
43
+ }
44
+ async stop() {
45
+ for (const id of [...this.idleTimers.keys()]) this.clearIdle(id);
46
+ await Promise.all([...this.shells.values()].map(s => s.stop("SIGTERM", 2000).catch(() => {})));
47
+ for (const detach of this.attachments.values()) detach();
48
+ for (const shell of this.shells.values()) shell.dispose();
49
+ this.attachments.clear();
50
+ this.shells.clear();
51
+ }
52
+ busy() {
53
+ for (const shell of this.shells.values()) if (shell.running) return true;
54
+ return false;
55
+ }
56
+ methods = shellMethods(this);
57
+
58
+ /** @internal used by methods.ts */
59
+ startShell(params) {
60
+ if (params.reuse) {
61
+ const previous = this.findReusable(params);
62
+ if (previous) {
63
+ Object.assign(previous.spec, {
64
+ env: this.environment(params.env),
65
+ title: params.title ?? previous.spec.title,
66
+ timeoutMs: params.timeoutMs,
67
+ owner: params.owner
68
+ });
69
+ this.spawn(previous);
70
+ return previous.info();
71
+ }
72
+ }
73
+ const id = this.uniqueId();
74
+ const shell = new Shell({
75
+ id,
76
+ command: params.command,
77
+ args: params.args,
78
+ cwd: params.cwd,
79
+ env: this.environment(params.env),
80
+ title: params.title ?? [params.command, ...params.args].join(" ").slice(0, 200),
81
+ cols: params.cols,
82
+ rows: params.rows,
83
+ owner: params.owner,
84
+ timeoutMs: params.timeoutMs,
85
+ idleTimeoutMs: params.idleTimeoutMs,
86
+ logFile: params.logFile ? join(this.options.logDir ?? "/tmp", `${id}.log`) : undefined
87
+ }, this.backend, this.limits);
88
+ this.shells.set(shell.id, shell);
89
+ shell.subscribe({
90
+ exit: info => this.onExit(info)
91
+ });
92
+ this.spawn(shell);
93
+ this.pruneFinished();
94
+ return shell.info();
95
+ }
96
+
97
+ /** @internal used by methods.ts */
98
+ findReusable(params) {
99
+ const args = JSON.stringify(params.args);
100
+ let match;
101
+ for (const shell of this.shells.values()) {
102
+ const spec = shell.spec;
103
+ 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)) {
104
+ match = shell;
105
+ }
106
+ }
107
+ return match;
108
+ }
109
+
110
+ /** @internal used by methods.ts */
111
+ spawn(shell) {
112
+ try {
113
+ shell.start();
114
+ } catch (err) {
115
+ const info = shell.info();
116
+ this.log.warn("spawn failed", {
117
+ id: shell.id,
118
+ command: shell.spec.command,
119
+ err: String(err)
120
+ });
121
+ this.emit("shell.exited", info);
122
+ return;
123
+ }
124
+ const pid = shell.info().pid;
125
+ if (pid) this.registry?.add(shell.id, pid, shell.spec.command);
126
+ this.log.info("shell started", {
127
+ id: shell.id,
128
+ command: shell.spec.command,
129
+ pid
130
+ });
131
+ this.emit("shell.started", shell.info());
132
+ }
133
+ onExit(info) {
134
+ this.registry?.remove(info.id);
135
+ this.log.info("shell ended", {
136
+ id: info.id,
137
+ status: info.status,
138
+ exitCode: info.exitCode,
139
+ signal: info.signal
140
+ });
141
+ this.emit("shell.exited", info);
142
+ }
143
+
144
+ /** @internal used by methods.ts */
145
+ attachStream(peer, shell) {
146
+ const key = `${peer.id}:${shell.id}`;
147
+ const flushMs = this.options.outputFlushMs ?? 16;
148
+ let pending = [];
149
+ let pendingOffset = 0;
150
+ let timer;
151
+ const flush = () => {
152
+ timer = undefined;
153
+ if (pending.length === 0) return;
154
+ const data = Buffer.concat(pending).toString("base64");
155
+ peer.send("shell.output", {
156
+ id: shell.id,
157
+ offset: pendingOffset,
158
+ data
159
+ });
160
+ pending = [];
161
+ };
162
+ const unsubscribe = shell.subscribe({
163
+ data(offset, chunk) {
164
+ if (pending.length === 0) pendingOffset = offset;
165
+ pending.push(chunk);
166
+ timer ??= setTimeout(flush, flushMs);
167
+ }
168
+ });
169
+ const detach = () => {
170
+ clearTimeout(timer);
171
+ flush();
172
+ unsubscribe();
173
+ this.attachments.delete(key);
174
+ };
175
+ this.attachments.set(key, detach);
176
+ peer.onClose(detach);
177
+ }
178
+
179
+ /** @internal used by methods.ts */
180
+ detach(peer, id) {
181
+ this.attachments.get(`${peer.id}:${id}`)?.();
182
+ }
183
+
184
+ /**
185
+ * Rules without a `done` pattern end a run on silence, so poll those watchers; the check is a
186
+ * timestamp comparison, and only shells that need it are polled.
187
+ */
188
+ /** @internal used by methods.ts */
189
+ armIdle(shell) {
190
+ this.clearIdle(shell.id);
191
+ const idleMs = shell.watcher?.idleMs;
192
+ if (!idleMs) return;
193
+ const timer = setInterval(() => {
194
+ if (!shell.watcher || Date.now() - shell.lastOutputAt < idleMs) return;
195
+ const change = shell.watcher.idle();
196
+ if (change) this.emit("shell.watch", {
197
+ info: shell.info(),
198
+ ...change
199
+ });
200
+ }, Math.max(500, Math.floor(idleMs / 2)));
201
+ this.idleTimers.set(shell.id, timer);
202
+ }
203
+
204
+ /** @internal used by methods.ts */
205
+ clearIdle(id) {
206
+ const timer = this.idleTimers.get(id);
207
+ if (timer) clearInterval(timer);
208
+ this.idleTimers.delete(id);
209
+ }
210
+
211
+ /** @internal used by methods.ts */
212
+ forget(shell) {
213
+ this.clearIdle(shell.id);
214
+ for (const [key, detach] of this.attachments) if (key.endsWith(`:${shell.id}`)) detach();
215
+ shell.dispose();
216
+ this.shells.delete(shell.id);
217
+ this.emit("shell.removed", {
218
+ id: shell.id
219
+ });
220
+ }
221
+
222
+ /** @internal used by methods.ts */
223
+ pruneFinished() {
224
+ const max = this.options.maxFinished ?? 50;
225
+ const finished = [...this.shells.values()].filter(s => !s.running);
226
+ for (const shell of finished.slice(0, Math.max(0, finished.length - max))) this.forget(shell);
227
+ }
228
+
229
+ /** @internal used by methods.ts */
230
+ require(id) {
231
+ const shell = this.shells.get(id);
232
+ if (!shell) throw notFound(`shell ${id}`);
233
+ return shell;
234
+ }
235
+
236
+ /** @internal used by methods.ts */
237
+ uniqueId() {
238
+ let id = newShellId();
239
+ while (this.shells.has(id)) id = newShellId();
240
+ return id;
241
+ }
242
+
243
+ /** @internal used by methods.ts */
244
+ environment(extra) {
245
+ const env = {};
246
+ for (const [k, v] of Object.entries(this.options.baseEnv ?? process.env)) if (v !== undefined) env[k] = v;
247
+ env.TERM ??= "xterm-256color";
248
+ env.COLORTERM ??= "truecolor";
249
+ Object.assign(env, extra);
250
+ return env;
251
+ }
252
+ }
253
+ export function compilePattern(pattern, ignoreCase) {
254
+ try {
255
+ return new RegExp(pattern, ignoreCase ? "i" : "");
256
+ } catch (err) {
257
+ throw invalidParams(`invalid pattern: ${err instanceof Error ? err.message : String(err)}`);
258
+ }
259
+ }
@@ -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,19 @@
1
+ /** Standard xterm palette, so the daemon can hand UIs resolved colours instead of escape codes. */
2
+ const BASE = ["#000000", "#cd0000", "#00cd00", "#cdcd00", "#0000ee", "#cd00cd", "#00cdcd", "#e5e5e5", "#7f7f7f", "#ff0000", "#00ff00", "#ffff00", "#5c5cff", "#ff00ff", "#00ffff", "#ffffff"];
3
+ const STEPS = [0, 95, 135, 175, 215, 255];
4
+ const hex = n => n.toString(16).padStart(2, "0");
5
+ export function paletteColor(index) {
6
+ if (index < 16) return BASE[index] ?? "#ffffff";
7
+ if (index < 232) {
8
+ const value = index - 16;
9
+ const r = STEPS[Math.floor(value / 36) % 6] ?? 0;
10
+ const g = STEPS[Math.floor(value / 6) % 6] ?? 0;
11
+ const b = STEPS[value % 6] ?? 0;
12
+ return `#${hex(r)}${hex(g)}${hex(b)}`;
13
+ }
14
+ const grey = 8 + (index - 232) * 10;
15
+ return `#${hex(grey)}${hex(grey)}${hex(grey)}`;
16
+ }
17
+ export function rgbColor(value) {
18
+ return `#${hex(value >> 16 & 0xff)}${hex(value >> 8 & 0xff)}${hex(value & 0xff)}`;
19
+ }