oakode 0.0.0-stage → 0.5.1

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 (53) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +9 -0
  3. package/README.md +245 -2
  4. package/SECURITY.md +13 -0
  5. package/dist/account.js +34 -0
  6. package/dist/agent.js +584 -0
  7. package/dist/agents/approval.js +116 -0
  8. package/dist/agents/budget.js +293 -0
  9. package/dist/agents/child.js +103 -0
  10. package/dist/agents/host.js +289 -0
  11. package/dist/agents/orchestrator.js +286 -0
  12. package/dist/agents/plan.js +143 -0
  13. package/dist/agents/types.js +20 -0
  14. package/dist/agents/verify.js +94 -0
  15. package/dist/app.js +619 -0
  16. package/dist/auth.js +456 -0
  17. package/dist/brief.js +97 -0
  18. package/dist/commands.js +588 -0
  19. package/dist/compact.js +231 -0
  20. package/dist/config.js +87 -0
  21. package/dist/diff.js +98 -0
  22. package/dist/doctor.js +106 -0
  23. package/dist/errors.js +15 -0
  24. package/dist/fileio.js +688 -0
  25. package/dist/input.js +244 -0
  26. package/dist/jobs.js +262 -0
  27. package/dist/main.js +560 -0
  28. package/dist/mask.js +63 -0
  29. package/dist/mcp.js +721 -0
  30. package/dist/models.js +106 -0
  31. package/dist/modes.js +114 -0
  32. package/dist/oicki.js +475 -0
  33. package/dist/output.js +101 -0
  34. package/dist/permissions.js +389 -0
  35. package/dist/permissions_ui.js +134 -0
  36. package/dist/print.js +76 -0
  37. package/dist/proc.js +63 -0
  38. package/dist/runners.js +264 -0
  39. package/dist/serial.js +9 -0
  40. package/dist/session.js +452 -0
  41. package/dist/shell.js +40 -0
  42. package/dist/skills.js +335 -0
  43. package/dist/syntax.js +80 -0
  44. package/dist/tools.js +480 -0
  45. package/dist/tui/editor.js +279 -0
  46. package/dist/tui/highlight.js +78 -0
  47. package/dist/tui/markdown.js +254 -0
  48. package/dist/tui/screen.js +146 -0
  49. package/dist/tui/tui.js +505 -0
  50. package/dist/tui/view.js +337 -0
  51. package/dist/ui.js +105 -0
  52. package/dist/update.js +64 -0
  53. package/package.json +57 -4
package/dist/input.js ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Keyboard input decoding (raw mode) and the exit watchdog.
3
+ *
4
+ * Pasted text arrives between ESC[200~ and ESC[201~ and is inserted verbatim: it can never submit, approve a prompt, or be
5
+ * altered by editing rules. There are no timers in paste detection.
6
+ */
7
+ import { spawn } from "node:child_process";
8
+ const PASTE_START = "\x1b[200~";
9
+ const PASTE_END = "\x1b[201~";
10
+ /** Pasted text: unify line endings and drop control characters (they could drive the terminal). */
11
+ const cleanPaste = (s) => s.replace(/\r\n?/g, "\n").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
12
+ export class KeyParser {
13
+ pending = "";
14
+ paste = null;
15
+ /** After a short idle: a lone ESC is the Escape key; any other incomplete sequence is dropped. Never touches a paste. */
16
+ flushPending() {
17
+ if (this.paste !== null)
18
+ return [];
19
+ const lone = this.pending === "\x1b" || this.pending.startsWith("\x1b\x1b"); // ESC ESC that did not become Option+Up
20
+ this.pending = "";
21
+ return lone ? [{ k: "escape" }] : [];
22
+ }
23
+ feed(chunk) {
24
+ const s = this.pending + chunk;
25
+ this.pending = "";
26
+ const keys = [];
27
+ let text = "";
28
+ const flushText = () => {
29
+ if (text)
30
+ keys.push({ k: "text", s: text });
31
+ text = "";
32
+ };
33
+ let i = 0;
34
+ while (i < s.length) {
35
+ if (this.paste !== null) {
36
+ const end = s.indexOf(PASTE_END, i);
37
+ if (end < 0) {
38
+ // keep a possible partial end marker for the next chunk
39
+ let hold = 0;
40
+ for (let n = Math.min(PASTE_END.length - 1, s.length - i); n > 0; n--) {
41
+ if (PASTE_END.startsWith(s.slice(s.length - n))) {
42
+ hold = n;
43
+ break;
44
+ }
45
+ }
46
+ this.paste += s.slice(i, s.length - hold);
47
+ this.pending = s.slice(s.length - hold);
48
+ return keys;
49
+ }
50
+ keys.push({ k: "paste", s: cleanPaste(this.paste + s.slice(i, end)) });
51
+ this.paste = null;
52
+ i = end + PASTE_END.length;
53
+ continue;
54
+ }
55
+ const ch = s[i];
56
+ if (ch === "\x1b") {
57
+ flushText();
58
+ const next = this.escape(s, i, keys);
59
+ if (next < 0) {
60
+ this.pending = s.slice(i);
61
+ return unbracketed(keys);
62
+ }
63
+ i = next;
64
+ continue;
65
+ }
66
+ const code = ch.charCodeAt(0);
67
+ if (code >= 0x20 && code !== 0x7f) {
68
+ text += ch;
69
+ i++;
70
+ continue;
71
+ }
72
+ flushText();
73
+ i++;
74
+ switch (ch) {
75
+ case "\r":
76
+ keys.push({ k: "enter" });
77
+ break;
78
+ case "\t":
79
+ keys.push({ k: "tab" });
80
+ break;
81
+ case "\n":
82
+ keys.push({ k: "newline" });
83
+ break; // Ctrl+J
84
+ case "\x7f":
85
+ case "\b":
86
+ keys.push({ k: "backspace" });
87
+ break;
88
+ case "\x01":
89
+ keys.push({ k: "ctrl", c: "a" });
90
+ break;
91
+ case "\x03":
92
+ keys.push({ k: "ctrl", c: "c" });
93
+ break;
94
+ case "\x04":
95
+ keys.push({ k: "ctrl", c: "d" });
96
+ break;
97
+ case "\x05":
98
+ keys.push({ k: "ctrl", c: "e" });
99
+ break;
100
+ case "\x0b":
101
+ keys.push({ k: "ctrl", c: "k" });
102
+ break;
103
+ case "\x0c":
104
+ keys.push({ k: "ctrl", c: "l" });
105
+ break;
106
+ case "\x15":
107
+ keys.push({ k: "ctrl", c: "u" });
108
+ break;
109
+ case "\x17":
110
+ keys.push({ k: "ctrl", c: "w" });
111
+ break;
112
+ default: break; // other control characters are ignored
113
+ }
114
+ }
115
+ flushText();
116
+ return unbracketed(keys);
117
+ }
118
+ /** Parse an escape sequence at s[i]; returns the next index, or -1 if the sequence is incomplete. */
119
+ escape(s, i, keys) {
120
+ if (i + 1 >= s.length)
121
+ return -1;
122
+ const n = s[i + 1];
123
+ if (n === "[") {
124
+ let j = i + 2;
125
+ while (j < s.length && !(s.charCodeAt(j) >= 0x40 && s.charCodeAt(j) <= 0x7e))
126
+ j++;
127
+ if (j >= s.length)
128
+ return s.length - i > 32 ? s.length : -1; // incomplete (or junk: drop it)
129
+ const params = s.slice(i + 2, j);
130
+ const fin = s[j];
131
+ const arrows = { A: "up", B: "down", C: "right", D: "left", H: "home", F: "end" };
132
+ if (fin === "~") {
133
+ if (params === "200")
134
+ this.paste = "";
135
+ else if (params === "3")
136
+ keys.push({ k: "delete" });
137
+ else if (params === "1" || params === "7")
138
+ keys.push({ k: "home" });
139
+ else if (params === "4" || params === "8")
140
+ keys.push({ k: "end" });
141
+ else if (params === "27;2;13")
142
+ keys.push({ k: "newline" }); // Shift+Enter (xterm modifyOtherKeys)
143
+ }
144
+ else if (fin === "Z") {
145
+ keys.push({ k: "shifttab" });
146
+ }
147
+ else if ((fin === "I" || fin === "O") && params === "") {
148
+ keys.push({ k: fin === "I" ? "focusin" : "focusout" }); // focus reporting (ESC[?1004h)
149
+ }
150
+ else if (fin === "u" && /^13;[2-8]$/.test(params)) {
151
+ keys.push({ k: "newline" }); // Shift/Alt/Ctrl+Enter (kitty protocol)
152
+ }
153
+ else if (fin === "u" && params === "13") {
154
+ keys.push({ k: "enter" });
155
+ }
156
+ else if (arrows[fin]) {
157
+ const mod = /^1;[35]$/.test(params);
158
+ if (/^1;(3|9)$/.test(params) && fin === "A")
159
+ keys.push({ k: "altup" }); // Alt/Option+Up
160
+ else if (mod && fin === "C")
161
+ keys.push({ k: "wordright" });
162
+ else if (mod && fin === "D")
163
+ keys.push({ k: "wordleft" });
164
+ else
165
+ keys.push({ k: arrows[fin] });
166
+ }
167
+ return j + 1;
168
+ }
169
+ if (n === "O") {
170
+ if (i + 2 >= s.length)
171
+ return -1;
172
+ const m = { A: "up", B: "down", C: "right", D: "left", H: "home", F: "end" };
173
+ if (m[s[i + 2]])
174
+ keys.push({ k: m[s[i + 2]] });
175
+ return i + 3;
176
+ }
177
+ if (n === "\x1b") {
178
+ // ESC ESC [A: Option+Up in terminals that send Meta as an ESC prefix. Wait only while it can still be that;
179
+ // anything else is an Escape key, and the second ESC starts its own sequence.
180
+ const rest = s.slice(i, i + 4);
181
+ if (rest === "\x1b\x1b[A") {
182
+ keys.push({ k: "altup" });
183
+ return i + 4;
184
+ }
185
+ if ("\x1b\x1b[A".startsWith(rest))
186
+ return -1;
187
+ keys.push({ k: "escape" });
188
+ return i + 1;
189
+ }
190
+ if (n === "\r")
191
+ keys.push({ k: "newline" }); // Alt+Enter
192
+ else if (n === "\x7f")
193
+ keys.push({ k: "delword" });
194
+ else if (n === "b")
195
+ keys.push({ k: "wordleft" });
196
+ else if (n === "f")
197
+ keys.push({ k: "wordright" });
198
+ return i + 2;
199
+ }
200
+ }
201
+ /**
202
+ * Terminals without bracketed paste deliver a paste as one chunk with Enter keys followed by more
203
+ * input. Typed Enter is always the last key of its chunk, so Enters followed by anything are newlines.
204
+ * This is structural (no timing) and fails safe: it never submits.
205
+ */
206
+ function unbracketed(keys) {
207
+ const firstEnter = keys.findIndex((k) => k.k === "enter");
208
+ if (firstEnter < 0 || firstEnter === keys.length - 1)
209
+ return keys;
210
+ return keys.map((k) => (k.k === "enter" ? { k: "newline" } : k));
211
+ }
212
+ /**
213
+ * Shell script for the exit watchdog. After `delay` seconds, if the SAME process (same start time, so a recycled pid is
214
+ * never touched) is still alive, kill it and best-effort restore its terminal (raw mode, bracketed paste) for when the
215
+ * terminal starts reading again.
216
+ *
217
+ * Platform assumptions (all fail safe: if one does not hold, the watchdog does nothing rather than killing something):
218
+ * POSIX `sh`, `ps -o lstart=`/`-o tty=` (macOS, Linux procps; busybox `ps` lacks lstart, so it stays inert there), and
219
+ * `stty`. It is never armed on Windows.
220
+ */
221
+ export function watchdogScript(pid, opts = {}) {
222
+ const start = opts.expectedStart !== undefined ? `S='${opts.expectedStart.replace(/'/g, "")}'` : `S=$(ps -o lstart= -p ${pid} 2>/dev/null)`;
223
+ return [
224
+ start,
225
+ `sleep ${opts.delay ?? 2}`,
226
+ `[ -n "$S" ] && [ "$(ps -o lstart= -p ${pid} 2>/dev/null)" = "$S" ] || exit 0`,
227
+ `T=$(ps -o tty= -p ${pid} 2>/dev/null | tr -d ' ')`,
228
+ `kill -9 ${pid} 2>/dev/null`,
229
+ `case "$T" in ""|"?"|"??") exit 0;; esac`,
230
+ `printf '\\033[?2004l\\033[?1004l' > "/dev/$T"`, // bracketed paste and focus reports off
231
+ `stty sane < "/dev/$T"`,
232
+ ].join("\n");
233
+ }
234
+ export function armWatchdog(pid) {
235
+ if (process.platform === "win32")
236
+ return false;
237
+ try {
238
+ spawn("sh", ["-c", watchdogScript(pid)], { detached: true, stdio: "ignore" }).unref();
239
+ return true;
240
+ }
241
+ catch {
242
+ return false;
243
+ }
244
+ }
package/dist/jobs.js ADDED
@@ -0,0 +1,262 @@
1
+ import { closeSync, constants, existsSync, mkdtempSync, openSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { isAbsolute, join, sep } from "node:path";
4
+ import { MAX_BYTES, ToolError } from "./fileio.js";
5
+ import { HeadTail, condenseOutput, keepEnds, stripEscapes } from "./output.js";
6
+ import { killTreeOf, spawnShell, untrackChild } from "./proc.js";
7
+ // ---------------------------------------------------------------- output folder
8
+ const PREFIX = "oakode-out-";
9
+ let dir;
10
+ /** This process's output folder (created on first use, owner-only: logs can hold secrets; the pid marks it in use). */
11
+ export function outputDir() {
12
+ if (!dir || !existsSync(dir))
13
+ dir = mkdtempSync(join(tmpdir(), `${PREFIX}${process.pid}-`));
14
+ return dir;
15
+ }
16
+ /** Creates a new file there: never an existing one, never through a symlink. */
17
+ const CREATE = constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0);
18
+ export function openOutput(name) {
19
+ const path = join(outputDir(), name);
20
+ return { path, fd: openSync(path, CREATE, 0o600) };
21
+ }
22
+ export function saveOutput(name, text) {
23
+ const { path, fd } = openOutput(name);
24
+ try {
25
+ writeFileSync(fd, text);
26
+ }
27
+ finally {
28
+ closeSync(fd);
29
+ }
30
+ return path;
31
+ }
32
+ /** The real path when `p` is a file in this process's output folder, else undefined (read and grep may open those). */
33
+ export function outputFile(p) {
34
+ if (!dir || typeof p !== "string" || !isAbsolute(p))
35
+ return undefined;
36
+ try {
37
+ const real = realpathSync(p);
38
+ return real.startsWith(realpathSync(dir) + sep) ? real : undefined;
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ /** At exit. Folders left by a crash are removed by the next start once they are a day old. */
45
+ export function removeOutput() {
46
+ try {
47
+ if (dir)
48
+ rmSync(dir, { recursive: true, force: true });
49
+ }
50
+ catch { } // Windows: a log still open by a closing job; the next start's sweep removes it
51
+ dir = undefined;
52
+ }
53
+ const alive = (pid) => {
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ }
58
+ catch (e) {
59
+ return e.code === "EPERM";
60
+ }
61
+ };
62
+ export function sweepOldOutput(maxAgeMs = 24 * 3600_000) {
63
+ try {
64
+ for (const n of readdirSync(tmpdir())) {
65
+ const m = new RegExp(`^${PREFIX}(\\d+)-`).exec(n);
66
+ if (!m || alive(Number(m[1])))
67
+ continue; // a running OAKODE's folder is never touched
68
+ const p = join(tmpdir(), n);
69
+ try {
70
+ if (Date.now() - statSync(p).mtimeMs > maxAgeMs)
71
+ rmSync(p, { recursive: true, force: true });
72
+ }
73
+ catch { }
74
+ }
75
+ }
76
+ catch { }
77
+ }
78
+ // ---------------------------------------------------------------- jobs
79
+ export const MAX_JOBS = 8;
80
+ const LOG_CAP = 50 * 1024 * 1024;
81
+ export class Job {
82
+ id;
83
+ command;
84
+ started = Date.now();
85
+ /** "exit code 0", "signal SIGINT", "failed to start: …"; undefined while running. */
86
+ ended;
87
+ /** Its output pipes closed: nothing it started is still holding them (a shell can end before what it put in the background). */
88
+ closed = false;
89
+ log;
90
+ child;
91
+ pending = new HeadTail(256 * 1024, 768 * 1024);
92
+ fd;
93
+ logged = 0;
94
+ listeners = new Set();
95
+ constructor(id, command, cwd) {
96
+ this.id = id;
97
+ this.command = command;
98
+ const out = openOutput(`job-${id}.log`);
99
+ this.log = out.path;
100
+ this.fd = out.fd;
101
+ const child = (this.child = spawnShell(command, cwd));
102
+ const take = (b) => {
103
+ this.pending.push(b);
104
+ if (this.fd !== undefined && this.logged < LOG_CAP) {
105
+ try {
106
+ writeSync(this.fd, this.logged + b.length > LOG_CAP ? b.subarray(0, LOG_CAP - this.logged) : b);
107
+ this.logged += b.length;
108
+ if (this.logged >= LOG_CAP)
109
+ writeSync(this.fd, "\n[log stopped at 50 MB]\n");
110
+ }
111
+ catch {
112
+ this.closeLog(); // a full disk stops the log, not OAKODE
113
+ }
114
+ }
115
+ this.emit(b.toString());
116
+ };
117
+ child.stdout.on("data", take);
118
+ child.stderr.on("data", take);
119
+ const end = (why) => {
120
+ if (this.ended !== undefined)
121
+ return;
122
+ this.ended = why;
123
+ this.emit("");
124
+ };
125
+ // the shell's exit ends the job; what it left running in the background is stopped with it at OAKODE's exit
126
+ child.on("exit", (code, sig) => setTimeout(() => end(code !== null ? `exit code ${code}` : `signal ${sig}`), 250));
127
+ child.on("error", (e) => end(`failed to start: ${e.message}`));
128
+ child.on("close", (code, sig) => {
129
+ end(code !== null ? `exit code ${code}` : `signal ${sig}`);
130
+ this.closed = true;
131
+ untrackChild(child);
132
+ this.closeLog();
133
+ this.emit("");
134
+ });
135
+ }
136
+ closeLog() {
137
+ try {
138
+ if (this.fd !== undefined)
139
+ closeSync(this.fd);
140
+ }
141
+ catch { }
142
+ this.fd = undefined;
143
+ }
144
+ get running() {
145
+ return this.ended === undefined;
146
+ }
147
+ /** Something of it still runs: the command, or processes it left in the background. */
148
+ get alive() {
149
+ return !this.closed && !this.ended?.startsWith("failed to start");
150
+ }
151
+ emit(chunk) {
152
+ for (const l of this.listeners)
153
+ l(chunk);
154
+ }
155
+ /**
156
+ * Waits up to `ms` until the job ends or its new output matches `until`. `onOutput` sees the output as it arrives.
157
+ * An abort stops the wait, never the job.
158
+ */
159
+ wait(ms, until, signal, onOutput) {
160
+ return new Promise((resolve) => {
161
+ // `until` sees whole lines, each cut to 2 KB: a model's regex cannot stall OAKODE on a huge line, and each
162
+ // chunk is checked once, not the whole buffer again
163
+ let carry = "";
164
+ const hit = (text) => !!until && text.split("\n").some((l) => until.test(stripEscapes(l.slice(0, 2048))));
165
+ const fresh = (chunk) => {
166
+ const lines = (carry + chunk).split("\n");
167
+ carry = (lines.pop() ?? "").slice(-2048);
168
+ return hit(lines.join("\n")) || hit(carry);
169
+ };
170
+ const check = (matched = false) => (!this.running ? "ended" : matched ? "matched" : undefined);
171
+ const first = check(hit(this.pending.text()));
172
+ if (first)
173
+ return resolve(first);
174
+ if (signal.aborted)
175
+ return resolve("stopped");
176
+ const done = (r) => {
177
+ clearTimeout(timer);
178
+ this.listeners.delete(listener);
179
+ signal.removeEventListener("abort", onAbort);
180
+ resolve(r);
181
+ };
182
+ const listener = (chunk) => {
183
+ if (chunk)
184
+ onOutput?.(stripEscapes(chunk));
185
+ const r = check(!!chunk && fresh(chunk));
186
+ if (r)
187
+ done(r);
188
+ };
189
+ const onAbort = () => done("stopped");
190
+ const timer = setTimeout(() => done("waited"), ms);
191
+ this.listeners.add(listener);
192
+ signal.addEventListener("abort", onAbort, { once: true });
193
+ });
194
+ }
195
+ /** The output since the last call, cleaned and shortened for the model. */
196
+ take() {
197
+ const raw = this.pending.text();
198
+ const omitted = this.pending.omitted;
199
+ this.pending = new HeadTail(256 * 1024, 768 * 1024);
200
+ const { text, truncated } = keepEnds(condenseOutput(raw).text, MAX_BYTES);
201
+ return (truncated || omitted ? `[output shortened; the full log is ${this.log} (read or grep can open it)]\n` : "") + text;
202
+ }
203
+ /** interrupt: Ctrl+C (SIGINT) to the job's process group; kill: SIGTERM, then SIGKILL. On Windows both are a kill. */
204
+ async stop(how) {
205
+ if (!this.alive)
206
+ return;
207
+ const gone = (ms) => new Promise((r) => {
208
+ if (!this.alive)
209
+ return r();
210
+ const t = setTimeout(() => (this.listeners.delete(l), r()), ms);
211
+ const l = () => !this.alive && (clearTimeout(t), this.listeners.delete(l), r());
212
+ this.listeners.add(l);
213
+ });
214
+ if (how === "interrupt" && process.platform !== "win32") {
215
+ killTreeOf(this.child, "SIGINT");
216
+ await gone(5000);
217
+ return;
218
+ }
219
+ killTreeOf(this.child, "SIGTERM");
220
+ await gone(2000);
221
+ if (this.alive)
222
+ killTreeOf(this.child, "SIGKILL"), await gone(2000);
223
+ }
224
+ }
225
+ const jobs = new Map();
226
+ let seq = 0;
227
+ export const allJobs = () => [...jobs.values()];
228
+ /** Jobs with something still running (also a shell that ended but left processes in the background). */
229
+ export const runningJobs = () => allJobs().filter((j) => j.alive);
230
+ export const jobById = (id) => (typeof id === "number" ? jobs.get(id) : undefined);
231
+ export function startJob(command, cwd) {
232
+ const live = runningJobs();
233
+ if (live.length >= MAX_JOBS) {
234
+ throw new ToolError(`${MAX_JOBS} background jobs are already running (${live.map((j) => `${j.id}: ${j.command.slice(0, 40)}`).join("; ")}). Stop one with job {id, action: "kill"} first.`);
235
+ }
236
+ const job = new Job(++seq, command, cwd);
237
+ jobs.set(job.id, job);
238
+ // keep the table small: forget the oldest finished jobs (their logs stay until exit)
239
+ const done = allJobs().filter((j) => !j.alive);
240
+ for (const j of done.slice(0, Math.max(0, done.length - 16)))
241
+ jobs.delete(j.id);
242
+ return job;
243
+ }
244
+ const secs = (ms) => (ms < 60_000 ? `${Math.round(ms / 1000)} s` : `${Math.floor(ms / 60_000)} min ${Math.round((ms % 60_000) / 1000)} s`);
245
+ /** One job's state and new output, as a tool result. */
246
+ export function report(job, outcome) {
247
+ const out = job.take().replace(/\s+$/, "");
248
+ const head = `[job ${job.id} · ${job.running ? `running for ${secs(Date.now() - job.started)}` : `ended (${job.ended}) after ${secs(Date.now() - job.started)}`} · ${job.command.split("\n")[0].slice(0, 80)}]`;
249
+ const why = outcome === "matched" ? " (its output matched `until`)" : outcome === "stopped" ? " (stopped waiting at your request; the job itself keeps running)" : "";
250
+ const next = job.running
251
+ ? `[still running${why}: poll with job {id: ${job.id}}, stop with action "interrupt" or "kill"]`
252
+ : job.alive
253
+ ? `[processes it started in the background are still running and holding its output; job {id: ${job.id}, action: "kill"} stops them]`
254
+ : "";
255
+ return [head, out || "(no new output)", next].filter(Boolean).join("\n");
256
+ }
257
+ export function stopAllJobs() {
258
+ for (const j of runningJobs())
259
+ void j.stop("kill");
260
+ }
261
+ /** "running", "ended (exit code 0)", or that plus processes it left in the background. */
262
+ export const stateOf = (j) => (j.running ? "running" : `ended (${j.ended})${j.alive ? ", background processes still running" : ""}`);