@agimon-ai/doompi-runner 0.0.1-alpha.76 → 0.0.1-alpha.77

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.
@@ -0,0 +1,4179 @@
1
+ Object.defineProperties(exports, {
2
+ __esModule: { value: true },
3
+ [Symbol.toStringTag]: { value: "Module" }
4
+ });
5
+ //#region \0rolldown/runtime.js
6
+ var __create = Object.create;
7
+ var __defProp = Object.defineProperty;
8
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
9
+ var __getOwnPropNames = Object.getOwnPropertyNames;
10
+ var __getProtoOf = Object.getPrototypeOf;
11
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
14
+ key = keys[i];
15
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
16
+ get: ((k) => from[k]).bind(null, key),
17
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
18
+ });
19
+ }
20
+ return to;
21
+ };
22
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
23
+ value: mod,
24
+ enumerable: true
25
+ }) : target, mod));
26
+ //#endregion
27
+ let _agimon_ai_doompi_core_mcp_facet = require("@agimon-ai/doompi-core/mcp-facet");
28
+ let _agimon_ai_doompi_core_server_facet = require("@agimon-ai/doompi-core/server-facet");
29
+ let typebox = require("typebox");
30
+ let node_path = require("node:path");
31
+ node_path = __toESM(node_path, 1);
32
+ let node_fs = require("node:fs");
33
+ node_fs = __toESM(node_fs, 1);
34
+ let node_string_decoder = require("node:string_decoder");
35
+ let node_crypto = require("node:crypto");
36
+ let node_url = require("node:url");
37
+ let node_net = require("node:net");
38
+ node_net = __toESM(node_net, 1);
39
+ let node_os = require("node:os");
40
+ node_os = __toESM(node_os, 1);
41
+ let _xterm_headless = require("@xterm/headless");
42
+ _xterm_headless = __toESM(_xterm_headless, 1);
43
+ let node_module = require("node:module");
44
+ let _rmux_sdk = require("@rmux/sdk");
45
+ let node_child_process = require("node:child_process");
46
+ let node_util = require("node:util");
47
+ let _agimon_ai_doompi_core_child_process = require("@agimon-ai/doompi-core/child-process");
48
+ let _agimon_ai_foundation_process_registry = require("@agimon-ai/foundation-process-registry");
49
+ //#region src/extensions/workspaces/sessions/(backend)/skill/doompi-use-runner.mcp.ts
50
+ var doompi_use_runner_mcp_default = (0, _agimon_ai_doompi_core_mcp_facet.defineMcpSkill)({
51
+ name: "doompi-use-runner",
52
+ description: "Use Doom Pi Runner to supervise shell commands, inspect durable logs, provide interactive input, and stop background runs.",
53
+ read: () => (0, _agimon_ai_doompi_core_server_facet.readPackageResource)(require("url").pathToFileURL(__filename).href, "src/prompts/doompi-use-runner/SKILL.md")
54
+ });
55
+ //#endregion
56
+ //#region src/constants/bashTool.ts
57
+ const BASH_TOOL_NAME = "bash";
58
+ const BASH_TOOL_LABEL = "bash";
59
+ //#endregion
60
+ //#region src/constants/headless.ts
61
+ const MS_PER_SECOND = 1e3;
62
+ //#endregion
63
+ //#region src/schemas/bashTool.ts
64
+ /** Replaces pi's built-in tool of the same name, so hooks keyed on `bash` keep working. */
65
+ /**
66
+ * Built-in bash parameters plus the backgrounding controls.
67
+ *
68
+ * `command` and `timeout` keep their original names and wording so the tool is
69
+ * a drop-in replacement. Every `description` is prompt copy the model reads.
70
+ */
71
+ const BashParamsSchema = typebox.Type.Object({
72
+ command: typebox.Type.String({ description: "Bash command to execute" }),
73
+ timeout: typebox.Type.Optional(typebox.Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
74
+ background: typebox.Type.Optional(typebox.Type.Boolean({ description: "Start the command in the background immediately instead of waiting. Use for dev servers, watchers and other commands you already know will not finish." })),
75
+ interactive: typebox.Type.Optional(typebox.Type.Boolean({ description: "Run under a pseudo terminal so the command can prompt. Use only when the command asks for confirmation or input; answer it with doom-runner input. Logs from interactive runs are noisier." })),
76
+ name: typebox.Type.Optional(typebox.Type.String({ description: "Preferred runner name if the command ends up in the background. A name in use gets a suffix." }))
77
+ });
78
+ //#endregion
79
+ //#region src/constants/bashResult.ts
80
+ const BYTES_PER_KB = 1024;
81
+ const BYTES_PER_MB = 1048576;
82
+ const PRAGMA_PATTERN = /^\s*(?:#|\/\/)\s*@doom:\s*(\{.*\})\s*$/u;
83
+ const PRAGMA_BYTES_CEILING = 262144;
84
+ const PRAGMA_LINES_CEILING = 5e3;
85
+ const PRAGMA_TOKENS_CEILING = 1e5;
86
+ //#endregion
87
+ //#region src/constants/ansiScrub.ts
88
+ const CONTROL_PATTERN = /[\u0007\u0008]/g;
89
+ //#endregion
90
+ //#region src/services/ansiScrub/index.ts
91
+ /**
92
+ * Turns terminal output into text worth grepping.
93
+ *
94
+ * PTY output is written for a screen, not a file: colours, cursor moves and
95
+ * carriage-return redraws all end up in the byte stream. Without this a single
96
+ * progress bar becomes thousands of near-identical log lines.
97
+ */
98
+ /**
99
+ * CSI sequences (colours, cursor moves), OSC strings, the two-byte Fe escapes,
100
+ * and the keypad-mode escapes shells emit around every prompt.
101
+ */
102
+ const ANSI_PATTERN = new RegExp([
103
+ "\\u001B\\[[0-9;?]*[ -/]*[@-~]",
104
+ "\\u001B\\][^\\u0007\\u001B]*(?:\\u0007|\\u001B\\\\)",
105
+ "\\u001B[@-Z\\\\-_]",
106
+ "\\u001B[=><]"
107
+ ].join("|"), "g");
108
+ /** Bell and backspace, which a terminal consumes rather than displays. */
109
+ /**
110
+ * The same set minus SGR, the sequence that only sets colour and weight.
111
+ *
112
+ * The final byte class omits `m`, so `ESC[32m` survives while cursor moves,
113
+ * OSC strings and keypad escapes are still removed.
114
+ */
115
+ const ANSI_PATTERN_KEEPING_COLOUR = new RegExp([
116
+ "\\u001B\\[[0-9;?]*[ -/]*[@-ln-~]",
117
+ "\\u001B\\][^\\u0007\\u001B]*(?:\\u0007|\\u001B\\\\)",
118
+ "\\u001B[@-Z\\\\-_]",
119
+ "\\u001B[=><]"
120
+ ].join("|"), "g");
121
+ /** Removes escape sequences, leaving the characters a user would have seen. */
122
+ function stripAnsi(text) {
123
+ return text.replace(ANSI_PATTERN, "").replace(CONTROL_PATTERN, "");
124
+ }
125
+ /**
126
+ * Removes everything `stripAnsi` does except colour.
127
+ *
128
+ * Logs are read back by a terminal renderer as well as by grep, and a command
129
+ * that colours its own output has already said how it should look.
130
+ */
131
+ function stripAnsiKeepingColour(text) {
132
+ return text.replace(ANSI_PATTERN_KEEPING_COLOUR, "").replace(CONTROL_PATTERN, "");
133
+ }
134
+ /**
135
+ * Collapses carriage-return redraws.
136
+ *
137
+ * A bare `\r` returns the cursor to the start of the current line, so whatever
138
+ * follows overwrites it. Only the final state of each line is kept. The `\r` of
139
+ * a CRLF pair is a line terminator rather than a redraw, so it is normalised
140
+ * away first: a pseudo terminal ends every line that way, and treating it as a
141
+ * redraw would blank the whole log.
142
+ */
143
+ function collapseCarriageReturns(text) {
144
+ return text.replace(/\r\n/g, "\n").split("\n").map((line) => line.includes("\r") ? line.split("\r").at(-1) ?? "" : line).join("\n");
145
+ }
146
+ /** Full scrub applied to PTY output before it reaches the log file. */
147
+ function scrubTerminalOutput(text) {
148
+ return collapseCarriageReturns(stripAnsiKeepingColour(text));
149
+ }
150
+ //#endregion
151
+ //#region src/constants/runnerConfig.ts
152
+ const BG_THRESHOLD_MS_ENV = "DOOM_RUNNER_BG_THRESHOLD_MS";
153
+ const RESULT_MAX_BYTES_ENV = "DOOM_RUNNER_RESULT_MAX_BYTES";
154
+ const SUCCESS_RESULT_MAX_BYTES_ENV = "DOOM_RUNNER_SUCCESS_RESULT_MAX_BYTES";
155
+ const RESULT_MAX_TOKENS_ENV = "DOOM_RUNNER_RESULT_MAX_TOKENS";
156
+ const SUCCESS_RESULT_MAX_TOKENS_ENV = "DOOM_RUNNER_SUCCESS_RESULT_MAX_TOKENS";
157
+ const LOG_MAX_BYTES_ENV = "DOOM_RUNNER_LOG_MAX_BYTES";
158
+ const MAX_COMPLETED_RUNNERS_ENV = "DOOM_RUNNER_MAX_COMPLETED_RUNNERS";
159
+ const LOG_DIR_ENV = "DOOM_RUNNER_LOG_DIR";
160
+ const DEFAULT_BG_THRESHOLD_MS = 6e4;
161
+ const DEFAULT_LOG_MAX_BYTES = 5242880;
162
+ //#endregion
163
+ //#region src/models/runnerSettings.ts
164
+ const runnerSettingsState = { value: {} };
165
+ //#endregion
166
+ //#region src/services/runnerConfig/index.ts
167
+ /**
168
+ * Project settings sit below the environment on purpose: a checked-in file is
169
+ * the project's baseline, while an environment variable is set for one run.
170
+ */
171
+ /** A bash call still running at this point is promoted to a named runner. */
172
+ /** Tool results past this size are excerpted and spilled to the log file. */
173
+ /** Line ceiling for a single tool result, applied alongside the byte ceiling. */
174
+ /**
175
+ * A successful command gets a much smaller budget than a failing one. Exiting 0
176
+ * has already reported the outcome, so its output buys far less than a failure's.
177
+ */
178
+ /**
179
+ * Token ceilings sit alongside the byte ones because bytes do not track context
180
+ * cost: 8 KiB of prose is roughly 2k tokens, while 8 KiB of base64 or CJK is
181
+ * several times that. Whichever ceiling binds first wins.
182
+ */
183
+ /** Share of the budget spent on the leading excerpt; the remainder goes to the tail. */
184
+ /** Share reserved for errors rescued from the elided middle, when any exist. */
185
+ /** Distinct failures rescued from the elided middle. */
186
+ /** Variants joined inside one bracket before the rest become a count. */
187
+ /** Legacy rotation value retained so existing configuration imports remain compatible. */
188
+ /** Retention window for logs with no live registry record. */
189
+ /**
190
+ * Completed runners a single session keeps on disk.
191
+ *
192
+ * A count bound, not just an age one, because a session that stays open for a
193
+ * working day never ages its own records past the TTL: they would accumulate
194
+ * for as long as the session lives.
195
+ */
196
+ function positiveNumber(raw, fallback) {
197
+ const value = Number(raw);
198
+ return Number.isFinite(value) && value > 0 ? value : fallback;
199
+ }
200
+ /** Age at which a bash call becomes a background runner. */
201
+ function getBackgroundThresholdMs(env = process.env) {
202
+ return positiveNumber(env[BG_THRESHOLD_MS_ENV], DEFAULT_BG_THRESHOLD_MS);
203
+ }
204
+ /** Byte ceiling for a single tool result before tail truncation kicks in. */
205
+ function getResultMaxBytes(env = process.env) {
206
+ return positiveNumber(env[RESULT_MAX_BYTES_ENV], runnerSettingsState.value.maxResultBytes ?? 8192);
207
+ }
208
+ /** Legacy rotation setting retained for public API compatibility. */
209
+ function getLogMaxBytes(env = process.env) {
210
+ return positiveNumber(env[LOG_MAX_BYTES_ENV], DEFAULT_LOG_MAX_BYTES);
211
+ }
212
+ /** Completed runners one session retains before the oldest are swept. */
213
+ function getMaxCompletedRunners(env = process.env) {
214
+ return positiveNumber(env[MAX_COMPLETED_RUNNERS_ENV], 100);
215
+ }
216
+ /** Byte ceiling for the result of a command that succeeded. */
217
+ function getSuccessResultMaxBytes(env = process.env) {
218
+ return positiveNumber(env[SUCCESS_RESULT_MAX_BYTES_ENV], runnerSettingsState.value.successMaxResultBytes ?? 2048);
219
+ }
220
+ /** Token ceiling for a single tool result. */
221
+ function getResultMaxTokens(env = process.env) {
222
+ return positiveNumber(env[RESULT_MAX_TOKENS_ENV], runnerSettingsState.value.maxResultTokens ?? 2048);
223
+ }
224
+ /** Token ceiling for the result of a command that succeeded. */
225
+ function getSuccessResultMaxTokens(env = process.env) {
226
+ return positiveNumber(env[SUCCESS_RESULT_MAX_TOKENS_ENV], runnerSettingsState.value.successMaxResultTokens ?? 512);
227
+ }
228
+ /** Line ceiling for a single tool result. */
229
+ function getResultMaxLines() {
230
+ return runnerSettingsState.value.maxResultLines ?? 120;
231
+ }
232
+ function getHeadRatio() {
233
+ return runnerSettingsState.value.headRatio ?? .2;
234
+ }
235
+ function getErrorBudgetRatio() {
236
+ return runnerSettingsState.value.errorBudgetRatio ?? .2;
237
+ }
238
+ function getErrorMaxEntries() {
239
+ return runnerSettingsState.value.errorMaxEntries ?? 10;
240
+ }
241
+ function getErrorMaxVariantsJoined() {
242
+ return runnerSettingsState.value.errorMaxVariantsJoined ?? 6;
243
+ }
244
+ /** Extra severity patterns appended to the built-in matcher. */
245
+ function getErrorPatterns() {
246
+ return runnerSettingsState.value.errorPatterns ?? [];
247
+ }
248
+ //#endregion
249
+ //#region src/constants/tokenEstimate.ts
250
+ const MIXED_CHARS_PER_TOKEN = 2.1;
251
+ const ALPHANUMERIC_RUN = /([^\p{L}\p{N}]+)/u;
252
+ const HAS_ALPHANUMERIC = /[\p{L}\p{N}]/u;
253
+ const HAS_DIGIT = /\d/u;
254
+ const HAS_LETTER = /[A-Za-z]/u;
255
+ //#endregion
256
+ //#region src/services/tokenEstimate/index.ts
257
+ /**
258
+ * Estimates BPE tokens without carrying a vocabulary.
259
+ *
260
+ * Bytes are a poor proxy for what a result costs: measured against
261
+ * `gpt-tokenizer`, real text ranges from 1.4 characters per token for base64 to
262
+ * 4.7 for English prose, a spread of more than three. Truncation runs on every
263
+ * command and is synchronous, so loading a real tokenizer here would charge
264
+ * every session for a table most of them never need.
265
+ *
266
+ * The shape below follows how BPE actually behaves: punctuation forces token
267
+ * boundaries, natural words are cheap, and runs mixing letters with digits are
268
+ * expensive. Calibrated against `gpt-tokenizer` over build logs, source, JSON,
269
+ * stack traces, CJK, base64, hex, and UUIDs, the worst error is a factor of
270
+ * 1.46 either way. It stays an estimate, so the byte ceiling remains the hard
271
+ * bound behind it.
272
+ */
273
+ /** Characters per token for a natural word. */
274
+ /** Characters per token for a run mixing letters and digits, which tokenizes poorly. */
275
+ /** Characters per token for a run of punctuation or whitespace. */
276
+ /** Dense scripts such as CJK cost about one token per character. */
277
+ function estimateTokens(text) {
278
+ let tokens = 0;
279
+ for (const run of text.split(ALPHANUMERIC_RUN)) {
280
+ if (run.length === 0) continue;
281
+ if (!HAS_ALPHANUMERIC.test(run)) {
282
+ tokens += run.length / 2;
283
+ continue;
284
+ }
285
+ let ascii = 0;
286
+ let dense = 0;
287
+ let astral = 0;
288
+ for (const character of run) {
289
+ const code = character.codePointAt(0) ?? 0;
290
+ if (code > 65535) astral += 1;
291
+ else if (code > 12287) dense += 1;
292
+ else ascii += 1;
293
+ }
294
+ tokens += astral * 2 + dense;
295
+ if (ascii > 0) {
296
+ const mixed = HAS_DIGIT.test(run) && HAS_LETTER.test(run);
297
+ tokens += Math.max(1, ascii / (mixed ? MIXED_CHARS_PER_TOKEN : 5));
298
+ }
299
+ }
300
+ return Math.ceil(tokens);
301
+ }
302
+ //#endregion
303
+ //#region src/services/bashResult/index.ts
304
+ /** Variants retained per group while scanning; the rest only add to the count. */
305
+ /** Below this much shared text, joining reads worse than separate lines. */
306
+ /**
307
+ * Severity anywhere in the opening of the line, not only at its start. Real
308
+ * tooling buries it behind a tag or a timestamp: `[widget] build: error TS2322`.
309
+ */
310
+ const SEVERITY_TOKEN = /(?<![\w\-/=.])(?:errors?|fatal|panic|exception|traceback|assertion|assert|fail(?:ed|ure|ures)?)\b/iu;
311
+ /** `0 errors` and `no failures` are the success case wearing the same words. */
312
+ const NEGATED_SEVERITY = /\b(?:no|not|without|zero|0)\s+$/iu;
313
+ function textResult(text, details = {}) {
314
+ return {
315
+ content: [{
316
+ type: "text",
317
+ text
318
+ }],
319
+ details
320
+ };
321
+ }
322
+ /** Human-readable byte count, matching how pi reports output sizes. */
323
+ function formatSize(bytes) {
324
+ if (bytes < 1024) return `${bytes} B`;
325
+ if (bytes < 1048576) return `${(bytes / BYTES_PER_KB).toFixed(1)} KB`;
326
+ return `${(bytes / BYTES_PER_MB).toFixed(1)} MB`;
327
+ }
328
+ function formatCount(value) {
329
+ return value.toLocaleString("en-US");
330
+ }
331
+ function countLines(text) {
332
+ if (text.length === 0) return 0;
333
+ return text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length;
334
+ }
335
+ /** Accumulates grouped error lines while a log streams past, without holding it. */
336
+ var ErrorScan = class {
337
+ maxEntries;
338
+ byKey = /* @__PURE__ */ new Map();
339
+ constructor(maxEntries = getErrorMaxEntries()) {
340
+ this.maxEntries = maxEntries;
341
+ }
342
+ push(line) {
343
+ if (!isErrorLine(line)) return;
344
+ const trimmed = line.trim();
345
+ const key = normalizeErrorLine(line);
346
+ const existing = this.byKey.get(key);
347
+ if (existing) {
348
+ existing.count += 1;
349
+ if (!existing.variants.includes(trimmed) && existing.variants.length < 12) existing.variants.push(trimmed);
350
+ return;
351
+ }
352
+ if (this.byKey.size >= this.maxEntries) return;
353
+ this.byKey.set(key, {
354
+ variants: [trimmed],
355
+ count: 1
356
+ });
357
+ }
358
+ entries() {
359
+ return [...this.byKey.values()];
360
+ }
361
+ };
362
+ function createErrorScanner(maxEntries = getErrorMaxEntries()) {
363
+ return new ErrorScan(maxEntries);
364
+ }
365
+ /** Codex-style first-line pragma, for the one command that needs a wider result. */
366
+ function positiveInteger(value) {
367
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : void 0;
368
+ }
369
+ /**
370
+ * Reads `# @doom: {"maxResultBytes": 32768}` from the command's first line.
371
+ *
372
+ * The pragma is a shell comment, so it stays harmless if this parse is ever
373
+ * skipped, and it survives verbatim into the log where a wider result can be
374
+ * explained after the fact.
375
+ */
376
+ function parseResultPragma(command) {
377
+ const match = PRAGMA_PATTERN.exec(command.split("\n", 1)[0] ?? "");
378
+ if (!match?.[1]) return {};
379
+ let parsed;
380
+ try {
381
+ parsed = JSON.parse(match[1]);
382
+ } catch {
383
+ return {};
384
+ }
385
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
386
+ const source = parsed;
387
+ const maxBytes = positiveInteger(source.maxResultBytes);
388
+ const maxLines = positiveInteger(source.maxResultLines);
389
+ const maxTokens = positiveInteger(source.maxResultTokens);
390
+ return {
391
+ ...maxBytes === void 0 ? {} : { maxBytes: Math.min(maxBytes, PRAGMA_BYTES_CEILING) },
392
+ ...maxLines === void 0 ? {} : { maxLines: Math.min(maxLines, PRAGMA_LINES_CEILING) },
393
+ ...maxTokens === void 0 ? {} : { maxTokens: Math.min(maxTokens, PRAGMA_TOKENS_CEILING) }
394
+ };
395
+ }
396
+ function isErrorLine(line) {
397
+ const opening = line.slice(0, 60);
398
+ const match = SEVERITY_TOKEN.exec(opening);
399
+ if (match && !NEGATED_SEVERITY.test(opening.slice(0, match.index))) return true;
400
+ return getErrorPatterns().some((pattern) => matchesCustomPattern(pattern, line));
401
+ }
402
+ const customPatterns = /* @__PURE__ */ new Map();
403
+ function matchesCustomPattern(pattern, line) {
404
+ if (!customPatterns.has(pattern)) try {
405
+ customPatterns.set(pattern, new RegExp(pattern, "iu"));
406
+ } catch {
407
+ customPatterns.set(pattern, void 0);
408
+ }
409
+ return customPatterns.get(pattern)?.test(line) ?? false;
410
+ }
411
+ /** Masks the parts that differ between repeats of the same underlying failure. */
412
+ function normalizeErrorLine(line) {
413
+ return line.replace(/0x[0-9a-f]+/giu, "0x#").replace(/\/[^\s:,)]+/gu, "/#").replace(/\d+/gu, "#").trim().toLowerCase();
414
+ }
415
+ /**
416
+ * Collapses repeats so one failure repeated two hundred times costs one slot.
417
+ * Counting continues past the entry cap so the tallies stay honest.
418
+ */
419
+ function collectErrorLines(lines, maxEntries = getErrorMaxEntries()) {
420
+ const scan = new ErrorScan(maxEntries);
421
+ for (const line of lines) scan.push(line);
422
+ return scan.entries();
423
+ }
424
+ function commonAffixLengths(values) {
425
+ const shortest = values.reduce((min, value) => Math.min(min, value.length), Infinity);
426
+ let prefix = 0;
427
+ while (prefix < shortest && values.every((value) => value[prefix] === values[0][prefix])) prefix += 1;
428
+ let suffix = 0;
429
+ while (suffix < shortest - prefix && values.every((value) => value[value.length - 1 - suffix] === values[0][values[0].length - 1 - suffix])) suffix += 1;
430
+ return {
431
+ prefix,
432
+ suffix
433
+ };
434
+ }
435
+ /** Back off to a whitespace boundary so the brackets hold whole tokens. */
436
+ function retreatToBoundary(value, length) {
437
+ const boundary = value.lastIndexOf(" ", Math.max(0, length - 1));
438
+ return boundary >= 0 ? boundary + 1 : 0;
439
+ }
440
+ function advanceToBoundary(value, length) {
441
+ const cut = value.length - length;
442
+ const boundary = value.indexOf(" ", cut);
443
+ return boundary >= 0 ? value.length - boundary : 0;
444
+ }
445
+ /**
446
+ * One line per distinct failure would be ideal, but repeats of one failure
447
+ * across many files are the common case. Sharing the fixed text and joining
448
+ * only the parts that differ keeps every file name without paying for the
449
+ * message N times.
450
+ */
451
+ function renderGroup(group) {
452
+ const [first] = group.variants;
453
+ if (first === void 0) return "";
454
+ if (group.variants.length === 1) return group.count > 1 ? `${first} (\u00d7${formatCount(group.count)})` : first;
455
+ const shown = group.variants.slice(0, getErrorMaxVariantsJoined());
456
+ const { prefix, suffix } = commonAffixLengths(shown);
457
+ const head = first.slice(0, retreatToBoundary(first, prefix));
458
+ const tailLength = advanceToBoundary(first, suffix);
459
+ const tail = tailLength > 0 ? first.slice(first.length - tailLength) : "";
460
+ if (head.length + tail.length < 8) return shown.join("\n");
461
+ const middles = shown.map((value) => value.slice(head.length, value.length - tail.length));
462
+ const overflow = group.variants.length - shown.length;
463
+ return `${head}[${overflow > 0 ? `${middles.join("|")}|+${formatCount(overflow)}` : middles.join("|")}]${tail}`;
464
+ }
465
+ function renderSalvaged(errors, maxBytes) {
466
+ const rendered = [];
467
+ let used = 0;
468
+ for (const group of errors) {
469
+ const text = renderGroup(group);
470
+ if (text.length === 0) continue;
471
+ const cost = Buffer.byteLength(text, "utf8") + 1;
472
+ if (used + cost > maxBytes) break;
473
+ rendered.push(text);
474
+ used += cost;
475
+ }
476
+ return rendered;
477
+ }
478
+ function elisionMarker(elidedLines, salvagedCount = 0) {
479
+ const unit = elidedLines === 1 ? "line" : "lines";
480
+ const errors = salvagedCount > 0 ? `, ${formatCount(salvagedCount)} shown below` : "";
481
+ return `\u2026 [${formatCount(elidedLines)} ${unit} elided${errors}] \u2026`;
482
+ }
483
+ function splitTextLines(text) {
484
+ const trailingNewline = text.endsWith("\n");
485
+ const lines = text.split("\n");
486
+ if (trailingNewline) lines.pop();
487
+ return {
488
+ lines,
489
+ trailingNewline
490
+ };
491
+ }
492
+ /** Whole lines only: a fragment of a line reads as data that was never there. */
493
+ function takeSide(lines, maxCount, maxBytes, fromEnd, maxTokens = Number.POSITIVE_INFINITY) {
494
+ if (maxBytes <= 0 || maxCount <= 0) return {
495
+ text: "",
496
+ count: 0
497
+ };
498
+ const ordered = fromEnd ? [...lines].reverse() : [...lines];
499
+ const taken = [];
500
+ let used = 0;
501
+ let tokens = 0;
502
+ for (const line of ordered) {
503
+ if (taken.length >= maxCount) break;
504
+ const cost = Buffer.byteLength(line, "utf8") + (taken.length > 0 ? 1 : 0);
505
+ const lineTokens = estimateTokens(line) + (taken.length > 0 ? 1 : 0);
506
+ if (used + cost > maxBytes || tokens + lineTokens > maxTokens) break;
507
+ taken.push(line);
508
+ used += cost;
509
+ tokens += lineTokens;
510
+ }
511
+ if (taken.length === 0) return {
512
+ text: "",
513
+ count: 0
514
+ };
515
+ const restored = fromEnd ? taken.reverse() : taken;
516
+ return {
517
+ text: restored.join("\n"),
518
+ count: restored.length
519
+ };
520
+ }
521
+ /**
522
+ * Used only when no whole line fits either side, which means the source has no
523
+ * usable line boundary. A clamped fragment beats returning nothing at all.
524
+ */
525
+ function clampedFragment(text, limitBytes) {
526
+ const clamped = utf8Tail$1(Buffer.from(text, "utf8"), limitBytes);
527
+ const newline = clamped.indexOf("\n");
528
+ const whole = newline >= 0 ? clamped.slice(newline + 1) : clamped;
529
+ return {
530
+ text: whole,
531
+ count: whole.length > 0 ? 1 : 0
532
+ };
533
+ }
534
+ /** Charged against the budget so the composed excerpt still honours the ceiling. */
535
+ function markerReservation(totalLines) {
536
+ return Buffer.byteLength(elisionMarker(totalLines), "utf8") + 2;
537
+ }
538
+ function assembleSides(head, tail, elidedLines, trailingNewline, limitBytes, salvaged = []) {
539
+ const opening = elidedLines > 0 ? [elisionMarker(elidedLines, salvaged.length)] : [];
540
+ const closing = elidedLines > 0 && salvaged.length > 0 ? ["…"] : [];
541
+ const body = [
542
+ head.text,
543
+ ...opening,
544
+ ...salvaged,
545
+ ...closing,
546
+ tail.text
547
+ ].filter((part) => part.length > 0).join("\n");
548
+ const withNewline = trailingNewline && body.length > 0 ? `${body}\n` : body;
549
+ const encoded = Buffer.from(withNewline, "utf8");
550
+ return {
551
+ text: encoded.byteLength > limitBytes ? utf8Tail$1(encoded, limitBytes) : withNewline,
552
+ lines: head.count + tail.count,
553
+ truncated: true,
554
+ elidedLines: Math.max(0, elidedLines)
555
+ };
556
+ }
557
+ function budgetFor(maxLines, maxBytes, totalLines, maxTokens) {
558
+ const limitLines = Math.max(1, Math.floor(maxLines));
559
+ const usable = Math.max(0, maxBytes - markerReservation(totalLines));
560
+ const tokens = Math.max(1, Math.floor(maxTokens) - estimateTokens(elisionMarker(totalLines)));
561
+ return {
562
+ limitLines,
563
+ headLines: Math.max(1, Math.floor(limitLines * getHeadRatio())),
564
+ headBytes: Math.max(0, Math.floor(usable * getHeadRatio())),
565
+ headTokens: Math.max(1, Math.floor(tokens * getHeadRatio())),
566
+ usable,
567
+ tokens
568
+ };
569
+ }
570
+ /**
571
+ * Bounds text to the budget, keeping a leading excerpt as well as the tail.
572
+ *
573
+ * Failures announce themselves at both ends: a command that could not start
574
+ * says so on line one, and a command that died says so on the last. Keeping
575
+ * only the tail loses the first kind entirely.
576
+ */
577
+ function boundExcerpt(text, maxLines = getResultMaxLines(), maxBytes = getResultMaxBytes(), maxTokens = getResultMaxTokens()) {
578
+ if (text.length === 0) return {
579
+ text: "",
580
+ lines: 0,
581
+ truncated: false,
582
+ elidedLines: 0
583
+ };
584
+ const { lines, trailingNewline } = splitTextLines(text);
585
+ const totalBytes = Buffer.byteLength(text, "utf8");
586
+ const limitBytes = Number.isFinite(maxBytes) ? Math.max(0, Math.floor(maxBytes)) : totalBytes;
587
+ const limitLines = Math.max(1, Math.floor(maxLines));
588
+ if (lines.length <= limitLines && totalBytes <= limitBytes && estimateTokens(text) <= maxTokens) return {
589
+ text,
590
+ lines: lines.length,
591
+ truncated: false,
592
+ elidedLines: 0
593
+ };
594
+ const budget = budgetFor(maxLines, limitBytes, lines.length, maxTokens);
595
+ const head = takeSide(lines, budget.headLines, budget.headBytes, false, budget.headTokens);
596
+ const rest = lines.slice(head.count);
597
+ const errorBytes = rest.some(isErrorLine) ? Math.floor(budget.usable * getErrorBudgetRatio()) : 0;
598
+ const tailBytes = budget.usable - Buffer.byteLength(head.text, "utf8") - errorBytes;
599
+ const tailTokens = budget.tokens - estimateTokens(head.text);
600
+ const tail = takeSide(rest, Math.max(1, budget.limitLines - head.count), tailBytes, true, tailTokens);
601
+ if (head.count === 0 && tail.count === 0) {
602
+ const fragment = clampedFragment(text, limitBytes);
603
+ return {
604
+ text: fragment.text,
605
+ lines: fragment.count,
606
+ truncated: true,
607
+ elidedLines: lines.length - fragment.count
608
+ };
609
+ }
610
+ const middle = lines.slice(head.count, lines.length - tail.count);
611
+ const salvaged = errorBytes > 0 ? renderSalvaged(collectErrorLines(middle), errorBytes) : [];
612
+ return assembleSides(head, tail, lines.length - head.count - tail.count, trailingNewline, limitBytes, salvaged);
613
+ }
614
+ /**
615
+ * Same budget as `boundExcerpt`, for callers that streamed the two ends
616
+ * separately and never held the middle. `totalLines` counts the whole source,
617
+ * so the marker states what was dropped rather than what was captured.
618
+ */
619
+ function composeExcerpt(headSource, tailSource, totalLines, maxLines = getResultMaxLines(), maxBytes = getResultMaxBytes(), errors = [], maxTokens = getResultMaxTokens()) {
620
+ const limitBytes = Number.isFinite(maxBytes) ? Math.max(0, Math.floor(maxBytes)) : Number.MAX_SAFE_INTEGER;
621
+ const budget = budgetFor(maxLines, limitBytes, totalLines, maxTokens);
622
+ const headLines = splitTextLines(headSource).lines;
623
+ const { lines: tailLines, trailingNewline } = splitTextLines(tailSource);
624
+ const head = takeSide(headLines.slice(0, Math.max(0, headLines.length - 1)), budget.headLines, budget.headBytes, false, budget.headTokens);
625
+ const errorBytes = errors.length > 0 ? Math.floor(budget.usable * getErrorBudgetRatio()) : 0;
626
+ const tailBytes = budget.usable - Buffer.byteLength(head.text, "utf8") - errorBytes;
627
+ const tailTokens = budget.tokens - estimateTokens(head.text);
628
+ const tail = takeSide(tailLines.slice(1), Math.max(1, budget.limitLines - head.count), tailBytes, true, tailTokens);
629
+ if (head.count === 0 && tail.count === 0) {
630
+ const fragment = clampedFragment(tailSource, limitBytes);
631
+ return {
632
+ text: fragment.text,
633
+ lines: fragment.count,
634
+ truncated: true,
635
+ elidedLines: Math.max(0, totalLines - fragment.count)
636
+ };
637
+ }
638
+ const salvaged = errorBytes > 0 ? renderSalvaged(errors, errorBytes) : [];
639
+ return assembleSides(head, tail, Math.max(0, totalLines - head.count - tail.count), trailingNewline, limitBytes, salvaged);
640
+ }
641
+ /** Bounds the complete model-facing result after status and recovery text are attached. */
642
+ function boundResultText(text, maxBytes = getResultMaxBytes()) {
643
+ return boundExcerpt(text, getResultMaxLines(), maxBytes).text;
644
+ }
645
+ /**
646
+ * Keeps both ends of an oversized result and points at the file holding the rest.
647
+ *
648
+ * The notice states the file's real size and line count so the model does not
649
+ * guess at offsets when it goes on to read the file, which is the failure this
650
+ * whole policy exists to prevent.
651
+ */
652
+ function utf8Tail$1(encoded, maxBytes) {
653
+ let start = Math.max(0, encoded.byteLength - maxBytes);
654
+ while (start < encoded.byteLength && (encoded[start] & 192) === 128) start += 1;
655
+ return encoded.subarray(start).toString("utf8");
656
+ }
657
+ const PROMOTION_REASONS = {
658
+ requested: "Started in the background",
659
+ threshold: "Still running after the background threshold",
660
+ interactive: "Started interactively"
661
+ };
662
+ function completionFailed(result) {
663
+ return result.timedOut === true || result.signal !== null || result.exitCode !== null && result.exitCode !== 0;
664
+ }
665
+ function completionStatus(result) {
666
+ if (result.timedOut === true) return "Timed out: exceeded the requested timeout.";
667
+ if (result.signal !== null) return `Signal: ${result.signal}`;
668
+ if (result.exitCode === null) return "Exit status unavailable.";
669
+ if (result.exitCode !== 0) return `Exit: ${result.exitCode}`;
670
+ }
671
+ const emptyLogSummary = () => ({
672
+ tail: "",
673
+ bytes: 0,
674
+ lines: 0,
675
+ tailLines: 0
676
+ });
677
+ function formatRunResult(result, budget = {}, summarizeLog = emptyLogSummary) {
678
+ if (result.kind === "failed") throw new Error([`Could not start runner "${result.name}": ${result.error}`, "Next: correct the reported launch or supervision problem. Retry only after changing the command or environment."].join("\n"));
679
+ if (result.kind === "promoted") return textResult([
680
+ `${PROMOTION_REASONS[result.reason]}: runner "${result.name}" (${result.id}).`,
681
+ `Streaming log: ${result.logPath}`,
682
+ `Inspect: doom-runner logs ${result.id}`
683
+ ].join("\n"), {
684
+ id: result.id,
685
+ runner: result.name,
686
+ pid: result.pid,
687
+ logPath: result.logPath,
688
+ promoted: true,
689
+ reason: result.reason
690
+ });
691
+ const succeeded = !completionFailed(result);
692
+ const maxLines = budget.maxLines ?? getResultMaxLines();
693
+ const maxBytes = budget.maxBytes ?? (succeeded ? getSuccessResultMaxBytes() : getResultMaxBytes());
694
+ const maxTokens = budget.maxTokens ?? (succeeded ? getSuccessResultMaxTokens() : getResultMaxTokens());
695
+ const log = summarizeLog(result.logPath, maxLines, maxBytes, maxTokens);
696
+ const useCapturedOutput = !result.rtkOutput && log.tail.length === 0 && result.output.length > 0;
697
+ let tail;
698
+ let tailLines;
699
+ let outputLines;
700
+ let outputBytes;
701
+ let truncated;
702
+ if (result.rtkOutput) {
703
+ const bounded = Buffer.byteLength(result.rtkOutput.output, "utf8") < result.rtkOutput.bytes ? composeExcerpt(result.rtkOutput.head, result.rtkOutput.output, result.rtkOutput.lines, maxLines, maxBytes, [], maxTokens) : boundExcerpt(result.rtkOutput.output, maxLines, maxBytes, maxTokens);
704
+ tail = bounded.text;
705
+ tailLines = bounded.lines;
706
+ outputLines = result.rtkOutput.lines;
707
+ outputBytes = result.rtkOutput.bytes;
708
+ truncated = outputLines > tailLines || outputBytes > Buffer.byteLength(tail, "utf8");
709
+ } else {
710
+ tail = useCapturedOutput ? result.output : log.tail;
711
+ tailLines = useCapturedOutput ? countLines(tail) : log.tailLines;
712
+ outputLines = Math.max(log.lines, tailLines);
713
+ outputBytes = useCapturedOutput ? Buffer.byteLength(tail, "utf8") : log.bytes;
714
+ truncated = !useCapturedOutput && (outputLines > tailLines || outputBytes > Buffer.byteLength(tail, "utf8"));
715
+ }
716
+ const plainTail = stripAnsi(tail).replace(/\r?\n$/u, "");
717
+ const failed = completionFailed(result);
718
+ const status = completionStatus(result);
719
+ const textLines = [];
720
+ if (plainTail.length === 0) textLines.push(status === void 0 ? "Completed with no output." : "No output.");
721
+ else if (truncated) {
722
+ const label = result.rtkOutput ? `RTK ${result.rtkOutput.filter} excerpt` : "Log excerpt";
723
+ textLines.push(`${label} (${tailLines.toLocaleString("en-US")} of ${outputLines.toLocaleString("en-US")} lines):\n${plainTail}`);
724
+ } else textLines.push(plainTail);
725
+ if (status !== void 0) textLines.push(status);
726
+ if (result.rtkWarning) textLines.push(result.rtkWarning);
727
+ if (truncated) {
728
+ const label = result.rtkOutput ? "Complete raw log" : "Full log";
729
+ textLines.push(`${label}: ${result.logPath} (${formatSize(log.bytes)}, ${log.lines.toLocaleString("en-US")} lines); inspect with doom-runner logs ${result.id}`);
730
+ } else if (failed && plainTail.length === 0) textLines.push(`Log: ${result.logPath}`, "Next: run one read-only diagnostic; retry only after correcting the cause.");
731
+ const text = boundResultText(textLines.join("\n"), maxBytes);
732
+ const details = {
733
+ id: result.id,
734
+ runner: result.name,
735
+ exitCode: result.exitCode,
736
+ logPath: result.logPath,
737
+ backend: result.backend,
738
+ fileSize: log.bytes,
739
+ lines: useCapturedOutput ? tailLines : log.lines,
740
+ tail,
741
+ tailLines,
742
+ ...result.rtkOutput ? {
743
+ rtkFilter: result.rtkOutput.filter,
744
+ rtkOutputBytes: result.rtkOutput.bytes,
745
+ rtkOutputLines: result.rtkOutput.lines
746
+ } : {},
747
+ ...result.rtkWarning ? { rtkWarning: result.rtkWarning } : {},
748
+ ...result.timedOut ? { timedOut: true } : {}
749
+ };
750
+ if (failed) throw new Error(text);
751
+ return textResult(text, details);
752
+ }
753
+ //#endregion
754
+ //#region src/services/headless/index.ts
755
+ function createHeadlessBashTool(bashRunService, summarizeLog) {
756
+ return {
757
+ name: BASH_TOOL_NAME,
758
+ label: BASH_TOOL_LABEL,
759
+ description: "Execute one Bash command in the current working directory. Foreground commands return bounded output; commands that outlive the threshold return a supervised runner id and streaming log path.",
760
+ promptSnippet: "Execute shell commands with bounded foreground output and supervised background runners",
761
+ promptGuidelines: [
762
+ `A command still running after ${Math.round(getBackgroundThresholdMs() / MS_PER_SECOND)} seconds remains active as a background runner with an id and streaming log path.`,
763
+ "Pass background: true for commands that should remain active, such as dev servers, watchers, and tails.",
764
+ "Stop background runners when they are no longer needed."
765
+ ],
766
+ parameters: BashParamsSchema,
767
+ executionMode: "serial",
768
+ execute: async (_toolCallId, params, signal, onUpdate, context) => {
769
+ const { command, timeout, background, interactive, name } = params;
770
+ if (signal?.aborted) throw new Error("Operation aborted");
771
+ if (onUpdate) onUpdate(textResult(`Starting ${interactive === true ? "interactive runner" : background === true ? "background runner" : "command"}...`));
772
+ let onOutput;
773
+ if (background !== true && interactive !== true && onUpdate) onOutput = (output) => onUpdate(textResult(output));
774
+ const result = await bashRunService.run({
775
+ command,
776
+ timeoutMs: timeout === void 0 ? void 0 : timeout * MS_PER_SECOND,
777
+ background,
778
+ interactive,
779
+ name,
780
+ ...onOutput ? { onOutput } : {},
781
+ ...signal ? { signal } : {},
782
+ cwd: context.cwd,
783
+ sessionId: context.sessionId
784
+ });
785
+ if (signal?.aborted) throw new Error("Operation aborted");
786
+ return formatRunResult(result, parseResultPragma(command), summarizeLog);
787
+ }
788
+ };
789
+ }
790
+ //#endregion
791
+ //#region src/constants/logReader.ts
792
+ const READ_CHUNK_BYTES = 65536;
793
+ //#endregion
794
+ //#region src/services/logReader/index.ts
795
+ var LogReader = class {
796
+ read(logPath, query = {}) {
797
+ let handle;
798
+ try {
799
+ handle = node_fs.default.openSync(logPath, "r");
800
+ return {
801
+ ...scanLog(handle, query),
802
+ fileSize: node_fs.default.fstatSync(handle).size,
803
+ path: logPath,
804
+ exists: true
805
+ };
806
+ } catch (error) {
807
+ if (error.code !== "ENOENT") throw error;
808
+ return {
809
+ text: "",
810
+ lineCount: 0,
811
+ totalLines: 0,
812
+ fileSize: 0,
813
+ completeBytes: 0,
814
+ path: logPath,
815
+ exists: false
816
+ };
817
+ } finally {
818
+ if (handle !== void 0) node_fs.default.closeSync(handle);
819
+ }
820
+ }
821
+ };
822
+ function scanLog(handle, query) {
823
+ const limit = Math.max(0, Math.floor(query.lines ?? 200));
824
+ const needle = query.grep ? query.ignoreCase ? query.grep.toLowerCase() : query.grep : void 0;
825
+ const contextLines = Math.max(0, Math.floor(query.contextLines ?? 0));
826
+ const decoder = new node_string_decoder.StringDecoder("utf8");
827
+ const buffer = Buffer.alloc(READ_CHUNK_BYTES);
828
+ const before = [];
829
+ const output = [];
830
+ let pending = "";
831
+ let totalLines = 0;
832
+ let afterThrough = -1;
833
+ let lastEmitted = -1;
834
+ let lastByte;
835
+ let bytesSeen = 0;
836
+ let completeBytes = 0;
837
+ const retain = (line) => {
838
+ if (limit === 0) return;
839
+ output.push(line);
840
+ if (output.length > limit) output.shift();
841
+ };
842
+ const emit = (line) => {
843
+ if (line.index <= lastEmitted) return;
844
+ lastEmitted = line.index;
845
+ retain(line);
846
+ };
847
+ const consume = (text) => {
848
+ const line = {
849
+ index: totalLines,
850
+ text
851
+ };
852
+ totalLines += 1;
853
+ if (!needle) {
854
+ retain(line);
855
+ return;
856
+ }
857
+ if ((query.ignoreCase ? text.toLowerCase() : text).includes(needle)) {
858
+ for (const previous of before) emit(previous);
859
+ emit(line);
860
+ afterThrough = Math.max(afterThrough, line.index + contextLines);
861
+ } else if (line.index <= afterThrough) emit(line);
862
+ before.push(line);
863
+ if (before.length > contextLines) before.shift();
864
+ };
865
+ for (;;) {
866
+ const bytesRead = node_fs.default.readSync(handle, buffer, 0, buffer.byteLength, null);
867
+ if (bytesRead === 0) break;
868
+ lastByte = buffer[bytesRead - 1];
869
+ const lastNewline = buffer.subarray(0, bytesRead).lastIndexOf(10);
870
+ if (lastNewline >= 0) completeBytes = bytesSeen + lastNewline + 1;
871
+ bytesSeen += bytesRead;
872
+ pending += decoder.write(buffer.subarray(0, bytesRead));
873
+ let newline = pending.indexOf("\n");
874
+ while (newline >= 0) {
875
+ consume(pending.slice(0, newline));
876
+ pending = pending.slice(newline + 1);
877
+ newline = pending.indexOf("\n");
878
+ }
879
+ }
880
+ pending += decoder.end();
881
+ if (pending.length > 0) consume(pending);
882
+ const trailingNewline = lastByte === 10;
883
+ const scanned = {
884
+ text: `${output.map((line) => line.text).join("\n")}${!needle && trailingNewline && output.length > 0 ? "\n" : ""}`,
885
+ lineCount: output.length,
886
+ totalLines,
887
+ completeBytes
888
+ };
889
+ if (!needle) return scanned;
890
+ return {
891
+ ...scanned,
892
+ lineNumbers: output.map((line) => line.index + 1)
893
+ };
894
+ }
895
+ /** Reads a bounded head and tail plus exact metadata after the writer has flushed. */
896
+ function summarizeLog(path, maxLines = getResultMaxLines(), maxBytes = getResultMaxBytes(), maxTokens = getResultMaxTokens()) {
897
+ let handle;
898
+ try {
899
+ const size = node_fs.default.statSync(path).size;
900
+ handle = node_fs.default.openSync(path, "r");
901
+ const readBuffer = Buffer.alloc(READ_CHUNK_BYTES);
902
+ const endLimit = Math.max(1, maxBytes + 1);
903
+ let headBuffer = Buffer.alloc(0);
904
+ let tailBuffer = Buffer.alloc(0);
905
+ let newlineCount = 0;
906
+ let lastByte;
907
+ const decoder = new node_string_decoder.StringDecoder("utf8");
908
+ const scanned = createErrorScanner();
909
+ let pending = "";
910
+ for (;;) {
911
+ const bytesRead = node_fs.default.readSync(handle, readBuffer, 0, readBuffer.byteLength, null);
912
+ if (bytesRead === 0) break;
913
+ const chunk = readBuffer.subarray(0, bytesRead);
914
+ for (const byte of chunk) if (byte === 10) newlineCount += 1;
915
+ lastByte = chunk.at(-1);
916
+ if (headBuffer.byteLength < endLimit) headBuffer = Buffer.concat([headBuffer, chunk.subarray(0, endLimit - headBuffer.byteLength)]);
917
+ tailBuffer = appendBufferTail(tailBuffer, chunk, endLimit);
918
+ pending += decoder.write(chunk);
919
+ let newline = pending.indexOf("\n");
920
+ while (newline >= 0) {
921
+ scanned.push(pending.slice(0, newline));
922
+ pending = pending.slice(newline + 1);
923
+ newline = pending.indexOf("\n");
924
+ }
925
+ }
926
+ pending += decoder.end();
927
+ if (pending.length > 0) scanned.push(pending);
928
+ const lines = newlineCount + (size > 0 && lastByte !== 10 ? 1 : 0);
929
+ const excerpt = size <= tailBuffer.byteLength ? boundExcerpt(utf8Tail(tailBuffer, tailBuffer.byteLength), maxLines, maxBytes, maxTokens) : composeExcerpt(headBuffer.toString("utf8"), utf8Tail(tailBuffer, tailBuffer.byteLength), lines, maxLines, maxBytes, scanned.entries(), maxTokens);
930
+ return {
931
+ tail: excerpt.text,
932
+ bytes: size,
933
+ lines,
934
+ tailLines: excerpt.lines
935
+ };
936
+ } catch {
937
+ return {
938
+ tail: "",
939
+ bytes: 0,
940
+ lines: 0,
941
+ tailLines: 0
942
+ };
943
+ } finally {
944
+ if (handle !== void 0) node_fs.default.closeSync(handle);
945
+ }
946
+ }
947
+ function utf8Tail(encoded, maxBytes) {
948
+ let start = Math.max(0, encoded.byteLength - maxBytes);
949
+ while (start < encoded.byteLength && (encoded[start] & 192) === 128) start += 1;
950
+ return encoded.subarray(start).toString("utf8");
951
+ }
952
+ function appendBufferTail(current, chunk, limit) {
953
+ if (chunk.byteLength >= limit) return Buffer.from(chunk.subarray(chunk.byteLength - limit));
954
+ const combined = Buffer.concat([current, chunk]);
955
+ return combined.byteLength > limit ? Buffer.from(combined.subarray(combined.byteLength - limit)) : combined;
956
+ }
957
+ //#endregion
958
+ //#region src/constants/bashRunService.ts
959
+ const COMPLETED = "completed";
960
+ const FAILED = "failed";
961
+ const LAUNCHER_ERROR = "launcher_error";
962
+ const SIGNALED = "signaled";
963
+ //#endregion
964
+ //#region src/types/rtkProcessor.ts
965
+ /** Raw-output fallback notices. Shared so the processor and its caller cannot drift. */
966
+ const RTK_UNAVAILABLE_WARNING = "Warning: RTK is unavailable; showing raw output.";
967
+ const RTK_FAILED_WARNING = "Warning: RTK processing failed; showing raw output.";
968
+ const RTK_TIMEOUT_WARNING = "Warning: RTK processing timed out; showing raw output.";
969
+ const RTK_OVERSIZED_WARNING = "Warning: RTK input exceeds 10 MiB; showing raw output.";
970
+ //#endregion
971
+ //#region src/services/bashRunService/index.ts
972
+ /** The command outlived the background threshold and should be promoted. */
973
+ const TIMED_OUT = Symbol("threshold-reached");
974
+ /** The command outlived the caller's explicit timeout and should be stopped. */
975
+ const DEADLINE = Symbol("deadline-reached");
976
+ /** The caller cancelled the command and its owned runner should be stopped. */
977
+ const ABORTED = Symbol("aborted");
978
+ function runnerId() {
979
+ return `${Date.now().toString(36)}-${(0, node_crypto.randomBytes)(3).toString("base64url")}`;
980
+ }
981
+ var BashRunService = class {
982
+ launcher;
983
+ rmuxBackend;
984
+ namer;
985
+ registry;
986
+ clock;
987
+ rtkProcessor;
988
+ constructor(launcher, rmuxBackend, namer, registry, clock, rtkProcessor) {
989
+ this.launcher = launcher;
990
+ this.rmuxBackend = rmuxBackend;
991
+ this.namer = namer;
992
+ this.registry = registry;
993
+ this.clock = clock;
994
+ this.rtkProcessor = rtkProcessor;
995
+ }
996
+ async run(request) {
997
+ const id = runnerId();
998
+ const name = await this.namer.allocate(request.command, request.sessionId, request.name);
999
+ const cwd = request.cwd ?? process.cwd();
1000
+ const interactive = request.interactive === true;
1001
+ if (request.signal?.aborted) return {
1002
+ kind: FAILED,
1003
+ id,
1004
+ name,
1005
+ error: "Operation aborted"
1006
+ };
1007
+ let handle;
1008
+ try {
1009
+ const rmuxHandle = await this.rmuxBackend.launch({
1010
+ id,
1011
+ name,
1012
+ command: request.command,
1013
+ cwd,
1014
+ sessionId: request.sessionId,
1015
+ interactive
1016
+ });
1017
+ if (interactive && !rmuxHandle) return {
1018
+ kind: FAILED,
1019
+ id,
1020
+ name,
1021
+ error: "RMUX is required for interactive commands but is unavailable"
1022
+ };
1023
+ handle = rmuxHandle ?? this.launcher.launch({
1024
+ id,
1025
+ name,
1026
+ command: request.command,
1027
+ cwd,
1028
+ sessionId: request.sessionId
1029
+ });
1030
+ } catch (error) {
1031
+ return {
1032
+ kind: FAILED,
1033
+ id,
1034
+ name,
1035
+ error: error instanceof Error ? error.message : String(error)
1036
+ };
1037
+ }
1038
+ if (handle.pid === void 0) return {
1039
+ kind: FAILED,
1040
+ id,
1041
+ name,
1042
+ error: "The command did not start, so it cannot be supervised"
1043
+ };
1044
+ if (request.signal?.aborted) {
1045
+ await handle.stop();
1046
+ return {
1047
+ kind: FAILED,
1048
+ id,
1049
+ name,
1050
+ error: "Operation aborted"
1051
+ };
1052
+ }
1053
+ try {
1054
+ await this.registry.register({
1055
+ id,
1056
+ name,
1057
+ pid: handle.pid,
1058
+ command: request.command,
1059
+ cwd,
1060
+ logPath: handle.logPath,
1061
+ interactive,
1062
+ sessionId: request.sessionId,
1063
+ backend: handle.backend,
1064
+ ...handle.backendTarget ? { backendTarget: handle.backendTarget } : {}
1065
+ });
1066
+ } catch (error) {
1067
+ await handle.stop();
1068
+ return {
1069
+ kind: FAILED,
1070
+ id,
1071
+ name,
1072
+ error: error instanceof Error ? error.message : String(error)
1073
+ };
1074
+ }
1075
+ if (request.background === true || interactive) return this.promote(handle, request.background === true ? "requested" : "interactive", request.signal);
1076
+ const outputUpdates = request.onOutput ? this.followOutput(handle, request.onOutput) : void 0;
1077
+ try {
1078
+ const outcome = await this.race(handle, request.timeoutMs, request.signal);
1079
+ outputUpdates?.flush();
1080
+ if (outcome === TIMED_OUT) return this.promote(handle, "threshold", request.signal);
1081
+ if (outcome === ABORTED) {
1082
+ await handle.stop();
1083
+ outputUpdates?.flush();
1084
+ await this.registry.complete(id, {
1085
+ reason: "stopped",
1086
+ code: null,
1087
+ signal: "SIGTERM"
1088
+ });
1089
+ return {
1090
+ kind: COMPLETED,
1091
+ id,
1092
+ name,
1093
+ output: handle.output(),
1094
+ exitCode: null,
1095
+ signal: "SIGTERM",
1096
+ logPath: handle.logPath,
1097
+ backend: handle.backend,
1098
+ aborted: true
1099
+ };
1100
+ }
1101
+ if (outcome === DEADLINE) {
1102
+ await handle.stop();
1103
+ outputUpdates?.flush();
1104
+ await this.registry.complete(id, {
1105
+ reason: "timed_out",
1106
+ code: null,
1107
+ signal: "SIGTERM"
1108
+ });
1109
+ return {
1110
+ kind: COMPLETED,
1111
+ id,
1112
+ name,
1113
+ output: handle.output(),
1114
+ exitCode: null,
1115
+ signal: "SIGTERM",
1116
+ logPath: handle.logPath,
1117
+ backend: handle.backend,
1118
+ timedOut: true
1119
+ };
1120
+ }
1121
+ if (outcome instanceof Error) {
1122
+ await this.registry.complete(id, {
1123
+ reason: LAUNCHER_ERROR,
1124
+ code: null,
1125
+ signal: null
1126
+ });
1127
+ return {
1128
+ kind: FAILED,
1129
+ id,
1130
+ name,
1131
+ error: outcome.message
1132
+ };
1133
+ }
1134
+ await this.registry.complete(id, {
1135
+ reason: outcome.signal ? SIGNALED : outcome.code === 0 ? COMPLETED : FAILED,
1136
+ code: outcome.code,
1137
+ signal: outcome.signal
1138
+ });
1139
+ return {
1140
+ kind: COMPLETED,
1141
+ id,
1142
+ name,
1143
+ output: handle.output(),
1144
+ exitCode: outcome.code,
1145
+ signal: outcome.signal,
1146
+ logPath: handle.logPath,
1147
+ backend: handle.backend,
1148
+ ...await this.processLog(request.command, handle.logPath)
1149
+ };
1150
+ } finally {
1151
+ outputUpdates?.dispose();
1152
+ }
1153
+ }
1154
+ async processLog(command, logPath) {
1155
+ try {
1156
+ const result = await this.rtkProcessor.process({
1157
+ command,
1158
+ logPath
1159
+ });
1160
+ if (result.kind === "processed") return { rtkOutput: result.result };
1161
+ if (result.kind === "fallback") return { rtkWarning: result.warning };
1162
+ return {};
1163
+ } catch {
1164
+ return { rtkWarning: RTK_FAILED_WARNING };
1165
+ }
1166
+ }
1167
+ followOutput(handle, onOutput) {
1168
+ let active = true;
1169
+ let previous = "";
1170
+ let cancelPoll;
1171
+ const flush = () => {
1172
+ if (!active) return;
1173
+ const output = handle.output();
1174
+ if (output === previous) return;
1175
+ previous = output;
1176
+ onOutput(output);
1177
+ };
1178
+ const poll = () => {
1179
+ if (!active) return;
1180
+ flush();
1181
+ if (active) cancelPoll = this.clock.after(100, poll);
1182
+ };
1183
+ cancelPoll = this.clock.after(100, poll);
1184
+ return {
1185
+ flush,
1186
+ dispose: () => {
1187
+ active = false;
1188
+ cancelPoll?.();
1189
+ cancelPoll = void 0;
1190
+ }
1191
+ };
1192
+ }
1193
+ /**
1194
+ * Waits for the command, giving up at the background threshold or at the
1195
+ * caller's timeout, whichever comes first.
1196
+ *
1197
+ * A rejected completion is returned rather than thrown so the caller can
1198
+ * report a start failure as a tool result instead of an exception.
1199
+ */
1200
+ async race(handle, timeoutMs, signal) {
1201
+ const cancels = [];
1202
+ const racers = [handle.completion().catch((error) => error)];
1203
+ racers.push(new Promise((resolve) => {
1204
+ cancels.push(this.clock.after(getBackgroundThresholdMs(), () => resolve(TIMED_OUT)));
1205
+ }));
1206
+ if (timeoutMs !== void 0 && timeoutMs > 0) racers.push(new Promise((resolve) => {
1207
+ cancels.push(this.clock.after(timeoutMs, () => resolve(DEADLINE)));
1208
+ }));
1209
+ if (signal) racers.push(new Promise((resolve) => {
1210
+ if (signal.aborted) {
1211
+ resolve(ABORTED);
1212
+ return;
1213
+ }
1214
+ const abort = () => resolve(ABORTED);
1215
+ signal.addEventListener("abort", abort, { once: true });
1216
+ cancels.push(() => signal.removeEventListener("abort", abort));
1217
+ }));
1218
+ try {
1219
+ return await Promise.race(racers);
1220
+ } finally {
1221
+ for (const cancel of cancels) cancel();
1222
+ }
1223
+ }
1224
+ async promote(handle, reason, signal) {
1225
+ if (handle.pid === void 0) return {
1226
+ kind: FAILED,
1227
+ id: handle.id,
1228
+ name: handle.name,
1229
+ error: "The command did not start"
1230
+ };
1231
+ await this.registry.markPromoted(handle.id);
1232
+ handle.detach();
1233
+ let abortStop;
1234
+ const abort = () => {
1235
+ abortStop = handle.stop().then(async () => {
1236
+ await this.registry.complete(handle.id, {
1237
+ reason: "stopped",
1238
+ code: null,
1239
+ signal: "SIGTERM"
1240
+ });
1241
+ return true;
1242
+ }).catch((error) => {
1243
+ const message = error instanceof Error ? error.message : String(error);
1244
+ process.emitWarning(`Failed to stop aborted runner ${handle.id}: ${message}`);
1245
+ return false;
1246
+ });
1247
+ };
1248
+ if (signal) {
1249
+ if (signal.aborted) abort();
1250
+ else signal.addEventListener("abort", abort, { once: true });
1251
+ }
1252
+ handle.completion().then(async (outcome) => {
1253
+ if (await abortStop) return;
1254
+ await this.registry.complete(handle.id, {
1255
+ reason: outcome.signal ? SIGNALED : outcome.code === 0 ? COMPLETED : FAILED,
1256
+ code: outcome.code,
1257
+ signal: outcome.signal
1258
+ });
1259
+ }, async () => {
1260
+ if (await abortStop) return;
1261
+ await this.registry.complete(handle.id, {
1262
+ reason: LAUNCHER_ERROR,
1263
+ code: null,
1264
+ signal: null
1265
+ });
1266
+ }).catch((error) => {
1267
+ const message = error instanceof Error ? error.message : String(error);
1268
+ process.emitWarning(`Failed to complete runner ${handle.id}: ${message}`);
1269
+ }).finally(() => signal?.removeEventListener("abort", abort));
1270
+ return {
1271
+ kind: "promoted",
1272
+ id: handle.id,
1273
+ name: handle.name,
1274
+ pid: handle.pid,
1275
+ logPath: handle.logPath,
1276
+ backend: handle.backend,
1277
+ reason
1278
+ };
1279
+ }
1280
+ };
1281
+ //#endregion
1282
+ //#region src/services/clock/index.ts
1283
+ /** Real wall clock and timers. */
1284
+ var SystemClock = class {
1285
+ now() {
1286
+ return Date.now();
1287
+ }
1288
+ after(ms, handler) {
1289
+ const timer = setTimeout(handler, ms);
1290
+ timer.unref?.();
1291
+ return () => clearTimeout(timer);
1292
+ }
1293
+ };
1294
+ //#endregion
1295
+ //#region src/constants/launcher.ts
1296
+ const NON_INTERACTIVE_ENV$2 = {
1297
+ FORCE_COLOR: "1",
1298
+ CI: "1"
1299
+ };
1300
+ const TERM_GRACE_MS$1 = 2e3;
1301
+ //#endregion
1302
+ //#region src/constants/session.ts
1303
+ const PI_SESSION_ID_ENV = "PI_SESSION_ID";
1304
+ //#endregion
1305
+ //#region src/services/runnerSupervisor/index.ts
1306
+ /**
1307
+ * Settings that make a terminal safe for a caller with no keyboard.
1308
+ *
1309
+ * Commands run inside an rmux pane, so their stdout is a real tty. Git and
1310
+ * friends take that as permission to open a pager, and `less` then waits at
1311
+ * `(END)` for a keypress nobody is there to send, so the run hangs until its
1312
+ * timeout. The same tty invites credential prompts, which hang the same way.
1313
+ * A conventional piped stdout disables both automatically; a pane has to be
1314
+ * told. A command that genuinely wants a pager can still ask for one, as in
1315
+ * `git -c core.pager=less log`.
1316
+ */
1317
+ const NO_TERMINAL_INPUT_ENV = {
1318
+ GIT_PAGER: "cat",
1319
+ PAGER: "cat",
1320
+ GIT_TERMINAL_PROMPT: "0"
1321
+ };
1322
+ const SPEC_MODE = 384;
1323
+ const NOT_FOUND_ERROR_CODE$2 = "ENOENT";
1324
+ function supervisorPaths(directory, id) {
1325
+ return {
1326
+ spec: node_path.default.join(directory, `${id}.command.json`),
1327
+ gate: node_path.default.join(directory, `${id}.gate`),
1328
+ exit: node_path.default.join(directory, `${id}.exit.json`),
1329
+ logDone: node_path.default.join(directory, `${id}.log.done`)
1330
+ };
1331
+ }
1332
+ function writeCommandSpec(target, spec) {
1333
+ node_fs.default.writeFileSync(target, `${JSON.stringify(spec)}\n`, { mode: SPEC_MODE });
1334
+ }
1335
+ function readExitMetadata(target) {
1336
+ try {
1337
+ const value = JSON.parse(node_fs.default.readFileSync(target, "utf8"));
1338
+ if (typeof value !== "object" || value === null) return void 0;
1339
+ const metadata = value;
1340
+ return {
1341
+ code: typeof metadata.code === "number" ? metadata.code : null,
1342
+ signal: typeof metadata.signal === "string" ? metadata.signal : null
1343
+ };
1344
+ } catch (error) {
1345
+ if (error.code !== NOT_FOUND_ERROR_CODE$2) process.emitWarning(`Could not read runner exit metadata ${target}: ${String(error)}`);
1346
+ return;
1347
+ }
1348
+ }
1349
+ function cleanupSupervisorFiles(paths) {
1350
+ for (const target of Object.values(paths)) try {
1351
+ node_fs.default.unlinkSync(target);
1352
+ } catch (error) {
1353
+ if (error.code !== NOT_FOUND_ERROR_CODE$2) process.emitWarning(String(error));
1354
+ }
1355
+ }
1356
+ /**
1357
+ * Resolves an executable entry point by walking up to the `bin` directory that
1358
+ * ships beside this module. Source and published layouts nest services at
1359
+ * different depths. Source modules select built subprocess entries so Node can resolve their imports. A hardcoded depth stays silent
1360
+ * until the shell tries to exec it.
1361
+ */
1362
+ function runtimeEntry(name, moduleUrl = require("url").pathToFileURL(__filename).href) {
1363
+ const modulePath = (0, node_url.fileURLToPath)(moduleUrl);
1364
+ const source = node_path.default.extname(modulePath) === ".ts";
1365
+ const extension = "mjs";
1366
+ let directory = node_path.default.dirname(modulePath);
1367
+ while (true) {
1368
+ const entry = source ? node_path.default.join(directory, "dist", "bin", `${name}.${extension}`) : node_path.default.join(directory, "bin", `${name}.${extension}`);
1369
+ if (node_fs.default.existsSync(entry)) return node_fs.default.realpathSync(entry);
1370
+ const parent = node_path.default.dirname(directory);
1371
+ if (parent === directory) break;
1372
+ directory = parent;
1373
+ }
1374
+ throw new Error(`Cannot find bin/${name}.${extension} by walking up from ${modulePath}.`);
1375
+ }
1376
+ /**
1377
+ * Replaces the shell rather than nesting under it, so the pid handed back is the
1378
+ * supervisor's and a group signal reaches it directly.
1379
+ */
1380
+ function supervisorCommand(paths) {
1381
+ return `exec ${shellJoin([
1382
+ process.execPath,
1383
+ runtimeEntry("runnerHost"),
1384
+ paths.spec,
1385
+ paths.gate,
1386
+ paths.exit
1387
+ ])}`;
1388
+ }
1389
+ function shellJoin(values) {
1390
+ return values.map((value) => `'${value.replaceAll("'", `'"'"'`)}'`).join(" ");
1391
+ }
1392
+ /**
1393
+ * One watcher per directory, shared by every waiter inside it.
1394
+ *
1395
+ * A session's runners share a runs directory, and `DOOMPI_LOG_DIR` makes every
1396
+ * session using it share one. A watcher per waited file would then put a watcher
1397
+ * per in-flight runner on the same directory, each woken by every other runner's
1398
+ * sidecar writes, so the watchers are pooled and reference counted instead.
1399
+ */
1400
+ const directoryWatches = /* @__PURE__ */ new Map();
1401
+ function acquireDirectoryWatch(directory) {
1402
+ const existing = directoryWatches.get(directory);
1403
+ if (existing) {
1404
+ existing.references += 1;
1405
+ return existing;
1406
+ }
1407
+ const entry = {
1408
+ watcher: void 0,
1409
+ waiters: /* @__PURE__ */ new Map(),
1410
+ references: 1
1411
+ };
1412
+ try {
1413
+ entry.watcher = node_fs.default.watch(directory, (_event, changed) => {
1414
+ const named = changed === null ? void 0 : String(changed);
1415
+ const targets = named === void 0 ? [...entry.waiters.values()] : [entry.waiters.get(named)];
1416
+ for (const listeners of targets) {
1417
+ if (!listeners) continue;
1418
+ for (const listener of listeners) listener();
1419
+ }
1420
+ });
1421
+ entry.watcher.on("error", () => void 0);
1422
+ } catch {
1423
+ entry.watcher = void 0;
1424
+ }
1425
+ directoryWatches.set(directory, entry);
1426
+ return entry;
1427
+ }
1428
+ function releaseDirectoryWatch(directory, entry) {
1429
+ entry.references -= 1;
1430
+ if (entry.references > 0) return;
1431
+ entry.watcher?.close();
1432
+ entry.watcher = void 0;
1433
+ entry.waiters.clear();
1434
+ directoryWatches.delete(directory);
1435
+ }
1436
+ /**
1437
+ * Reports a supervisor sidecar landing on disk without polling for it.
1438
+ *
1439
+ * The sidecars are written once and never rewritten, so the directory's own
1440
+ * create event answers the only question a waiter has. Polling for them costs a
1441
+ * full poll interval on every run that finishes inside one, and the runs that
1442
+ * finish fastest are the ones that pay that tax most often.
1443
+ *
1444
+ * The watch is best effort: a platform or a directory that cannot deliver events
1445
+ * leaves every slice to time out, which is exactly the poll the caller already
1446
+ * runs as its fallback.
1447
+ */
1448
+ function watchForFile(filePath) {
1449
+ const directory = node_path.default.dirname(filePath);
1450
+ const name = node_path.default.basename(filePath);
1451
+ const entry = acquireDirectoryWatch(directory);
1452
+ let released = false;
1453
+ let found = false;
1454
+ let notify;
1455
+ const settle = () => {
1456
+ if (found || !node_fs.default.existsSync(filePath)) return;
1457
+ found = true;
1458
+ notify?.();
1459
+ };
1460
+ const listeners = entry.waiters.get(name) ?? /* @__PURE__ */ new Set();
1461
+ listeners.add(settle);
1462
+ entry.waiters.set(name, listeners);
1463
+ settle();
1464
+ return {
1465
+ async appeared(withinMs) {
1466
+ if (found) return true;
1467
+ return await new Promise((resolve) => {
1468
+ const timer = setTimeout(() => {
1469
+ notify = void 0;
1470
+ settle();
1471
+ resolve(found);
1472
+ }, withinMs);
1473
+ notify = () => {
1474
+ clearTimeout(timer);
1475
+ notify = void 0;
1476
+ resolve(true);
1477
+ };
1478
+ });
1479
+ },
1480
+ close() {
1481
+ if (released) return;
1482
+ released = true;
1483
+ notify = void 0;
1484
+ listeners.delete(settle);
1485
+ if (listeners.size === 0) entry.waiters.delete(name);
1486
+ releaseDirectoryWatch(directory, entry);
1487
+ }
1488
+ };
1489
+ }
1490
+ //#endregion
1491
+ //#region src/services/launcher/index.ts
1492
+ /**
1493
+ * Prompts belong on a screen, not in a log file, so `CI` stays set.
1494
+ *
1495
+ * Colour is kept: the log is replayed into the transcript, and the scrub keeps
1496
+ * SGR while still removing the cursor moves that made raw output unreadable.
1497
+ */
1498
+ /** In-memory output is a multiple of the result ceiling; the log holds the rest. */
1499
+ var Launcher = class {
1500
+ spawner;
1501
+ processControl;
1502
+ logFile;
1503
+ clock;
1504
+ paths;
1505
+ constructor(spawner, processControl, logFile, clock, paths) {
1506
+ this.spawner = spawner;
1507
+ this.processControl = processControl;
1508
+ this.logFile = logFile;
1509
+ this.clock = clock;
1510
+ this.paths = paths;
1511
+ }
1512
+ launch(request) {
1513
+ const writer = this.logFile.open(request.id);
1514
+ const memoryLimit = getResultMaxBytes() * 2;
1515
+ let buffer = "";
1516
+ let buffering = true;
1517
+ let settled = false;
1518
+ let resolveCompletion = () => void 0;
1519
+ let rejectCompletion = () => void 0;
1520
+ const completion = new Promise((resolve, reject) => {
1521
+ resolveCompletion = resolve;
1522
+ rejectCompletion = reject;
1523
+ });
1524
+ this.paths.ensureDirectories(request.sessionId);
1525
+ const supervisor = supervisorPaths(this.paths.stateDirectory(request.sessionId), request.id);
1526
+ const env = {
1527
+ ...process.env,
1528
+ ...NON_INTERACTIVE_ENV$2,
1529
+ ...NO_TERMINAL_INPUT_ENV,
1530
+ [PI_SESSION_ID_ENV]: request.sessionId
1531
+ };
1532
+ cleanupSupervisorFiles(supervisor);
1533
+ writeCommandSpec(supervisor.spec, {
1534
+ command: request.command,
1535
+ cwd: request.cwd,
1536
+ env: defined(env)
1537
+ });
1538
+ node_fs.default.writeFileSync(supervisor.gate, "", { mode: 384 });
1539
+ const child = this.spawner.spawn({
1540
+ command: supervisorCommand(supervisor),
1541
+ cwd: request.cwd,
1542
+ env,
1543
+ detached: true
1544
+ });
1545
+ const failLaunch = (error) => {
1546
+ if (settled) return;
1547
+ settled = true;
1548
+ writer.close();
1549
+ cleanupSupervisorFiles(supervisor);
1550
+ try {
1551
+ child.kill("SIGTERM");
1552
+ } catch (killError) {
1553
+ process.emitWarning(`Could not stop runner after log failure: ${String(killError)}`);
1554
+ }
1555
+ rejectCompletion(error instanceof Error ? error : new Error(String(error)));
1556
+ };
1557
+ child.onOutput((chunk) => {
1558
+ if (settled) return;
1559
+ try {
1560
+ writer.append(chunk);
1561
+ } catch (error) {
1562
+ failLaunch(error);
1563
+ return;
1564
+ }
1565
+ if (!buffering) return;
1566
+ buffer += chunk;
1567
+ if (buffer.length > memoryLimit) buffer = buffer.slice(-memoryLimit);
1568
+ });
1569
+ child.onError(failLaunch);
1570
+ child.onExit((result) => {
1571
+ if (settled) return;
1572
+ settled = true;
1573
+ writer.close();
1574
+ const outcome = readExitMetadata(supervisor.exit) ?? result;
1575
+ cleanupSupervisorFiles(supervisor);
1576
+ resolveCompletion(outcome);
1577
+ });
1578
+ child.unref();
1579
+ const stop = async () => child.pid === void 0 ? false : this.stop(child.pid);
1580
+ return {
1581
+ id: request.id,
1582
+ name: request.name,
1583
+ pid: child.pid,
1584
+ logPath: writer.path,
1585
+ backend: "native",
1586
+ output: () => buffer,
1587
+ completion: () => completion,
1588
+ detach: () => {
1589
+ buffering = false;
1590
+ buffer = "";
1591
+ },
1592
+ stop
1593
+ };
1594
+ }
1595
+ async stop(pid) {
1596
+ if (!this.processControl.isAlive(pid)) return false;
1597
+ this.processControl.signalGroup(pid, "SIGTERM");
1598
+ const deadline = this.clock.now() + TERM_GRACE_MS$1;
1599
+ while (this.clock.now() < deadline) {
1600
+ if (!this.processControl.isAlive(pid)) return true;
1601
+ await this.sleep(100);
1602
+ }
1603
+ this.processControl.signalGroup(pid, "SIGKILL");
1604
+ return true;
1605
+ }
1606
+ sleep(ms) {
1607
+ return new Promise((resolve) => {
1608
+ this.clock.after(ms, resolve);
1609
+ });
1610
+ }
1611
+ };
1612
+ /** The spec is JSON, which has no way to carry an unset variable. */
1613
+ function defined(env) {
1614
+ return Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== void 0));
1615
+ }
1616
+ //#endregion
1617
+ //#region src/constants/lifeline.ts
1618
+ const SOCKET_NAME = "lifeline.sock";
1619
+ //#endregion
1620
+ //#region src/services/lifeline/client.ts
1621
+ /** Environment variable carrying the lifeline socket path to every runner supervisor. */
1622
+ const LIFELINE_ENV = "DOOM_RUNNER_LIFELINE";
1623
+ //#endregion
1624
+ //#region src/services/lifeline/index.ts
1625
+ /** Unix socket paths are capped at 104 bytes, so a long agent directory falls back to the temp dir. */
1626
+ var NodeLifeline = class {
1627
+ paths;
1628
+ server;
1629
+ target;
1630
+ /** Closing a server stops it accepting; only the connections carry the death signal. */
1631
+ connections = /* @__PURE__ */ new Set();
1632
+ constructor(paths) {
1633
+ this.paths = paths;
1634
+ }
1635
+ async arm(sessionId) {
1636
+ if (this.target) return this.target;
1637
+ try {
1638
+ this.paths.ensureDirectories(sessionId);
1639
+ const target = this.socketPath(sessionId);
1640
+ node_fs.default.rmSync(target, { force: true });
1641
+ const server = node_net.default.createServer((connection) => {
1642
+ connection.unref();
1643
+ connection.resume();
1644
+ this.connections.add(connection);
1645
+ connection.on("close", () => this.connections.delete(connection));
1646
+ });
1647
+ await listen(server, target);
1648
+ node_fs.default.chmodSync(target, 384);
1649
+ server.unref();
1650
+ this.server = server;
1651
+ this.target = target;
1652
+ process.env[LIFELINE_ENV] = target;
1653
+ return target;
1654
+ } catch (error) {
1655
+ process.emitWarning(`Could not arm the doom-runner lifeline: ${String(error)}`);
1656
+ return;
1657
+ }
1658
+ }
1659
+ path() {
1660
+ return this.target;
1661
+ }
1662
+ dispose() {
1663
+ for (const connection of this.connections) connection.destroy();
1664
+ this.connections.clear();
1665
+ if (this.server) {
1666
+ try {
1667
+ this.server.close();
1668
+ } catch (error) {
1669
+ process.emitWarning(`Could not close the doom-runner lifeline: ${String(error)}`);
1670
+ }
1671
+ this.server = void 0;
1672
+ }
1673
+ if (this.target) {
1674
+ try {
1675
+ node_fs.default.rmSync(this.target, { force: true });
1676
+ } catch (error) {
1677
+ process.emitWarning(`Could not remove the doom-runner lifeline: ${String(error)}`);
1678
+ }
1679
+ this.target = void 0;
1680
+ }
1681
+ if (process.env["DOOM_RUNNER_LIFELINE"]) delete process.env[LIFELINE_ENV];
1682
+ }
1683
+ /** Keeps the socket beside the session it belongs to unless that path is too long to bind. */
1684
+ socketPath(sessionId) {
1685
+ const preferred = node_path.default.join(this.paths.stateDirectory(sessionId), SOCKET_NAME);
1686
+ if (Buffer.byteLength(preferred) <= 100) return preferred;
1687
+ const hash = (0, node_crypto.createHash)("sha256").update(preferred).digest("hex").slice(0, 12);
1688
+ return node_path.default.join(node_os.default.tmpdir(), `doom-runner-${hash}.sock`);
1689
+ }
1690
+ };
1691
+ /** Binding must complete before the first runner launches, so arming waits for it. */
1692
+ function listen(server, target) {
1693
+ return new Promise((resolve, reject) => {
1694
+ server.once("error", reject);
1695
+ server.listen(target, () => {
1696
+ server.removeListener("error", reject);
1697
+ server.on("error", (error) => process.emitWarning(`doom-runner lifeline failed: ${error.message}`));
1698
+ resolve();
1699
+ });
1700
+ });
1701
+ }
1702
+ //#endregion
1703
+ //#region src/services/logFile/index.ts
1704
+ var LogFile = class {
1705
+ paths;
1706
+ constructor(paths) {
1707
+ this.paths = paths;
1708
+ }
1709
+ open(id) {
1710
+ this.paths.ensureDirectories();
1711
+ const logPath = this.paths.logPathFor(id);
1712
+ const rotatedPath = this.paths.rotatedLogPathFor(id);
1713
+ const maxBytes = getLogMaxBytes();
1714
+ let handle = node_fs.default.openSync(logPath, "w");
1715
+ let written = 0;
1716
+ let closed = false;
1717
+ /**
1718
+ * A run that keeps producing output for hours would otherwise grow one file
1719
+ * without limit. Rotating keeps the newest window at the advertised path
1720
+ * and the window before it alongside, so on-disk cost is bounded at roughly
1721
+ * twice the ceiling, the path a result advertises stays valid, and the line
1722
+ * counts a reader reports still describe the file it actually read.
1723
+ */
1724
+ const rotate = () => {
1725
+ node_fs.default.closeSync(handle);
1726
+ try {
1727
+ node_fs.default.renameSync(logPath, rotatedPath);
1728
+ } catch (error) {
1729
+ process.emitWarning(`Could not rotate runner log ${logPath}: ${String(error)}`);
1730
+ }
1731
+ handle = node_fs.default.openSync(logPath, "w");
1732
+ written = 0;
1733
+ };
1734
+ return {
1735
+ path: logPath,
1736
+ append(text) {
1737
+ const chunk = Buffer.from(text, "utf8");
1738
+ if (written > 0 && written + chunk.byteLength > maxBytes) rotate();
1739
+ node_fs.default.writeSync(handle, chunk);
1740
+ written += chunk.byteLength;
1741
+ },
1742
+ size() {
1743
+ return written;
1744
+ },
1745
+ close() {
1746
+ if (closed) return;
1747
+ closed = true;
1748
+ try {
1749
+ node_fs.default.closeSync(handle);
1750
+ } catch (error) {
1751
+ process.emitWarning(`Could not close runner log ${logPath}: ${String(error)}`);
1752
+ }
1753
+ }
1754
+ };
1755
+ }
1756
+ };
1757
+ //#endregion
1758
+ //#region src/services/processControl/index.ts
1759
+ /** Wraps `process.kill`, which doubles as the liveness probe on POSIX. */
1760
+ var NodeProcessControl = class {
1761
+ isAlive(pid) {
1762
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1763
+ try {
1764
+ process.kill(pid, 0);
1765
+ return true;
1766
+ } catch {
1767
+ return false;
1768
+ }
1769
+ }
1770
+ signalGroup(pid, signal) {
1771
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1772
+ try {
1773
+ process.kill(-pid, signal);
1774
+ return true;
1775
+ } catch {
1776
+ return false;
1777
+ }
1778
+ }
1779
+ };
1780
+ //#endregion
1781
+ //#region src/constants/tmuxBackend.ts
1782
+ const LOG_DRAIN_TIMEOUT_MS$1 = 2e3;
1783
+ const PANE_STATE_FORMAT$1 = "#{pane_dead}:#{pane_dead_status}:#{session_name}";
1784
+ const SESSION_PREFIX$1 = "doom-tmux-";
1785
+ const OWNED_TARGET_PATTERN$1 = /^doom-tmux-[A-Za-z0-9_-]+$/;
1786
+ const SOCKET_PREFIX = "doom-tmux-";
1787
+ const NON_INTERACTIVE_ENV$1 = {
1788
+ NO_COLOR: "1",
1789
+ CI: "1"
1790
+ };
1791
+ //#endregion
1792
+ //#region src/services/ptyBackendChain/index.ts
1793
+ /**
1794
+ * Presents the available PTY backends as one, preferring RMUX.
1795
+ *
1796
+ * A launch takes the first backend that accepts it, so a host without a
1797
+ * compatible RMUX binary still gets panes rather than falling through to the
1798
+ * plain subprocess launcher. Every later call routes on the target's prefix
1799
+ * instead of asking each backend in turn: a backend cannot tell "this session
1800
+ * is gone" from "this session was never mine", and guessing would let one
1801
+ * report success for the other's run.
1802
+ */
1803
+ var PtyBackendChain = class {
1804
+ rmux;
1805
+ tmux;
1806
+ constructor(rmux, tmux) {
1807
+ this.rmux = rmux;
1808
+ this.tmux = tmux;
1809
+ }
1810
+ async launch(request) {
1811
+ return await this.rmux.launch(request) ?? await this.tmux.launch(request);
1812
+ }
1813
+ watch(id, target, sessionId) {
1814
+ return this.owner(target).watch(id, target, sessionId);
1815
+ }
1816
+ stop(target, expectedPid) {
1817
+ return this.owner(target).stop(target, expectedPid);
1818
+ }
1819
+ input(target, text) {
1820
+ return this.owner(target).input(target, text);
1821
+ }
1822
+ capture(target) {
1823
+ return this.owner(target).capture(target);
1824
+ }
1825
+ /** Both backends persist terminal evidence at the same path, so either answers. */
1826
+ readOutcome(id, sessionId) {
1827
+ return this.rmux.readOutcome(id, sessionId) ?? this.tmux.readOutcome(id, sessionId);
1828
+ }
1829
+ get(name) {
1830
+ return this.rmux.get(name) ?? this.tmux.get(name);
1831
+ }
1832
+ owner(target) {
1833
+ return target.startsWith("doom-tmux-") ? this.tmux : this.rmux;
1834
+ }
1835
+ };
1836
+ //#endregion
1837
+ //#region src/constants/ptyHost.ts
1838
+ const SCROLLBACK = 2e3;
1839
+ const TERM_GRACE_MS = 2e3;
1840
+ //#endregion
1841
+ //#region src/services/ptyHost/index.ts
1842
+ /** Enough history for the overlay to scroll without holding a whole build. */
1843
+ const { Terminal: HeadlessTerminal } = _xterm_headless.default;
1844
+ var PtyHost = class {
1845
+ spawner;
1846
+ logFile;
1847
+ processControl;
1848
+ clock;
1849
+ runs = /* @__PURE__ */ new Map();
1850
+ constructor(spawner, logFile, processControl, clock) {
1851
+ this.spawner = spawner;
1852
+ this.logFile = logFile;
1853
+ this.processControl = processControl;
1854
+ this.clock = clock;
1855
+ }
1856
+ async launch(request) {
1857
+ const cols = request.cols ?? 120;
1858
+ const rows = request.rows ?? 30;
1859
+ const writer = this.logFile.open(request.id);
1860
+ const terminal = new HeadlessTerminal({
1861
+ cols,
1862
+ rows,
1863
+ scrollback: SCROLLBACK,
1864
+ allowProposedApi: true
1865
+ });
1866
+ const memoryLimit = getResultMaxBytes() * 2;
1867
+ const child = await this.spawner.spawn({
1868
+ command: request.command,
1869
+ cwd: request.cwd,
1870
+ env: {
1871
+ ...process.env,
1872
+ ...NO_TERMINAL_INPUT_ENV,
1873
+ [PI_SESSION_ID_ENV]: request.sessionId
1874
+ },
1875
+ cols,
1876
+ rows
1877
+ });
1878
+ let buffer = "";
1879
+ let buffering = true;
1880
+ let settled = false;
1881
+ let resolveCompletion = () => void 0;
1882
+ const completion = new Promise((resolve) => {
1883
+ resolveCompletion = resolve;
1884
+ });
1885
+ const dataHandlers = /* @__PURE__ */ new Set();
1886
+ child.onData((data) => {
1887
+ terminal.write(data);
1888
+ const scrubbed = scrubTerminalOutput(data);
1889
+ writer.append(scrubbed);
1890
+ if (buffering) {
1891
+ buffer += scrubbed;
1892
+ if (buffer.length > memoryLimit) buffer = buffer.slice(-memoryLimit);
1893
+ }
1894
+ for (const handler of dataHandlers) handler(data);
1895
+ });
1896
+ child.onExit((result) => {
1897
+ if (settled) return;
1898
+ settled = true;
1899
+ this.runs.delete(request.name);
1900
+ writer.close();
1901
+ resolveCompletion({
1902
+ code: result.exitCode,
1903
+ signal: null
1904
+ });
1905
+ });
1906
+ const run = {
1907
+ id: request.id,
1908
+ name: request.name,
1909
+ pid: child.pid,
1910
+ logPath: writer.path,
1911
+ backend: "native",
1912
+ output: () => buffer,
1913
+ completion: () => completion,
1914
+ detach: () => {
1915
+ buffering = false;
1916
+ buffer = "";
1917
+ },
1918
+ stop: () => this.stopChild(child, request.name),
1919
+ write: (text) => child.write(text),
1920
+ screen: () => renderScreen(terminal),
1921
+ onData: (handler) => {
1922
+ dataHandlers.add(handler);
1923
+ return () => dataHandlers.delete(handler);
1924
+ },
1925
+ resize: (nextCols, nextRows) => {
1926
+ child.resize(nextCols, nextRows);
1927
+ terminal.resize(nextCols, nextRows);
1928
+ }
1929
+ };
1930
+ this.runs.set(request.name, run);
1931
+ return run;
1932
+ }
1933
+ get(name) {
1934
+ return this.runs.get(name);
1935
+ }
1936
+ write(name, text) {
1937
+ const run = this.runs.get(name);
1938
+ if (!run) return false;
1939
+ run.write(text.endsWith("\n") ? text : `${text}
1940
+ `);
1941
+ return true;
1942
+ }
1943
+ list() {
1944
+ return [...this.runs.values()];
1945
+ }
1946
+ async disposeAll() {
1947
+ const runs = [...this.runs.values()];
1948
+ this.runs.clear();
1949
+ for (const run of runs) await run.stop();
1950
+ }
1951
+ async stopChild(child, name) {
1952
+ this.runs.delete(name);
1953
+ if (!this.processControl.isAlive(child.pid)) return false;
1954
+ child.kill("SIGTERM");
1955
+ const deadline = this.clock.now() + TERM_GRACE_MS;
1956
+ while (this.clock.now() < deadline) {
1957
+ if (!this.processControl.isAlive(child.pid)) return true;
1958
+ await new Promise((resolve) => {
1959
+ this.clock.after(100, resolve);
1960
+ });
1961
+ }
1962
+ child.kill("SIGKILL");
1963
+ return true;
1964
+ }
1965
+ };
1966
+ /** The visible rows, trimmed of trailing blanks, as plain text. */
1967
+ function renderScreen(terminal) {
1968
+ const buffer = terminal.buffer.active;
1969
+ const lines = [];
1970
+ for (let row = buffer.baseY; row < buffer.baseY + terminal.rows; row += 1) lines.push(buffer.getLine(row)?.translateToString(true) ?? "");
1971
+ while (lines.length > 0 && lines.at(-1) === "") lines.pop();
1972
+ return lines.join("\n");
1973
+ }
1974
+ //#endregion
1975
+ //#region src/services/ptySpawner/index.ts
1976
+ const RMUX_REQUIRED_ERROR = "The node-pty fallback has been removed. Interactive commands require a supported RMUX runtime.";
1977
+ /**
1978
+ * Compatibility stub for the published NodePtySpawner subpath.
1979
+ *
1980
+ * Doom Runner no longer starts interactive commands through node-pty. The class
1981
+ * remains exportable so existing imports fail at launch with an actionable error.
1982
+ */
1983
+ var NodePtySpawner = class {
1984
+ async spawn(_request) {
1985
+ throw new Error(RMUX_REQUIRED_ERROR);
1986
+ }
1987
+ };
1988
+ //#endregion
1989
+ //#region src/constants/rmuxBackend.ts
1990
+ const RMUX_BINARY_ENV = "DOOMPI_RMUX_BINARY";
1991
+ const LOG_DRAIN_TIMEOUT_MS = 2e3;
1992
+ const PANE_STATE_FORMAT = "#{pane_dead}:#{pane_dead_status}:#{session_name}";
1993
+ const PANE_PID_FORMAT = "#{pane_pid}";
1994
+ const STOP_CLOSE_TIMEOUT_MS = 1e3;
1995
+ const SESSION_PREFIX = "doom-runner-";
1996
+ const OWNED_TARGET_PATTERN = /^doom-runner-[A-Za-z0-9_-]+$/;
1997
+ const NON_INTERACTIVE_ENV = {
1998
+ NO_COLOR: "1",
1999
+ CI: "1"
2000
+ };
2001
+ //#endregion
2002
+ //#region src/services/rmuxBackend/index.ts
2003
+ /** Pane liveness, its exit status, and whether the session still exists, in one read. */
2004
+ const PACKAGE_BY_PLATFORM$1 = {
2005
+ "darwin-arm64": "@agimon-ai/doompi-runner-rmux-darwin-arm64",
2006
+ "darwin-x64": "@agimon-ai/doompi-runner-rmux-darwin-x64",
2007
+ "linux-arm64": "@agimon-ai/doompi-runner-rmux-linux-arm64",
2008
+ "linux-x64": "@agimon-ai/doompi-runner-rmux-linux-x64"
2009
+ };
2010
+ var RmuxBackend = class {
2011
+ paths;
2012
+ clientPromise;
2013
+ runs = /* @__PURE__ */ new Map();
2014
+ completions = /* @__PURE__ */ new Map();
2015
+ constructor(paths) {
2016
+ this.paths = paths;
2017
+ }
2018
+ async launch(request) {
2019
+ const rmux = await this.client();
2020
+ if (!rmux) return void 0;
2021
+ this.paths.ensureDirectories();
2022
+ const target = `${SESSION_PREFIX}${request.id}`;
2023
+ const logPath = this.paths.logPathFor(request.id);
2024
+ const aux = this.auxPaths(request.id, request.sessionId);
2025
+ let created = false;
2026
+ let released = false;
2027
+ try {
2028
+ this.prepareFiles(request, logPath, aux);
2029
+ const opened = await rmux.cmd("new-session", "-d", "-P", "-F", PANE_PID_FORMAT, "-s", target, "-c", request.cwd, process.execPath, runtimeEntry("runnerHost"), aux.spec, aux.gate, aux.exit, { check: true });
2030
+ created = true;
2031
+ await rmux.cmd("set-option", "-t", target, "remain-on-exit", "on", { check: true });
2032
+ await rmux.cmd("pipe-pane", "-t", target, this.logPipeCommand(logPath, aux.logDone, request), { check: true });
2033
+ const pane = rmux.session(target).pane(0, 0);
2034
+ const pid = reportedPid(String(opened.stdout ?? "")) ?? await panePid$1(rmux, target);
2035
+ node_fs.default.writeFileSync(aux.gate, "", { mode: 384 });
2036
+ released = true;
2037
+ let buffering = true;
2038
+ const completion = this.monitor(rmux, pane, target, aux);
2039
+ this.trackCompletion(target, completion);
2040
+ const stop = () => this.stop(target, pid);
2041
+ const handle = {
2042
+ id: request.id,
2043
+ name: request.name,
2044
+ pid,
2045
+ logPath,
2046
+ backend: "rmux",
2047
+ backendTarget: target,
2048
+ output: () => buffering ? readTail$1(logPath, getResultMaxBytes() * 2) : "",
2049
+ completion: () => completion,
2050
+ detach: () => {
2051
+ buffering = false;
2052
+ },
2053
+ stop
2054
+ };
2055
+ if (request.interactive) this.runs.set(request.name, this.interactiveRun(handle, pane, completion));
2056
+ completion.finally(() => this.runs.delete(request.name)).catch((error) => process.emitWarning(`RMUX completion failed for ${target}: ${errorMessage$1(error)}`));
2057
+ return handle;
2058
+ } catch (error) {
2059
+ if (released) throw error;
2060
+ if (created) await rmux.session(target).kill().catch((cleanupError) => process.emitWarning(`Could not clean up failed RMUX launch ${target}: ${errorMessage$1(cleanupError)}`));
2061
+ cleanupSupervisorFiles(aux);
2062
+ process.emitWarning(request.interactive ? `RMUX launch unavailable for interactive runner: ${errorMessage$1(error)}` : `RMUX launch unavailable, using supervised subprocess: ${errorMessage$1(error)}`);
2063
+ return;
2064
+ }
2065
+ }
2066
+ async stop(target, expectedPid) {
2067
+ if (!OWNED_TARGET_PATTERN.test(target) || !Number.isInteger(expectedPid) || expectedPid <= 0) return false;
2068
+ const rmux = await this.client();
2069
+ if (!rmux) return false;
2070
+ try {
2071
+ if (await sessionMissing(rmux, target)) return true;
2072
+ const pid = await panePid$1(rmux, target);
2073
+ if (pid !== expectedPid) return false;
2074
+ const signalled = signalPane$1(pid, target);
2075
+ if (signalled === "gone") return true;
2076
+ if (signalled === "failed") return false;
2077
+ if (await waitForPaneStop$1(rmux, target, 3e3)) return true;
2078
+ if (await sessionMissing(rmux, target)) return true;
2079
+ if (await panePid$1(rmux, target) !== expectedPid) return false;
2080
+ return closeOwnedSession(rmux, target);
2081
+ } catch (error) {
2082
+ process.emitWarning(`Could not stop RMUX target ${target}: ${errorMessage$1(error)}`);
2083
+ return false;
2084
+ }
2085
+ }
2086
+ async watch(id, target, sessionId) {
2087
+ const rmux = await this.client();
2088
+ if (!rmux) return void 0;
2089
+ const pane = rmux.session(target).pane(0, 0);
2090
+ try {
2091
+ await rmux.displayMessage("#{pane_id}", { target });
2092
+ } catch (error) {
2093
+ process.emitWarning(`Could not watch RMUX target ${target}: ${errorMessage$1(error)}`);
2094
+ return;
2095
+ }
2096
+ const completion = this.monitor(rmux, pane, target, this.auxPaths(id, sessionId));
2097
+ this.trackCompletion(target, completion);
2098
+ return completion;
2099
+ }
2100
+ async input(target, text) {
2101
+ const rmux = await this.client();
2102
+ if (!rmux) return false;
2103
+ return await rmux.session(target).pane(0, 0).sendText(text).catch((error) => {
2104
+ process.emitWarning(`Could not send input to RMUX target ${target}: ${errorMessage$1(error)}`);
2105
+ }) !== void 0;
2106
+ }
2107
+ async capture(target) {
2108
+ const rmux = await this.client();
2109
+ if (!rmux) return void 0;
2110
+ return rmux.session(target).pane(0, 0).captureText().catch((error) => {
2111
+ process.emitWarning(`Could not capture RMUX target ${target}: ${errorMessage$1(error)}`);
2112
+ });
2113
+ }
2114
+ get(name) {
2115
+ return this.runs.get(name);
2116
+ }
2117
+ readOutcome(id, sessionId) {
2118
+ return readExitMetadata(this.auxPaths(id, sessionId).exit);
2119
+ }
2120
+ trackCompletion(target, completion) {
2121
+ this.completions.set(target, completion);
2122
+ completion.finally(() => this.completions.delete(target)).catch((error) => process.emitWarning(`RMUX monitor failed for ${target}: ${errorMessage$1(error)}`));
2123
+ }
2124
+ client() {
2125
+ this.clientPromise ??= this.resolveClient();
2126
+ return this.clientPromise;
2127
+ }
2128
+ async resolveClient() {
2129
+ const binaries = [bundledBinary$1(), "rmux"].filter((value) => value !== void 0);
2130
+ for (const binary of binaries) try {
2131
+ if (binary !== "rmux") node_fs.default.chmodSync(binary, 493);
2132
+ const client = new _rmux_sdk.RMUX({
2133
+ binary,
2134
+ socketName: socketName$1(this.paths.repositoryPath())
2135
+ });
2136
+ await client.capabilities();
2137
+ return client;
2138
+ } catch (error) {
2139
+ process.emitWarning(`RMUX candidate ${binary} is unavailable: ${errorMessage$1(error)}`);
2140
+ }
2141
+ }
2142
+ prepareFiles(request, logPath, aux) {
2143
+ cleanupSupervisorFiles(aux);
2144
+ node_fs.default.writeFileSync(logPath, "", { mode: 384 });
2145
+ const env = environment$1(request.sessionId, request.interactive);
2146
+ const spec = {
2147
+ command: request.command,
2148
+ cwd: request.cwd,
2149
+ env
2150
+ };
2151
+ writeCommandSpec(aux.spec, spec);
2152
+ }
2153
+ auxPaths(id, sessionId) {
2154
+ return supervisorPaths(this.paths.stateDirectory(sessionId), id);
2155
+ }
2156
+ /**
2157
+ * The sink the pane pipes into.
2158
+ *
2159
+ * An interactive run also asks for a raw copy: the scrubbed log it writes
2160
+ * beside it has had cursor movement taken out, which is what a terminal
2161
+ * needs most. Only interactive runs pay for it, because only they can be
2162
+ * attached to.
2163
+ */
2164
+ logPipeCommand(logPath, donePath, request) {
2165
+ const id = node_path.default.basename(logPath, ".log");
2166
+ return shellJoin([
2167
+ process.execPath,
2168
+ runtimeEntry("logSink"),
2169
+ logPath,
2170
+ this.paths.rotatedLogPathFor(id),
2171
+ String(getLogMaxBytes()),
2172
+ donePath,
2173
+ ...request.interactive ? [this.paths.rawLogPathFor(id)] : []
2174
+ ]);
2175
+ }
2176
+ async monitor(rmux, pane, target, aux) {
2177
+ try {
2178
+ const paneCode = await waitForRunEnd$1(rmux, target, aux.exit);
2179
+ await waitForLogDrain$1(rmux, target, aux.logDone);
2180
+ return readExitMetadata(aux.exit) ?? {
2181
+ code: paneCode,
2182
+ signal: null
2183
+ };
2184
+ } finally {
2185
+ let timer;
2186
+ const cleanup = async () => {
2187
+ if (!await sessionMissing(rmux, target)) await pane.server.session(target).kill();
2188
+ };
2189
+ try {
2190
+ await Promise.race([cleanup().catch((error) => process.emitWarning(`Could not clean up completed RMUX target ${target}: ${errorMessage$1(error)}`)), new Promise((resolve) => {
2191
+ timer = setTimeout(() => {
2192
+ process.emitWarning(`Timed out cleaning up RMUX target ${target} after ${STOP_CLOSE_TIMEOUT_MS}ms`);
2193
+ resolve();
2194
+ }, STOP_CLOSE_TIMEOUT_MS);
2195
+ })]);
2196
+ } finally {
2197
+ if (timer) clearTimeout(timer);
2198
+ cleanupSupervisorFiles(aux);
2199
+ }
2200
+ }
2201
+ }
2202
+ interactiveRun(handle, pane, completion) {
2203
+ let screen = "";
2204
+ let active = true;
2205
+ const handlers = /* @__PURE__ */ new Set();
2206
+ const refresh = async () => {
2207
+ while (active) {
2208
+ const next = await pane.captureText().catch((error) => {
2209
+ process.emitWarning(`Could not capture RMUX screen for ${handle.name}: ${errorMessage$1(error)}`);
2210
+ return screen;
2211
+ });
2212
+ if (next !== screen) {
2213
+ screen = next;
2214
+ for (const handler of handlers) handler(next);
2215
+ }
2216
+ await delay$1(100);
2217
+ }
2218
+ };
2219
+ refresh();
2220
+ completion.finally(() => {
2221
+ active = false;
2222
+ }).catch((error) => process.emitWarning(`RMUX interactive completion failed for ${handle.name}: ${errorMessage$1(error)}`));
2223
+ return {
2224
+ ...handle,
2225
+ write: (text) => {
2226
+ pane.sendText(text).catch((error) => process.emitWarning(errorMessage$1(error)));
2227
+ },
2228
+ screen: () => screen,
2229
+ onData: (handler) => {
2230
+ handlers.add(handler);
2231
+ return () => handlers.delete(handler);
2232
+ },
2233
+ resize: (cols, rows) => {
2234
+ pane.resize({
2235
+ width: cols,
2236
+ height: rows
2237
+ }).catch((error) => process.emitWarning(errorMessage$1(error)));
2238
+ }
2239
+ };
2240
+ }
2241
+ };
2242
+ function rmuxPackageForTarget(platform, architecture) {
2243
+ const target = `${platform}-${architecture}`;
2244
+ const packageName = PACKAGE_BY_PLATFORM$1[target];
2245
+ if (!packageName) process.emitWarning(`Bundled RMUX binary is unavailable for unsupported target ${target}`);
2246
+ return packageName;
2247
+ }
2248
+ function resolveBundledRmuxBinary(moduleUrl = require("url").pathToFileURL(__filename).href) {
2249
+ const configured = process.env[RMUX_BINARY_ENV];
2250
+ if (configured !== void 0 && configured !== "") return configured;
2251
+ const packageName = rmuxPackageForTarget(process.platform, process.arch);
2252
+ if (!packageName) return void 0;
2253
+ try {
2254
+ const manifest = (0, node_module.createRequire)(moduleUrl).resolve(`${packageName}/package.json`);
2255
+ return node_path.default.join(node_path.default.dirname(manifest), "vendor", "bin", "rmux");
2256
+ } catch (error) {
2257
+ process.emitWarning(`Bundled RMUX binary is unavailable for ${packageName}: ${errorMessage$1(error)}`);
2258
+ return;
2259
+ }
2260
+ }
2261
+ function bundledBinary$1() {
2262
+ return resolveBundledRmuxBinary();
2263
+ }
2264
+ function socketName$1(repositoryPath) {
2265
+ const hash = (0, node_crypto.createHash)("sha256").update(repositoryPath).digest("hex").slice(0, 12);
2266
+ return `${SESSION_PREFIX}${hash}`;
2267
+ }
2268
+ function environment$1(sessionId, interactive) {
2269
+ const values = {
2270
+ ...process.env,
2271
+ ...interactive ? {} : NON_INTERACTIVE_ENV,
2272
+ ...NO_TERMINAL_INPUT_ENV,
2273
+ PI_SESSION_ID: sessionId
2274
+ };
2275
+ if (!interactive) delete values["FORCE_COLOR"];
2276
+ return Object.fromEntries(Object.entries(values).filter((entry) => entry[1] !== void 0));
2277
+ }
2278
+ async function panePid$1(rmux, target) {
2279
+ const response = await rmux.displayMessage(PANE_PID_FORMAT, { target });
2280
+ const pid = reportedPid(typeof response["message"] === "string" ? response["message"] : "");
2281
+ if (pid === void 0) throw new Error(`RMUX did not report a pane pid for ${target}`);
2282
+ return pid;
2283
+ }
2284
+ /** A pane pid as rmux prints it, or nothing when the output holds no usable pid. */
2285
+ function reportedPid(output) {
2286
+ const pid = Number(output.trim());
2287
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
2288
+ }
2289
+ /**
2290
+ * Waits for the run to end, preferring the supervisor's own record of it.
2291
+ *
2292
+ * The sidecar is written before the command's pane can end, and it outlives the
2293
+ * server that the pane's exit takes down with it. Watching for it means a run
2294
+ * that finishes inside a poll interval is observed when it finishes rather than
2295
+ * on the next tick. Asking the server stays the fallback for a supervisor that
2296
+ * died without recording anything.
2297
+ */
2298
+ async function waitForRunEnd$1(rmux, target, exitPath) {
2299
+ const landed = watchForFile(exitPath);
2300
+ let active = true;
2301
+ const readEvidence = async () => {
2302
+ while (active) if (await landed.appeared(100)) return null;
2303
+ return null;
2304
+ };
2305
+ const probePane = async () => {
2306
+ while (active) {
2307
+ await delay$1(100);
2308
+ if (!active) return null;
2309
+ const state = await readPaneExit$1(rmux, target);
2310
+ if (state.exited) return state.code;
2311
+ }
2312
+ return null;
2313
+ };
2314
+ try {
2315
+ return await Promise.race([readEvidence(), probePane()]);
2316
+ } finally {
2317
+ active = false;
2318
+ landed.close();
2319
+ }
2320
+ }
2321
+ /**
2322
+ * Gives the log sink a moment to mark the log complete.
2323
+ *
2324
+ * Normal completion retains the session until the sink records its final
2325
+ * writes. A lost session cannot guarantee a later marker, so it ends the wait.
2326
+ */
2327
+ async function waitForLogDrain$1(rmux, target, donePath) {
2328
+ const deadline = Date.now() + LOG_DRAIN_TIMEOUT_MS;
2329
+ const landed = watchForFile(donePath);
2330
+ let active = true;
2331
+ const readEvidence = async () => {
2332
+ while (active && Date.now() < deadline) if (await landed.appeared(25)) return;
2333
+ };
2334
+ const probeSession = async () => {
2335
+ while (active && Date.now() < deadline) {
2336
+ await delay$1(25);
2337
+ if (!active) return;
2338
+ if (await sessionMissing(rmux, target)) return;
2339
+ }
2340
+ };
2341
+ try {
2342
+ await Promise.race([readEvidence(), probeSession()]);
2343
+ } finally {
2344
+ active = false;
2345
+ landed.close();
2346
+ }
2347
+ }
2348
+ async function closeOwnedSession(rmux, target) {
2349
+ return new Promise((resolve) => {
2350
+ let settled = false;
2351
+ const finish = (result) => {
2352
+ if (settled) return;
2353
+ settled = true;
2354
+ clearTimeout(timer);
2355
+ resolve(result);
2356
+ };
2357
+ const timer = setTimeout(() => {
2358
+ process.emitWarning(`Timed out closing RMUX target ${target} after ${STOP_CLOSE_TIMEOUT_MS}ms`);
2359
+ finish(false);
2360
+ }, STOP_CLOSE_TIMEOUT_MS);
2361
+ rmux.session(target).kill().then(() => finish(true), (error) => {
2362
+ process.emitWarning(`Could not close RMUX target ${target}: ${errorMessage$1(error)}`);
2363
+ finish(false);
2364
+ });
2365
+ });
2366
+ }
2367
+ async function waitForPaneStop$1(rmux, target, timeoutMs) {
2368
+ const deadline = Date.now() + timeoutMs;
2369
+ while (Date.now() < deadline) {
2370
+ try {
2371
+ if ((await readPaneExit$1(rmux, target)).exited) return true;
2372
+ } catch (error) {
2373
+ process.emitWarning(`Could not verify RMUX target ${target} stopped: ${errorMessage$1(error)}`);
2374
+ return false;
2375
+ }
2376
+ await delay$1(100);
2377
+ }
2378
+ return false;
2379
+ }
2380
+ /**
2381
+ * One formatted read answers both questions this poll asks, so watching a run
2382
+ * costs the same single rmux call it always did.
2383
+ */
2384
+ async function readPaneExit$1(rmux, target) {
2385
+ let message;
2386
+ try {
2387
+ const response = await rmux.displayMessage(PANE_STATE_FORMAT, { target });
2388
+ message = typeof response["message"] === "string" ? response["message"] : "";
2389
+ } catch {
2390
+ return {
2391
+ exited: true,
2392
+ code: null
2393
+ };
2394
+ }
2395
+ const [dead, status, ...rest] = message.trim().split(":");
2396
+ if (rest.join(":") === "") return {
2397
+ exited: true,
2398
+ code: null
2399
+ };
2400
+ if (dead === "1" || dead === "true") {
2401
+ const code = Number(status);
2402
+ return {
2403
+ exited: true,
2404
+ code: Number.isInteger(code) ? code : null
2405
+ };
2406
+ }
2407
+ return {
2408
+ exited: false,
2409
+ code: null
2410
+ };
2411
+ }
2412
+ /** A session that cannot be found, on a server that may itself be gone. */
2413
+ async function sessionMissing(rmux, target) {
2414
+ try {
2415
+ return (await rmux.cmd("has-session", "-t", target)).returnCode !== 0;
2416
+ } catch {
2417
+ return true;
2418
+ }
2419
+ }
2420
+ /**
2421
+ * A pane that was already gone and a signal that could not be delivered are
2422
+ * opposite answers to "is it stopped", so they are reported apart.
2423
+ */
2424
+ function signalPane$1(pid, target) {
2425
+ try {
2426
+ process.kill(pid, "SIGTERM");
2427
+ return "signalled";
2428
+ } catch (error) {
2429
+ if (error.code === "ESRCH") return "gone";
2430
+ process.emitWarning(`Could not signal RMUX target ${target}: ${errorMessage$1(error)}`);
2431
+ return "failed";
2432
+ }
2433
+ }
2434
+ function readTail$1(target, maxBytes) {
2435
+ let handle;
2436
+ try {
2437
+ const size = node_fs.default.statSync(target).size;
2438
+ const bytes = Math.min(size, maxBytes);
2439
+ const buffer = Buffer.alloc(bytes);
2440
+ handle = node_fs.default.openSync(target, "r");
2441
+ node_fs.default.readSync(handle, buffer, 0, bytes, size - bytes);
2442
+ return buffer.toString("utf8");
2443
+ } catch (error) {
2444
+ if (error.code !== "ENOENT") process.emitWarning(`Could not read RMUX log ${target}: ${errorMessage$1(error)}`);
2445
+ return "";
2446
+ } finally {
2447
+ if (handle !== void 0) node_fs.default.closeSync(handle);
2448
+ }
2449
+ }
2450
+ function delay$1(ms) {
2451
+ return new Promise((resolve) => {
2452
+ setTimeout(resolve, ms).unref?.();
2453
+ });
2454
+ }
2455
+ function errorMessage$1(error) {
2456
+ return error instanceof Error ? error.message : String(error);
2457
+ }
2458
+ const DEFAULT_RTK_TIMEOUT_MS = 1e4;
2459
+ const RTK_KILL_WAIT_MS = 1e3;
2460
+ const SIMPLE_TOKEN_PATTERN = /^[A-Za-z0-9_./:@%+=,-]+$/u;
2461
+ const SHELL_CONTROL_PATTERN = /[\r\n;&|<>`$\\'"(){}*?!]/u;
2462
+ const RTK_BINARY_ENV = "DOOMPI_RTK_BINARY";
2463
+ //#endregion
2464
+ //#region src/services/rtkProcessor/index.ts
2465
+ const GIT_DIFF_INCOMPATIBLE_OPTIONS = [
2466
+ "-s",
2467
+ "-z",
2468
+ "--raw",
2469
+ "--numstat",
2470
+ "--shortstat",
2471
+ "--stat",
2472
+ "--dirstat",
2473
+ "--dirstat-by-file",
2474
+ "--summary",
2475
+ "--compact-summary",
2476
+ "--name-only",
2477
+ "--name-status",
2478
+ "--check",
2479
+ "--binary",
2480
+ "--word-diff",
2481
+ "--word-diff-regex",
2482
+ "--color",
2483
+ "--color-words",
2484
+ "--color-moved",
2485
+ "--color-moved-ws",
2486
+ "--line-prefix",
2487
+ "--no-patch",
2488
+ "--patch-with-raw",
2489
+ "--patch-with-stat",
2490
+ "--ext-diff",
2491
+ "--textconv",
2492
+ "--quiet",
2493
+ "--submodule"
2494
+ ];
2495
+ const STRUCTURED_SEARCH_INCOMPATIBLE_OPTIONS = [
2496
+ "--json",
2497
+ "--null",
2498
+ "--null-data",
2499
+ "--color",
2500
+ "--colour",
2501
+ "--after-context",
2502
+ "--before-context",
2503
+ "--context",
2504
+ "--no-filename",
2505
+ "--no-line-number",
2506
+ "--heading"
2507
+ ];
2508
+ const PACKAGE_BY_PLATFORM = {
2509
+ "darwin-arm64": "@agimon-ai/doompi-runner-rtk-darwin-arm64",
2510
+ "darwin-x64": "@agimon-ai/doompi-runner-rtk-darwin-x64",
2511
+ "linux-arm64": "@agimon-ai/doompi-runner-rtk-linux-arm64",
2512
+ "linux-x64": "@agimon-ai/doompi-runner-rtk-linux-x64"
2513
+ };
2514
+ var RtkProcessor = class {
2515
+ resolveBinary;
2516
+ timeoutMs;
2517
+ /**
2518
+ * Latched once a binary proves unrunnable on this host.
2519
+ *
2520
+ * A glibc build installed on a musl host satisfies os and cpu, so it
2521
+ * resolves and then fails to exec. Without this the same doomed spawn is
2522
+ * paid on every command, and each one is reported as a processing failure
2523
+ * rather than the absence it actually is.
2524
+ */
2525
+ unrunnable = false;
2526
+ constructor(resolveBinary = bundledBinary, timeoutMs = DEFAULT_RTK_TIMEOUT_MS) {
2527
+ this.resolveBinary = resolveBinary;
2528
+ this.timeoutMs = timeoutMs;
2529
+ }
2530
+ async process(request) {
2531
+ const filter = classifyRtkFilter(request.command);
2532
+ if (!filter) return { kind: "skipped" };
2533
+ let inputBytes;
2534
+ try {
2535
+ inputBytes = node_fs.default.statSync(request.logPath).size;
2536
+ } catch {
2537
+ return {
2538
+ kind: "fallback",
2539
+ warning: RTK_FAILED_WARNING
2540
+ };
2541
+ }
2542
+ if (inputBytes > 10485760) return {
2543
+ kind: "fallback",
2544
+ warning: RTK_OVERSIZED_WARNING
2545
+ };
2546
+ if (this.unrunnable) return {
2547
+ kind: "fallback",
2548
+ warning: RTK_UNAVAILABLE_WARNING
2549
+ };
2550
+ let binary;
2551
+ try {
2552
+ binary = this.resolveBinary();
2553
+ if (!binary) return {
2554
+ kind: "fallback",
2555
+ warning: RTK_UNAVAILABLE_WARNING
2556
+ };
2557
+ if ((node_fs.default.statSync(binary).mode & 73) === 0) node_fs.default.chmodSync(binary, 493);
2558
+ } catch {
2559
+ return {
2560
+ kind: "fallback",
2561
+ warning: RTK_UNAVAILABLE_WARNING
2562
+ };
2563
+ }
2564
+ return this.pipe(binary, filter, request.logPath, inputBytes);
2565
+ }
2566
+ pipe(binary, filter, logPath, inputBytes) {
2567
+ return new Promise((resolve) => {
2568
+ let child;
2569
+ try {
2570
+ child = (0, node_child_process.spawn)(binary, [
2571
+ "pipe",
2572
+ "-f",
2573
+ filter
2574
+ ], { stdio: "pipe" });
2575
+ } catch {
2576
+ this.unrunnable = true;
2577
+ resolve({
2578
+ kind: "fallback",
2579
+ warning: RTK_UNAVAILABLE_WARNING
2580
+ });
2581
+ return;
2582
+ }
2583
+ const input = node_fs.default.createReadStream(logPath);
2584
+ const outputLimit = getResultMaxBytes() * 2;
2585
+ let head = Buffer.alloc(0);
2586
+ let output = Buffer.alloc(0);
2587
+ let outputBytes = 0;
2588
+ let newlineCount = 0;
2589
+ let lastByte;
2590
+ let settled = false;
2591
+ let fallbackWarning;
2592
+ let killWaitTimer;
2593
+ const finish = (result) => {
2594
+ if (settled) return;
2595
+ settled = true;
2596
+ clearTimeout(timer);
2597
+ if (killWaitTimer) clearTimeout(killWaitTimer);
2598
+ input.destroy();
2599
+ resolve(result);
2600
+ };
2601
+ const stop = (warning) => {
2602
+ if (settled) return;
2603
+ fallbackWarning = warning;
2604
+ let killed = false;
2605
+ try {
2606
+ killed = child.kill("SIGKILL");
2607
+ } catch {
2608
+ finish({
2609
+ kind: "fallback",
2610
+ warning
2611
+ });
2612
+ return;
2613
+ }
2614
+ if (!killed) {
2615
+ finish({
2616
+ kind: "fallback",
2617
+ warning
2618
+ });
2619
+ return;
2620
+ }
2621
+ killWaitTimer ??= setTimeout(() => finish({
2622
+ kind: "fallback",
2623
+ warning
2624
+ }), RTK_KILL_WAIT_MS);
2625
+ };
2626
+ const timer = setTimeout(() => stop(RTK_TIMEOUT_WARNING), this.timeoutMs);
2627
+ child.stdout.on("data", (chunk) => {
2628
+ outputBytes += chunk.byteLength;
2629
+ for (const byte of chunk) if (byte === 10) newlineCount += 1;
2630
+ lastByte = chunk.at(-1);
2631
+ if (head.byteLength < outputLimit) head = Buffer.concat([head, chunk.subarray(0, outputLimit - head.byteLength)]);
2632
+ output = appendTail(output, chunk, outputLimit);
2633
+ });
2634
+ child.stderr.resume();
2635
+ child.once("error", (error) => {
2636
+ if (error.code === "ENOENT" || error.code === "EACCES" || error.code === "ENOEXEC") {
2637
+ this.unrunnable = true;
2638
+ stop(RTK_UNAVAILABLE_WARNING);
2639
+ return;
2640
+ }
2641
+ stop(RTK_FAILED_WARNING);
2642
+ });
2643
+ child.stdin.once("error", () => stop(RTK_FAILED_WARNING));
2644
+ input.once("error", () => stop(RTK_FAILED_WARNING));
2645
+ child.once("close", (code) => {
2646
+ if (fallbackWarning) {
2647
+ finish({
2648
+ kind: "fallback",
2649
+ warning: fallbackWarning
2650
+ });
2651
+ return;
2652
+ }
2653
+ const processedOutput = decodeUtf8Tail(output);
2654
+ if (code !== 0 || inputBytes > 0 && processedOutput.trim().length === 0) {
2655
+ finish({
2656
+ kind: "fallback",
2657
+ warning: RTK_FAILED_WARNING
2658
+ });
2659
+ return;
2660
+ }
2661
+ const lines = newlineCount + (outputBytes > 0 && lastByte !== 10 ? 1 : 0);
2662
+ finish({
2663
+ kind: "processed",
2664
+ result: {
2665
+ filter,
2666
+ head: outputBytes > output.byteLength ? head.toString("utf8") : "",
2667
+ output: processedOutput,
2668
+ bytes: outputBytes,
2669
+ lines
2670
+ }
2671
+ });
2672
+ });
2673
+ input.pipe(child.stdin);
2674
+ });
2675
+ }
2676
+ };
2677
+ /**
2678
+ * Classifies only whitespace-delimited single commands. Shell syntax, expansion,
2679
+ * quoting, and globbing are rejected instead of being partially interpreted.
2680
+ */
2681
+ function classifyRtkFilter(command) {
2682
+ if (SHELL_CONTROL_PATTERN.test(command)) return void 0;
2683
+ const trimmed = command.trim();
2684
+ if (!trimmed) return void 0;
2685
+ const tokens = trimmed.split(/[ \t]+/u);
2686
+ if (tokens.some((token) => !SIMPLE_TOKEN_PATTERN.test(token))) return void 0;
2687
+ if (tokens[0] === "cargo" && tokens[1] === "test") {
2688
+ if (tokens.includes("--") || tokens.some((token) => token.startsWith("--message-format") || token === "--json")) return;
2689
+ return "cargo-test";
2690
+ }
2691
+ if (tokens[0] === "pytest") {
2692
+ if (tokens.some((token) => token.startsWith("--json-report"))) return void 0;
2693
+ return "pytest";
2694
+ }
2695
+ if (tokens[0] === "git" && tokens[1] === "diff") {
2696
+ if (tokens.some((token) => GIT_DIFF_INCOMPATIBLE_OPTIONS.some((option) => token === option || token.startsWith(`${option}=`)))) return;
2697
+ return "git-diff";
2698
+ }
2699
+ if (tokens[0] === "grep") {
2700
+ if (tokens.length < 4 || incompatibleStructuredSearch(tokens)) return void 0;
2701
+ return hasShortOrLongOption(tokens, "n", "--line-number") && hasShortOrLongOption(tokens, "H", "--with-filename") ? "grep" : void 0;
2702
+ }
2703
+ if (tokens[0] === "rg") {
2704
+ if (tokens.length < 4 || incompatibleStructuredSearch(tokens) || !tokens.includes("--no-heading")) return void 0;
2705
+ return hasShortOrLongOption(tokens, "n", "--line-number") && hasShortOrLongOption(tokens, "H", "--with-filename") ? "grep" : void 0;
2706
+ }
2707
+ if (tokens[0] === "go" && tokens[1] === "test") {
2708
+ let jsonOutput = false;
2709
+ for (const token of tokens.slice(2)) {
2710
+ if (token === "-json" || token === "-json=true") jsonOutput = true;
2711
+ if (token === "-json=false") jsonOutput = false;
2712
+ }
2713
+ return jsonOutput ? "go-test" : void 0;
2714
+ }
2715
+ if (tokens[0] === "ruff" && tokens[1] === "check") return tokens.some((token, index) => token === "--output-format=json" || token === "--output-format" && tokens[index + 1] === "json") ? "ruff-check" : void 0;
2716
+ }
2717
+ function rtkPackageForTarget(platform, architecture) {
2718
+ const target = `${platform}-${architecture}`;
2719
+ const packageName = PACKAGE_BY_PLATFORM[target];
2720
+ if (!packageName) process.emitWarning(`Bundled RTK binary is unavailable for unsupported target ${target}`);
2721
+ return packageName;
2722
+ }
2723
+ function bundledBinary() {
2724
+ const configured = process.env[RTK_BINARY_ENV];
2725
+ if (configured !== void 0 && configured !== "") return configured;
2726
+ const packageName = rtkPackageForTarget(process.platform, process.arch);
2727
+ if (!packageName) return void 0;
2728
+ try {
2729
+ const manifest = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href).resolve(`${packageName}/package.json`);
2730
+ return node_path.default.join(node_path.default.dirname(manifest), "vendor", "bin", "rtk");
2731
+ } catch {
2732
+ return;
2733
+ }
2734
+ }
2735
+ function appendTail(current, chunk, limit) {
2736
+ if (chunk.byteLength >= limit) return Buffer.from(chunk.subarray(chunk.byteLength - limit));
2737
+ const combined = Buffer.concat([current, chunk]);
2738
+ return combined.byteLength > limit ? Buffer.from(combined.subarray(combined.byteLength - limit)) : combined;
2739
+ }
2740
+ function decodeUtf8Tail(output) {
2741
+ let start = 0;
2742
+ while (start < output.byteLength && (output[start] & 192) === 128) start += 1;
2743
+ return output.subarray(start).toString("utf8");
2744
+ }
2745
+ function hasShortOrLongOption(tokens, short, long) {
2746
+ return tokens.some((token) => token === long || token.startsWith("-") && !token.startsWith("--") && token.slice(1).includes(short));
2747
+ }
2748
+ function incompatibleStructuredSearch(tokens) {
2749
+ return tokens.some((token) => token.startsWith("-") && !token.startsWith("--") && /[0ABCILNchzZ]/u.test(token.slice(1)) || STRUCTURED_SEARCH_INCOMPATIBLE_OPTIONS.some((option) => token === option || token.startsWith(`${option}=`)));
2750
+ }
2751
+ //#endregion
2752
+ //#region src/constants/runnerNamer.ts
2753
+ const MAX_ATTEMPTS = 1e3;
2754
+ const FALLBACK_SLUG = "runner";
2755
+ //#endregion
2756
+ //#region src/services/runnerNamer/index.ts
2757
+ /** Shell noise that says nothing about what the command actually runs. */
2758
+ const IGNORED_LEADING_WORDS = /* @__PURE__ */ new Set([
2759
+ "sudo",
2760
+ "env",
2761
+ "time",
2762
+ "nohup",
2763
+ "exec"
2764
+ ]);
2765
+ var RunnerNamer = class {
2766
+ registry;
2767
+ constructor(registry) {
2768
+ this.registry = registry;
2769
+ }
2770
+ async allocate(command, sessionId, requested) {
2771
+ const base = slugify(requested ?? deriveSlug(command));
2772
+ const taken = new Set((await this.registry.listBySession(sessionId)).map((record) => record.name));
2773
+ if (!taken.has(base)) return base;
2774
+ for (let suffix = 2; suffix < MAX_ATTEMPTS; suffix += 1) {
2775
+ const candidate = `${base}-${suffix}`;
2776
+ if (!taken.has(candidate)) return candidate;
2777
+ }
2778
+ throw new Error(`Could not find a free runner name based on "${base}"`);
2779
+ }
2780
+ };
2781
+ /**
2782
+ * Names come from the first few meaningful words of the command, which is what
2783
+ * a reader recognises in a runner list: `nx-dev-start-api`, not `runner-3`.
2784
+ */
2785
+ function deriveSlug(command) {
2786
+ return command.split(/\s+/).filter((word) => word.length > 0 && !word.startsWith("-") && !word.includes("=")).filter((word) => !IGNORED_LEADING_WORDS.has(word)).slice(0, 4).join("-");
2787
+ }
2788
+ function slugify(value) {
2789
+ const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32).replace(/-+$/, "");
2790
+ return slug.length > 0 ? slug : FALLBACK_SLUG;
2791
+ }
2792
+ //#endregion
2793
+ //#region src/constants/runnerPaths.ts
2794
+ const STORE_DIR_NAME = "doom-runner";
2795
+ const AGENT_DIR_NAME = "agent";
2796
+ const LOG_DIR_NAME = "logs";
2797
+ const STATE_DIR_NAME = "runs";
2798
+ const LOG_EXTENSION = ".log";
2799
+ const STATE_EXTENSION = ".json";
2800
+ const RAW_SUFFIX = ".raw";
2801
+ const PI_CODING_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
2802
+ const FILE_ENCODING = "utf8";
2803
+ //#endregion
2804
+ //#region src/services/session/index.ts
2805
+ /** Session identity inherited by commands launched from a Pi session. */
2806
+ /** Returns a usable inherited session id, failing closed on blank values. */
2807
+ function inheritedSessionId(env = process.env) {
2808
+ const value = env[PI_SESSION_ID_ENV]?.trim();
2809
+ return value ? value : void 0;
2810
+ }
2811
+ //#endregion
2812
+ //#region src/services/runnerPaths/index.ts
2813
+ /** Unscrubbed pane bytes, kept beside the log so an attached terminal can replay them. */
2814
+ /**
2815
+ * How long an entirely empty session directory is left alone before it counts
2816
+ * as an orphan. A session that has started but not yet run anything owns such a
2817
+ * directory for a moment, and reclaiming it out from under that session would
2818
+ * be churn for no gain.
2819
+ */
2820
+ function git(args, cwd) {
2821
+ try {
2822
+ const trimmed = (0, node_child_process.execFileSync)("git", args, {
2823
+ cwd,
2824
+ encoding: FILE_ENCODING,
2825
+ stdio: [
2826
+ "ignore",
2827
+ "pipe",
2828
+ "ignore"
2829
+ ]
2830
+ }).trim();
2831
+ return trimmed.length > 0 ? trimmed : void 0;
2832
+ } catch {
2833
+ return;
2834
+ }
2835
+ }
2836
+ function realPath(target) {
2837
+ try {
2838
+ return node_fs.default.realpathSync(target);
2839
+ } catch {
2840
+ return target;
2841
+ }
2842
+ }
2843
+ function resolveAgentDirectory(env) {
2844
+ const configured = env[PI_CODING_AGENT_DIR_ENV]?.trim();
2845
+ if (configured === "~") return node_os.default.homedir();
2846
+ if (configured?.startsWith("~/")) return node_path.default.join(node_os.default.homedir(), configured.slice(2));
2847
+ return configured ? node_path.default.resolve(configured) : node_path.default.join(node_os.default.homedir(), ".pi", AGENT_DIR_NAME);
2848
+ }
2849
+ /**
2850
+ * Newest mtime anywhere one level inside the session, falling back to the
2851
+ * directory itself. A session still being written to therefore always looks
2852
+ * recent, whatever state its individual records are in.
2853
+ */
2854
+ function newestEntryTime(directory) {
2855
+ let newest = node_fs.default.statSync(directory).mtimeMs;
2856
+ for (const entry of node_fs.default.readdirSync(directory, { withFileTypes: true })) {
2857
+ const target = node_path.default.join(directory, entry.name);
2858
+ newest = Math.max(newest, node_fs.default.statSync(target).mtimeMs);
2859
+ if (!entry.isDirectory()) continue;
2860
+ for (const child of node_fs.default.readdirSync(target)) newest = Math.max(newest, node_fs.default.statSync(node_path.default.join(target, child)).mtimeMs);
2861
+ }
2862
+ return newest;
2863
+ }
2864
+ async function newestEntryTimeAsync(directory) {
2865
+ let newest = (await node_fs.default.promises.stat(directory)).mtimeMs;
2866
+ for (const entry of await node_fs.default.promises.readdir(directory, { withFileTypes: true })) {
2867
+ const target = node_path.default.join(directory, entry.name);
2868
+ newest = Math.max(newest, (await node_fs.default.promises.stat(target)).mtimeMs);
2869
+ if (!entry.isDirectory()) continue;
2870
+ for (const child of await node_fs.default.promises.readdir(target)) newest = Math.max(newest, (await node_fs.default.promises.stat(node_path.default.join(target, child))).mtimeMs);
2871
+ }
2872
+ return newest;
2873
+ }
2874
+ /**
2875
+ * Whether any run in this session names a pi process that is still running.
2876
+ *
2877
+ * One live record is enough: records are only ever written by their owner, so a
2878
+ * live host pid means the session is still in use however old its files look.
2879
+ */
2880
+ function ownerAlive(stateDirectory) {
2881
+ let entries;
2882
+ try {
2883
+ entries = node_fs.default.readdirSync(stateDirectory);
2884
+ } catch {
2885
+ return false;
2886
+ }
2887
+ for (const entry of entries) {
2888
+ if (!entry.endsWith(".json")) continue;
2889
+ try {
2890
+ const hostPid = JSON.parse(node_fs.default.readFileSync(node_path.default.join(stateDirectory, entry), FILE_ENCODING)).hostPid;
2891
+ if (typeof hostPid !== "number") continue;
2892
+ process.kill(hostPid, 0);
2893
+ return true;
2894
+ } catch {}
2895
+ }
2896
+ return false;
2897
+ }
2898
+ async function ownerAliveAsync(stateDirectory) {
2899
+ let entries;
2900
+ try {
2901
+ entries = await node_fs.default.promises.readdir(stateDirectory);
2902
+ } catch {
2903
+ return false;
2904
+ }
2905
+ for (const entry of entries) {
2906
+ if (!entry.endsWith(".json")) continue;
2907
+ try {
2908
+ const hostPid = JSON.parse(await node_fs.default.promises.readFile(node_path.default.join(stateDirectory, entry), FILE_ENCODING)).hostPid;
2909
+ if (typeof hostPid !== "number") continue;
2910
+ process.kill(hostPid, 0);
2911
+ return true;
2912
+ } catch {}
2913
+ }
2914
+ return false;
2915
+ }
2916
+ function completedAt(value) {
2917
+ if (typeof value !== "object" || value === null) return void 0;
2918
+ const record = value;
2919
+ if (record.state !== "completed" || typeof record.exit?.finishedAt !== "string") return void 0;
2920
+ const timestamp = Date.parse(record.exit.finishedAt);
2921
+ return Number.isFinite(timestamp) ? timestamp : void 0;
2922
+ }
2923
+ /**
2924
+ * Completed records a sweep may delete: everything past the TTL, and everything
2925
+ * beyond the newest `keep`.
2926
+ *
2927
+ * The count bound is what keeps a live session from growing without limit. Its
2928
+ * records only reach a multi-day TTL long after the session that is still
2929
+ * writing them would have filled its directory, so age alone bounds nothing
2930
+ * while the session lasts.
2931
+ */
2932
+ function expiredCompletedRecords(records, ttlMs, now, keep) {
2933
+ return [...records].sort((left, right) => right.finishedAt - left.finishedAt).filter((record, index) => index >= keep || now - record.finishedAt > ttlMs);
2934
+ }
2935
+ /**
2936
+ * Whether the directory holds no files at all, only empty subdirectories.
2937
+ *
2938
+ * A session that ran something leaves metadata behind, and a live session keeps
2939
+ * its lifeline socket in the state directory, so an entirely empty tree names
2940
+ * no owner and no history.
2941
+ */
2942
+ function isEmptySession(directory) {
2943
+ for (const entry of node_fs.default.readdirSync(directory, { withFileTypes: true })) {
2944
+ if (!entry.isDirectory()) return false;
2945
+ if (node_fs.default.readdirSync(node_path.default.join(directory, entry.name)).length > 0) return false;
2946
+ }
2947
+ return true;
2948
+ }
2949
+ async function isEmptySessionAsync(directory) {
2950
+ for (const entry of await node_fs.default.promises.readdir(directory, { withFileTypes: true })) {
2951
+ if (!entry.isDirectory()) return false;
2952
+ if ((await node_fs.default.promises.readdir(node_path.default.join(directory, entry.name))).length > 0) return false;
2953
+ }
2954
+ return true;
2955
+ }
2956
+ /**
2957
+ * The root every session's runner state lives under, before any log-directory
2958
+ * override: `<agent dir>/doom-runner/<session id>/{runs,logs}`. A reader in
2959
+ * another process, such as the cockpit hub, resolves the same root from its
2960
+ * own environment.
2961
+ */
2962
+ function resolveRunnerStoreDirectory(env) {
2963
+ return node_path.default.join(resolveAgentDirectory(env), STORE_DIR_NAME);
2964
+ }
2965
+ /** Session-scoped runner storage under the Pi agent directory. */
2966
+ var RunnerPaths = class {
2967
+ adoptedSessionId;
2968
+ repository;
2969
+ repositoryPath() {
2970
+ this.repository ??= realPath(git(["rev-parse", "--show-toplevel"], process.cwd()) ?? process.cwd());
2971
+ return this.repository;
2972
+ }
2973
+ setSessionId(sessionId) {
2974
+ const trimmed = sessionId.trim();
2975
+ if (!trimmed) throw new Error("doom-runner session id cannot be blank");
2976
+ this.adoptedSessionId = trimmed;
2977
+ }
2978
+ logDirectory(sessionId) {
2979
+ const override = process.env[LOG_DIR_ENV]?.trim();
2980
+ if (override) return node_path.default.resolve(override);
2981
+ return node_path.default.join(this.sessionDirectory(sessionId), LOG_DIR_NAME);
2982
+ }
2983
+ stateDirectory(sessionId) {
2984
+ const override = process.env[LOG_DIR_ENV]?.trim();
2985
+ if (override) return node_path.default.join(node_path.default.resolve(override), "..", STATE_DIR_NAME);
2986
+ return node_path.default.join(this.sessionDirectory(sessionId), STATE_DIR_NAME);
2987
+ }
2988
+ logPathFor(id, sessionId) {
2989
+ return node_path.default.join(this.logDirectory(sessionId), `${id}${LOG_EXTENSION}`);
2990
+ }
2991
+ rotatedLogPathFor(id, sessionId) {
2992
+ return `${this.logPathFor(id, sessionId)}.1`;
2993
+ }
2994
+ rawLogPathFor(id, sessionId) {
2995
+ return `${this.logPathFor(id, sessionId)}${RAW_SUFFIX}`;
2996
+ }
2997
+ statePathFor(id, sessionId) {
2998
+ return node_path.default.join(this.stateDirectory(sessionId), `${id}${STATE_EXTENSION}`);
2999
+ }
3000
+ ensureDirectories(sessionId) {
3001
+ node_fs.default.mkdirSync(this.logDirectory(sessionId), { recursive: true });
3002
+ node_fs.default.mkdirSync(this.stateDirectory(sessionId), { recursive: true });
3003
+ }
3004
+ sweepHistory(ttlMs, now = Date.now()) {
3005
+ const result = {
3006
+ removed: [],
3007
+ errors: []
3008
+ };
3009
+ if (process.env["DOOM_RUNNER_LOG_DIR"]?.trim()) {
3010
+ this.sweepStateDirectory(this.stateDirectory(), this.sessionId(), ttlMs, now, result);
3011
+ return result;
3012
+ }
3013
+ const root = this.storeDirectory();
3014
+ let sessions;
3015
+ try {
3016
+ sessions = node_fs.default.readdirSync(root);
3017
+ } catch (error) {
3018
+ if (error.code === "ENOENT") return result;
3019
+ result.errors.push(`Could not read runner history directory ${root}: ${String(error)}`);
3020
+ return result;
3021
+ }
3022
+ const active = this.currentSessionId();
3023
+ for (const sessionId of sessions) {
3024
+ if (sessionId === active) {
3025
+ this.sweepActiveSession(sessionId, ttlMs, now, result);
3026
+ continue;
3027
+ }
3028
+ this.sweepSessionDirectory(node_path.default.join(root, sessionId), ttlMs, now, result);
3029
+ }
3030
+ return result;
3031
+ }
3032
+ async sweepHistoryAsync(ttlMs, now = Date.now()) {
3033
+ const result = {
3034
+ removed: [],
3035
+ errors: []
3036
+ };
3037
+ if (process.env["DOOM_RUNNER_LOG_DIR"]?.trim()) {
3038
+ await this.sweepStateDirectoryAsync(this.stateDirectory(), this.sessionId(), ttlMs, now, result);
3039
+ return result;
3040
+ }
3041
+ const root = this.storeDirectory();
3042
+ let sessions;
3043
+ try {
3044
+ sessions = await node_fs.default.promises.readdir(root);
3045
+ } catch (error) {
3046
+ if (error.code === "ENOENT") return result;
3047
+ result.errors.push(`Could not read runner history directory ${root}: ${String(error)}`);
3048
+ return result;
3049
+ }
3050
+ const active = this.currentSessionId();
3051
+ for (const sessionId of sessions) {
3052
+ if (sessionId === active) {
3053
+ await this.sweepActiveSessionAsync(sessionId, ttlMs, now, result);
3054
+ continue;
3055
+ }
3056
+ await this.sweepSessionDirectoryAsync(node_path.default.join(root, sessionId), ttlMs, now, result);
3057
+ }
3058
+ return result;
3059
+ }
3060
+ legacyDirectory() {
3061
+ const commonDirectory = git([
3062
+ "rev-parse",
3063
+ "--path-format=absolute",
3064
+ "--git-common-dir"
3065
+ ], process.cwd());
3066
+ return commonDirectory ? node_path.default.join(commonDirectory, STORE_DIR_NAME) : void 0;
3067
+ }
3068
+ removeLegacyStore() {
3069
+ const target = this.legacyDirectory();
3070
+ if (!target || node_path.default.basename(target) !== "doom-runner" || !node_fs.default.existsSync(target)) return void 0;
3071
+ node_fs.default.rmSync(target, {
3072
+ recursive: true,
3073
+ force: true
3074
+ });
3075
+ return target;
3076
+ }
3077
+ /** The live session, if one is known, so a sweep never deletes underneath itself. */
3078
+ currentSessionId() {
3079
+ try {
3080
+ return this.sessionId();
3081
+ } catch {
3082
+ return;
3083
+ }
3084
+ }
3085
+ /**
3086
+ * Retention inside the session that is still running.
3087
+ *
3088
+ * The whole-directory sweep cannot be pointed at the live session: it would
3089
+ * take the lifeline socket, the supervisor sidecars and the running runners'
3090
+ * own metadata with it. Per-record retention is safe there because it only
3091
+ * ever removes a record whose state is `completed` together with the two log
3092
+ * files that record names, and never the directory itself. A running record
3093
+ * has no `exit.finishedAt`, so it is not a candidate at any age.
3094
+ */
3095
+ sweepActiveSession(sessionId, ttlMs, now, result) {
3096
+ this.sweepStateDirectory(this.stateDirectory(sessionId), sessionId, ttlMs, now, result);
3097
+ }
3098
+ async sweepActiveSessionAsync(sessionId, ttlMs, now, result) {
3099
+ await this.sweepStateDirectoryAsync(this.stateDirectory(sessionId), sessionId, ttlMs, now, result);
3100
+ }
3101
+ /** Removes a session's whole directory once its newest entry is past the TTL. */
3102
+ sweepSessionDirectory(directory, ttlMs, now, result) {
3103
+ try {
3104
+ const newest = newestEntryTime(directory);
3105
+ if (now - newest > 3e5 && isEmptySession(directory)) {
3106
+ node_fs.default.rmSync(directory, {
3107
+ recursive: true,
3108
+ force: true
3109
+ });
3110
+ result.removed.push(directory);
3111
+ return;
3112
+ }
3113
+ if (now - newest <= ttlMs) return;
3114
+ if (ownerAlive(node_path.default.join(directory, "runs"))) return;
3115
+ node_fs.default.rmSync(directory, {
3116
+ recursive: true,
3117
+ force: true
3118
+ });
3119
+ result.removed.push(directory);
3120
+ } catch (error) {
3121
+ if (error.code !== "ENOENT") result.errors.push(`Could not sweep runner session ${directory}: ${String(error)}`);
3122
+ }
3123
+ }
3124
+ async sweepSessionDirectoryAsync(directory, ttlMs, now, result) {
3125
+ try {
3126
+ const newest = await newestEntryTimeAsync(directory);
3127
+ if (now - newest > 3e5 && await isEmptySessionAsync(directory)) {
3128
+ await node_fs.default.promises.rm(directory, {
3129
+ recursive: true,
3130
+ force: true
3131
+ });
3132
+ result.removed.push(directory);
3133
+ return;
3134
+ }
3135
+ if (now - newest <= ttlMs) return;
3136
+ if (await ownerAliveAsync(node_path.default.join(directory, "runs"))) return;
3137
+ await node_fs.default.promises.rm(directory, {
3138
+ recursive: true,
3139
+ force: true
3140
+ });
3141
+ result.removed.push(directory);
3142
+ } catch (error) {
3143
+ if (error.code !== "ENOENT") result.errors.push(`Could not sweep runner session ${directory}: ${String(error)}`);
3144
+ }
3145
+ }
3146
+ sessionId(explicit) {
3147
+ const sessionId = explicit?.trim() || this.adoptedSessionId || inheritedSessionId();
3148
+ if (!sessionId) throw new Error("PI_SESSION_ID is required for doom-runner storage");
3149
+ return sessionId;
3150
+ }
3151
+ storeDirectory() {
3152
+ const override = process.env[LOG_DIR_ENV]?.trim();
3153
+ if (override) return node_path.default.dirname(node_path.default.resolve(override));
3154
+ return resolveRunnerStoreDirectory(process.env);
3155
+ }
3156
+ sessionDirectory(sessionId) {
3157
+ return node_path.default.join(this.storeDirectory(), this.sessionId(sessionId));
3158
+ }
3159
+ sweepStateDirectory(directory, sessionId, ttlMs, now, result) {
3160
+ let entries;
3161
+ try {
3162
+ entries = node_fs.default.readdirSync(directory);
3163
+ } catch (error) {
3164
+ if (error.code !== "ENOENT") result.errors.push(`Could not read runner state directory ${directory}: ${String(error)}`);
3165
+ return;
3166
+ }
3167
+ const completed = [];
3168
+ for (const entry of entries) {
3169
+ if (!entry.endsWith(".json")) continue;
3170
+ const statePath = node_path.default.join(directory, entry);
3171
+ try {
3172
+ const finishedAt = completedAt(JSON.parse(node_fs.default.readFileSync(statePath, FILE_ENCODING)));
3173
+ if (finishedAt === void 0) continue;
3174
+ completed.push({
3175
+ id: entry.slice(0, -5),
3176
+ statePath,
3177
+ finishedAt
3178
+ });
3179
+ } catch (error) {
3180
+ result.errors.push(`Could not sweep runner metadata ${statePath}: ${String(error)}`);
3181
+ }
3182
+ }
3183
+ for (const record of expiredCompletedRecords(completed, ttlMs, now, getMaxCompletedRunners())) this.removeHistoryFiles(sessionId, record.id, record.statePath, result);
3184
+ }
3185
+ async sweepStateDirectoryAsync(directory, sessionId, ttlMs, now, result) {
3186
+ let entries;
3187
+ try {
3188
+ entries = await node_fs.default.promises.readdir(directory);
3189
+ } catch (error) {
3190
+ if (error.code !== "ENOENT") result.errors.push(`Could not read runner state directory ${directory}: ${String(error)}`);
3191
+ return;
3192
+ }
3193
+ const completed = [];
3194
+ for (const entry of entries) {
3195
+ if (!entry.endsWith(".json")) continue;
3196
+ const statePath = node_path.default.join(directory, entry);
3197
+ try {
3198
+ const finishedAt = completedAt(JSON.parse(await node_fs.default.promises.readFile(statePath, FILE_ENCODING)));
3199
+ if (finishedAt === void 0) continue;
3200
+ completed.push({
3201
+ id: entry.slice(0, -5),
3202
+ statePath,
3203
+ finishedAt
3204
+ });
3205
+ } catch (error) {
3206
+ result.errors.push(`Could not sweep runner metadata ${statePath}: ${String(error)}`);
3207
+ }
3208
+ }
3209
+ for (const record of expiredCompletedRecords(completed, ttlMs, now, getMaxCompletedRunners())) await this.removeHistoryFilesAsync(sessionId, record.id, record.statePath, result);
3210
+ }
3211
+ removeHistoryFiles(sessionId, id, statePath, result) {
3212
+ const candidates = [
3213
+ statePath,
3214
+ this.logPathFor(id, sessionId),
3215
+ this.rotatedLogPathFor(id, sessionId),
3216
+ this.rawLogPathFor(id, sessionId)
3217
+ ];
3218
+ for (const candidate of candidates) try {
3219
+ node_fs.default.unlinkSync(candidate);
3220
+ result.removed.push(candidate);
3221
+ } catch (error) {
3222
+ if (error.code !== "ENOENT") result.errors.push(`Could not remove runner history ${candidate}: ${String(error)}`);
3223
+ }
3224
+ }
3225
+ async removeHistoryFilesAsync(sessionId, id, statePath, result) {
3226
+ const candidates = [
3227
+ statePath,
3228
+ this.logPathFor(id, sessionId),
3229
+ this.rotatedLogPathFor(id, sessionId)
3230
+ ];
3231
+ for (const candidate of candidates) try {
3232
+ await node_fs.default.promises.unlink(candidate);
3233
+ result.removed.push(candidate);
3234
+ } catch (error) {
3235
+ if (error.code !== "ENOENT") result.errors.push(`Could not remove runner history ${candidate}: ${String(error)}`);
3236
+ }
3237
+ }
3238
+ };
3239
+ //#endregion
3240
+ //#region src/constants/runnerRegistry.ts
3241
+ const SERVICE_TYPE = "tool";
3242
+ const SERVICE_PREFIX = "doom-runner:";
3243
+ const RUNNER_TAG = "doom-runner";
3244
+ const REGISTRY_PATH_ENV = "PROCESS_REGISTRY_PATH";
3245
+ //#endregion
3246
+ //#region src/services/runnerRecord/index.ts
3247
+ /** The disk record's shape guard, shared by the registry and every reader of its files. */
3248
+ function isRunnerRecord(value) {
3249
+ if (typeof value !== "object" || value === null) return false;
3250
+ const record = value;
3251
+ return typeof record.id === "string" && typeof record.name === "string" && typeof record.pid === "number" && typeof record.command === "string" && typeof record.cwd === "string" && typeof record.logPath === "string" && typeof record.sessionId === "string" && typeof record.startedAt === "string" && (record.state === "running" || record.state === "completed") && typeof record.promoted === "boolean" && (record.backend === "rmux" || record.backend === "tmux" || record.backend === "native");
3252
+ }
3253
+ //#endregion
3254
+ //#region src/services/runnerRegistry/index.ts
3255
+ /** Opens the SQLite-backed registry the rest of the monorepo already shares. */
3256
+ function createDefaultProcessRegistry() {
3257
+ return (0, _agimon_ai_foundation_process_registry.createProcessRegistryService)(process.env[REGISTRY_PATH_ENV]);
3258
+ }
3259
+ function sessionTag(sessionId) {
3260
+ return `session:${sessionId}`;
3261
+ }
3262
+ function readString(record, key) {
3263
+ const value = record.metadata?.[key];
3264
+ return typeof value === "string" ? value : "";
3265
+ }
3266
+ function readNumber(record, key) {
3267
+ const value = record.metadata?.[key];
3268
+ return typeof value === "number" ? value : 0;
3269
+ }
3270
+ const runCommand = (0, node_util.promisify)(node_child_process.execFile);
3271
+ /**
3272
+ * The process start time the registry stores to prove a pid is still the
3273
+ * process it registered.
3274
+ *
3275
+ * The registry resolves it itself when the caller omits it, and it does so with
3276
+ * `execFileSync`, which stops this process for the length of a `ps` on every
3277
+ * runner that starts. Reading it here costs the same subprocess and blocks
3278
+ * nothing: the command is already running by the time a runner is registered.
3279
+ *
3280
+ * The value has to match what the registry would have read, so the command and
3281
+ * the trimming are deliberately identical to its own.
3282
+ */
3283
+ async function processStartedAt(pid) {
3284
+ if (process.platform === "win32") return void 0;
3285
+ try {
3286
+ const { stdout } = await runCommand("ps", [
3287
+ "-o",
3288
+ "lstart=",
3289
+ "-p",
3290
+ String(pid)
3291
+ ], { encoding: "utf8" });
3292
+ return stdout.trim() || void 0;
3293
+ } catch {
3294
+ return;
3295
+ }
3296
+ }
3297
+ const listenersBySession = /* @__PURE__ */ new Map();
3298
+ var RunnerRegistry = class {
3299
+ paths;
3300
+ processControl;
3301
+ registry;
3302
+ environment;
3303
+ subscriptions = /* @__PURE__ */ new Set();
3304
+ constructor(paths, processControl, registry, environment) {
3305
+ this.paths = paths;
3306
+ this.processControl = processControl;
3307
+ this.registry = registry;
3308
+ this.environment = environment;
3309
+ }
3310
+ async register(input) {
3311
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3312
+ const rootSessionId = (0, _agimon_ai_doompi_core_child_process.resolveRootSessionId)(input.sessionId, this.environment);
3313
+ const identity = await processStartedAt(input.pid);
3314
+ const response = await this.registry.registerProcess({
3315
+ repositoryPath: this.repositoryPath(),
3316
+ serviceName: `${SERVICE_PREFIX}${input.id}`,
3317
+ serviceType: SERVICE_TYPE,
3318
+ pid: input.pid,
3319
+ command: input.command,
3320
+ ...identity === void 0 ? {} : { processStartedAt: identity },
3321
+ tags: (0, _agimon_ai_foundation_process_registry.resolveProcessTags)([
3322
+ RUNNER_TAG,
3323
+ `runner:${input.id}`,
3324
+ sessionTag(input.sessionId)
3325
+ ]),
3326
+ metadata: {
3327
+ id: input.id,
3328
+ name: input.name,
3329
+ command: input.command,
3330
+ cwd: input.cwd,
3331
+ logPath: input.logPath,
3332
+ interactive: input.interactive,
3333
+ sessionId: input.sessionId,
3334
+ rootSessionId,
3335
+ backend: input.backend,
3336
+ ...input.backendTarget ? { backendTarget: input.backendTarget } : {},
3337
+ startedAt,
3338
+ hostPid: process.pid
3339
+ },
3340
+ force: true
3341
+ });
3342
+ if (!response.success) throw new Error(response.error ?? `Failed to register runner ${input.name}`);
3343
+ const record = {
3344
+ id: input.id,
3345
+ name: input.name,
3346
+ pid: input.pid,
3347
+ command: input.command,
3348
+ cwd: input.cwd,
3349
+ logPath: input.logPath,
3350
+ interactive: input.interactive,
3351
+ sessionId: input.sessionId,
3352
+ rootSessionId,
3353
+ startedAt,
3354
+ state: "running",
3355
+ promoted: false,
3356
+ backend: input.backend,
3357
+ ...input.backendTarget ? { backendTarget: input.backendTarget } : {},
3358
+ hostPid: process.pid
3359
+ };
3360
+ this.writeRecord(record);
3361
+ this.notify(record.sessionId);
3362
+ return record;
3363
+ }
3364
+ async list() {
3365
+ return (await this.registry.listProcesses({
3366
+ repositoryPath: this.repositoryPath(),
3367
+ serviceType: SERVICE_TYPE,
3368
+ tags: (0, _agimon_ai_foundation_process_registry.resolveProcessTags)([RUNNER_TAG])
3369
+ })).map(toRunnerRecord);
3370
+ }
3371
+ async listAcrossRepositories() {
3372
+ return (await this.registry.listProcesses({
3373
+ serviceType: SERVICE_TYPE,
3374
+ tags: (0, _agimon_ai_foundation_process_registry.resolveProcessTags)([RUNNER_TAG])
3375
+ })).map(toRunnerRecord);
3376
+ }
3377
+ async listBySession(sessionId) {
3378
+ return (await this.list()).filter((record) => record.sessionId === sessionId);
3379
+ }
3380
+ async listByRootSession(rootSessionId) {
3381
+ return (await this.list()).filter((record) => (record.rootSessionId ?? record.sessionId) === rootSessionId);
3382
+ }
3383
+ async listAll(sessionId) {
3384
+ this.paths.ensureDirectories(sessionId);
3385
+ const records = [];
3386
+ const directory = this.paths.stateDirectory(sessionId);
3387
+ for (const entry of node_fs.default.readdirSync(directory)) {
3388
+ if (!isPrimaryMetadataEntry(entry)) continue;
3389
+ try {
3390
+ const parsed = JSON.parse(node_fs.default.readFileSync(`${directory}/${entry}`, "utf8"));
3391
+ if (isRunnerRecord(parsed)) records.push(parsed);
3392
+ } catch (error) {
3393
+ process.emitWarning(`Could not read runner metadata ${entry}: ${String(error)}`);
3394
+ }
3395
+ }
3396
+ return records.sort((left, right) => right.startedAt.localeCompare(left.startedAt));
3397
+ }
3398
+ async get(id, sessionId) {
3399
+ const target = this.paths.statePathFor(id, sessionId);
3400
+ try {
3401
+ const parsed = JSON.parse(node_fs.default.readFileSync(target, "utf8"));
3402
+ if (!isRunnerRecord(parsed) || parsed.id !== id) {
3403
+ process.emitWarning(`Could not read runner metadata ${node_path.default.basename(target)}: invalid runner record`);
3404
+ return;
3405
+ }
3406
+ return parsed;
3407
+ } catch (error) {
3408
+ if (error.code === "ENOENT") return void 0;
3409
+ process.emitWarning(`Could not read runner metadata ${node_path.default.basename(target)}: ${String(error)}`);
3410
+ return;
3411
+ }
3412
+ }
3413
+ async markPromoted(id) {
3414
+ const record = await this.get(id);
3415
+ if (!record) return void 0;
3416
+ const promoted = {
3417
+ ...record,
3418
+ promoted: true
3419
+ };
3420
+ this.writeRecord(promoted);
3421
+ this.notify(promoted.sessionId);
3422
+ return promoted;
3423
+ }
3424
+ async complete(id, outcome, sessionId) {
3425
+ const persisted = await this.get(id, sessionId);
3426
+ if (persisted?.state === "completed") {
3427
+ await this.release(id, persisted.sessionId);
3428
+ return persisted;
3429
+ }
3430
+ const active = await this.list();
3431
+ const record = persisted ?? active.find((candidate) => candidate.id === id && (sessionId === void 0 || candidate.sessionId === sessionId));
3432
+ if (!record) {
3433
+ if (!(sessionId !== void 0 && active.some((candidate) => candidate.id === id && candidate.sessionId !== sessionId))) await this.release(id, sessionId);
3434
+ return;
3435
+ }
3436
+ const completed = {
3437
+ ...record,
3438
+ state: "completed",
3439
+ exit: {
3440
+ ...outcome,
3441
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
3442
+ }
3443
+ };
3444
+ this.writeRecord(completed);
3445
+ await this.release(id, completed.sessionId);
3446
+ return completed;
3447
+ }
3448
+ async release(id, sessionId) {
3449
+ const ownedSessionId = sessionId ?? (await this.list()).find((record) => record.id === id)?.sessionId ?? (await this.get(id))?.sessionId;
3450
+ const response = await this.registry.releaseProcess({
3451
+ repositoryPath: this.repositoryPath(),
3452
+ serviceName: `${SERVICE_PREFIX}${id}`,
3453
+ serviceType: SERVICE_TYPE,
3454
+ kill: false,
3455
+ releasePort: false,
3456
+ force: true
3457
+ });
3458
+ if (!response.success && !response.error?.includes("No matching process entry")) throw new Error(response.error ?? `Failed to release runner ${id}`);
3459
+ if (ownedSessionId !== void 0) this.notify(ownedSessionId);
3460
+ }
3461
+ async pruneDead() {
3462
+ const dead = (await this.list()).filter((record) => !this.processControl.isAlive(record.pid));
3463
+ for (const record of dead) await this.complete(record.id, {
3464
+ reason: "backend_lost",
3465
+ code: null,
3466
+ signal: null
3467
+ }, record.sessionId);
3468
+ return dead.map((record) => record.id);
3469
+ }
3470
+ subscribe(listener, sessionId) {
3471
+ const subscribed = () => listener();
3472
+ const registration = {
3473
+ key: this.subscriptionKey(sessionId),
3474
+ listener: subscribed
3475
+ };
3476
+ const listeners = listenersBySession.get(registration.key) ?? /* @__PURE__ */ new Set();
3477
+ listeners.add(subscribed);
3478
+ listenersBySession.set(registration.key, listeners);
3479
+ this.subscriptions.add(registration);
3480
+ return () => this.removeSubscription(registration);
3481
+ }
3482
+ close() {
3483
+ for (const registration of this.subscriptions) this.removeSubscription(registration);
3484
+ this.registry.close();
3485
+ }
3486
+ notify(sessionId) {
3487
+ for (const listener of listenersBySession.get(this.subscriptionKey(sessionId)) ?? []) try {
3488
+ listener();
3489
+ } catch (error) {
3490
+ process.emitWarning(`Runner registry listener failed: ${String(error)}`);
3491
+ }
3492
+ }
3493
+ removeSubscription(registration) {
3494
+ const listeners = listenersBySession.get(registration.key);
3495
+ listeners?.delete(registration.listener);
3496
+ if (listeners?.size === 0) listenersBySession.delete(registration.key);
3497
+ this.subscriptions.delete(registration);
3498
+ }
3499
+ subscriptionKey(sessionId) {
3500
+ return JSON.stringify([node_path.default.resolve(this.paths.stateDirectory(sessionId)), sessionId]);
3501
+ }
3502
+ repositoryPath() {
3503
+ return (0, _agimon_ai_foundation_process_registry.normalizeRepositoryPath)(this.paths.repositoryPath());
3504
+ }
3505
+ writeRecord(record) {
3506
+ this.paths.ensureDirectories(record.sessionId);
3507
+ const target = this.paths.statePathFor(record.id, record.sessionId);
3508
+ const temporary = `${target}.${process.pid}.tmp`;
3509
+ node_fs.default.writeFileSync(temporary, `${JSON.stringify(record, null, 2)}\n`, "utf8");
3510
+ node_fs.default.renameSync(temporary, target);
3511
+ }
3512
+ };
3513
+ /** Every field is read back from metadata this class wrote on registration. */
3514
+ function toRunnerRecord(record) {
3515
+ return {
3516
+ id: readString(record, "id"),
3517
+ name: readString(record, "name"),
3518
+ pid: record.pid,
3519
+ command: readString(record, "command"),
3520
+ cwd: readString(record, "cwd"),
3521
+ logPath: readString(record, "logPath"),
3522
+ interactive: record.metadata?.interactive === true,
3523
+ sessionId: readString(record, "sessionId"),
3524
+ ...readString(record, "rootSessionId") ? { rootSessionId: readString(record, "rootSessionId") } : {},
3525
+ startedAt: readString(record, "startedAt"),
3526
+ state: "running",
3527
+ promoted: false,
3528
+ backend: readBackend(record.metadata?.backend),
3529
+ ...readString(record, "backendTarget") ? { backendTarget: readString(record, "backendTarget") } : {},
3530
+ hostPid: readNumber(record, "hostPid")
3531
+ };
3532
+ }
3533
+ function readBackend(value) {
3534
+ return value === "rmux" || value === "tmux" ? value : "native";
3535
+ }
3536
+ function isPrimaryMetadataEntry(entry) {
3537
+ return entry.endsWith(".json") && !entry.endsWith(".command.json") && !entry.endsWith(".exit.json");
3538
+ }
3539
+ //#endregion
3540
+ //#region src/constants/spawner.ts
3541
+ const SHELL = "/bin/bash";
3542
+ //#endregion
3543
+ //#region src/services/spawner/index.ts
3544
+ /** Wraps `node:child_process.spawn`. */
3545
+ var NodeSpawner = class {
3546
+ spawn(request) {
3547
+ const child = (0, node_child_process.spawn)(request.command, {
3548
+ cwd: request.cwd,
3549
+ env: request.env,
3550
+ detached: request.detached,
3551
+ shell: SHELL,
3552
+ stdio: [
3553
+ "ignore",
3554
+ "pipe",
3555
+ "pipe"
3556
+ ]
3557
+ });
3558
+ child.stdout?.setEncoding("utf8");
3559
+ child.stderr?.setEncoding("utf8");
3560
+ return {
3561
+ pid: child.pid,
3562
+ onOutput(handler) {
3563
+ child.stdout?.on("data", (chunk) => handler(chunk, "stdout"));
3564
+ child.stderr?.on("data", (chunk) => handler(chunk, "stderr"));
3565
+ },
3566
+ onExit(handler) {
3567
+ let finished = false;
3568
+ let drainTimer;
3569
+ const finish = (result) => {
3570
+ if (finished) return;
3571
+ finished = true;
3572
+ if (drainTimer) clearTimeout(drainTimer);
3573
+ handler(result);
3574
+ };
3575
+ child.once("exit", (code, signal) => {
3576
+ drainTimer = setTimeout(() => finish({
3577
+ code,
3578
+ signal
3579
+ }), 250);
3580
+ });
3581
+ child.once("close", (code, signal) => finish({
3582
+ code,
3583
+ signal
3584
+ }));
3585
+ },
3586
+ onError(handler) {
3587
+ child.on("error", handler);
3588
+ },
3589
+ kill(signal) {
3590
+ child.kill(signal);
3591
+ },
3592
+ unref() {
3593
+ child.unref();
3594
+ }
3595
+ };
3596
+ }
3597
+ };
3598
+ //#endregion
3599
+ //#region src/services/tmuxBackend/TmuxClient.ts
3600
+ const TMUX_BINARY = "tmux";
3601
+ const SOCKET_OPTION = "-L";
3602
+ /** Long enough for a loaded server, short enough that a hung tmux is not fatal. */
3603
+ const COMMAND_TIMEOUT_MS = 1e4;
3604
+ const MAX_OUTPUT_BYTES = 4194304;
3605
+ var TmuxClient = class {
3606
+ socket;
3607
+ binary;
3608
+ constructor(socket, binary = TMUX_BINARY) {
3609
+ this.socket = socket;
3610
+ this.binary = binary;
3611
+ }
3612
+ run(args) {
3613
+ return new Promise((resolve, reject) => {
3614
+ (0, node_child_process.execFile)(this.binary, [
3615
+ SOCKET_OPTION,
3616
+ this.socket,
3617
+ ...args
3618
+ ], {
3619
+ timeout: COMMAND_TIMEOUT_MS,
3620
+ maxBuffer: MAX_OUTPUT_BYTES,
3621
+ encoding: "utf8"
3622
+ }, (error, stdout, stderr) => {
3623
+ const code = error?.code;
3624
+ if (error && typeof code !== "number") {
3625
+ reject(error);
3626
+ return;
3627
+ }
3628
+ resolve({
3629
+ returnCode: typeof code === "number" ? code : 0,
3630
+ stdout,
3631
+ stderr
3632
+ });
3633
+ });
3634
+ });
3635
+ }
3636
+ async format(target, format) {
3637
+ const result = await this.run([
3638
+ "display-message",
3639
+ "-p",
3640
+ "-t",
3641
+ target,
3642
+ "-F",
3643
+ format
3644
+ ]).catch(() => void 0);
3645
+ if (!result || result.returnCode !== 0) return void 0;
3646
+ return result.stdout.replace(/\n$/, "");
3647
+ }
3648
+ async sessionMissing(target) {
3649
+ const result = await this.run([
3650
+ "has-session",
3651
+ "-t",
3652
+ target
3653
+ ]).catch(() => void 0);
3654
+ return !result || result.returnCode !== 0;
3655
+ }
3656
+ };
3657
+ //#endregion
3658
+ //#region src/services/tmuxBackend/index.ts
3659
+ /** Pane liveness, its exit status, and whether the session still exists, in one read. */
3660
+ /** Distinct from the RMUX prefix so a target names the backend that owns it. */
3661
+ /**
3662
+ * Supervises runs in tmux panes, for hosts with no compatible RMUX binary.
3663
+ *
3664
+ * The pane runs the same runnerHost supervisor as the RMUX backend, so exit
3665
+ * metadata, log rotation, and lifeline handling are shared rather than
3666
+ * reimplemented: only the multiplexer differs. Kept beside the RMUX backend
3667
+ * rather than folded into it because that path has no unit coverage to
3668
+ * refactor against.
3669
+ */
3670
+ var TmuxBackend = class {
3671
+ paths;
3672
+ createClient;
3673
+ clientPromise;
3674
+ runs = /* @__PURE__ */ new Map();
3675
+ completions = /* @__PURE__ */ new Map();
3676
+ constructor(paths, createClient = (socket) => new TmuxClient(socket)) {
3677
+ this.paths = paths;
3678
+ this.createClient = createClient;
3679
+ }
3680
+ async launch(request) {
3681
+ const tmux = await this.client();
3682
+ if (!tmux) return void 0;
3683
+ this.paths.ensureDirectories();
3684
+ const target = `${SESSION_PREFIX$1}${request.id}`;
3685
+ const logPath = this.paths.logPathFor(request.id);
3686
+ const aux = this.auxPaths(request.id, request.sessionId);
3687
+ let created = false;
3688
+ let released = false;
3689
+ try {
3690
+ this.prepareFiles(request, logPath, aux);
3691
+ const supervisor = shellJoin([
3692
+ process.execPath,
3693
+ runtimeEntry("runnerHost"),
3694
+ aux.spec,
3695
+ aux.gate,
3696
+ aux.exit
3697
+ ]);
3698
+ await this.checked(tmux, [
3699
+ "new-session",
3700
+ "-d",
3701
+ "-s",
3702
+ target,
3703
+ "-c",
3704
+ request.cwd,
3705
+ supervisor
3706
+ ]);
3707
+ created = true;
3708
+ await this.checked(tmux, [
3709
+ "set-option",
3710
+ "-t",
3711
+ target,
3712
+ "remain-on-exit",
3713
+ "on"
3714
+ ]);
3715
+ await this.checked(tmux, [
3716
+ "pipe-pane",
3717
+ "-t",
3718
+ target,
3719
+ this.logPipeCommand(logPath, aux.logDone, request)
3720
+ ]);
3721
+ const pid = await panePid(tmux, target);
3722
+ node_fs.default.writeFileSync(aux.gate, "", { mode: 384 });
3723
+ released = true;
3724
+ let buffering = true;
3725
+ const completion = this.monitor(tmux, target, aux);
3726
+ this.trackCompletion(target, completion);
3727
+ const handle = {
3728
+ id: request.id,
3729
+ name: request.name,
3730
+ pid,
3731
+ logPath,
3732
+ backend: "tmux",
3733
+ backendTarget: target,
3734
+ output: () => buffering ? readTail(logPath, getResultMaxBytes() * 2) : "",
3735
+ completion: () => completion,
3736
+ detach: () => {
3737
+ buffering = false;
3738
+ },
3739
+ stop: () => this.stop(target, pid)
3740
+ };
3741
+ if (request.interactive) this.runs.set(request.name, this.interactiveRun(handle, tmux, target, completion));
3742
+ completion.finally(() => this.runs.delete(request.name)).catch((error) => process.emitWarning(`tmux completion failed for ${target}: ${errorMessage(error)}`));
3743
+ return handle;
3744
+ } catch (error) {
3745
+ if (released) throw error;
3746
+ if (created) await killSession(tmux, target);
3747
+ cleanupSupervisorFiles(aux);
3748
+ process.emitWarning(request.interactive ? `tmux launch unavailable for interactive runner: ${errorMessage(error)}` : `tmux launch unavailable, using supervised subprocess: ${errorMessage(error)}`);
3749
+ return;
3750
+ }
3751
+ }
3752
+ async stop(target, expectedPid) {
3753
+ if (!OWNED_TARGET_PATTERN$1.test(target) || !Number.isInteger(expectedPid) || expectedPid <= 0) return false;
3754
+ const tmux = await this.client();
3755
+ if (!tmux) return false;
3756
+ try {
3757
+ if (await tmux.sessionMissing(target)) return true;
3758
+ const pid = await panePid(tmux, target);
3759
+ if (pid !== expectedPid) return false;
3760
+ const signalled = signalPane(pid, target);
3761
+ if (signalled === "gone") return true;
3762
+ if (signalled === "failed") return false;
3763
+ if (await waitForPaneStop(tmux, target, 3e3)) return true;
3764
+ if (await tmux.sessionMissing(target)) return true;
3765
+ if (await panePid(tmux, target).catch(() => void 0) !== expectedPid) return false;
3766
+ return killSession(tmux, target);
3767
+ } catch (error) {
3768
+ process.emitWarning(`Could not stop tmux target ${target}: ${errorMessage(error)}`);
3769
+ return false;
3770
+ }
3771
+ }
3772
+ async watch(id, target, sessionId) {
3773
+ const tmux = await this.client();
3774
+ if (!tmux) return void 0;
3775
+ if (await tmux.sessionMissing(target)) return void 0;
3776
+ const completion = this.monitor(tmux, target, this.auxPaths(id, sessionId));
3777
+ this.trackCompletion(target, completion);
3778
+ return completion;
3779
+ }
3780
+ async input(target, text) {
3781
+ const tmux = await this.client();
3782
+ if (!tmux) return false;
3783
+ return (await tmux.run([
3784
+ "send-keys",
3785
+ "-t",
3786
+ target,
3787
+ "-l",
3788
+ text
3789
+ ]).catch((error) => {
3790
+ process.emitWarning(`Could not send input to tmux target ${target}: ${errorMessage(error)}`);
3791
+ }))?.returnCode === 0;
3792
+ }
3793
+ async capture(target) {
3794
+ const tmux = await this.client();
3795
+ if (!tmux) return void 0;
3796
+ const result = await tmux.run([
3797
+ "capture-pane",
3798
+ "-p",
3799
+ "-t",
3800
+ target
3801
+ ]).catch((error) => {
3802
+ process.emitWarning(`Could not capture tmux target ${target}: ${errorMessage(error)}`);
3803
+ });
3804
+ return result?.returnCode === 0 ? result.stdout : void 0;
3805
+ }
3806
+ get(name) {
3807
+ return this.runs.get(name);
3808
+ }
3809
+ readOutcome(id, sessionId) {
3810
+ return readExitMetadata(this.auxPaths(id, sessionId).exit);
3811
+ }
3812
+ trackCompletion(target, completion) {
3813
+ this.completions.set(target, completion);
3814
+ completion.finally(() => this.completions.delete(target)).catch((error) => process.emitWarning(`tmux monitor failed for ${target}: ${errorMessage(error)}`));
3815
+ }
3816
+ client() {
3817
+ this.clientPromise ??= this.resolveClient();
3818
+ return this.clientPromise;
3819
+ }
3820
+ async resolveClient() {
3821
+ const client = this.createClient(socketName(this.paths.repositoryPath()));
3822
+ try {
3823
+ const version = await client.run(["-V"]);
3824
+ if (version.returnCode !== 0) throw new Error(version.stderr.trim() || "tmux reported no version");
3825
+ return client;
3826
+ } catch (error) {
3827
+ process.emitWarning(`tmux is unavailable: ${errorMessage(error)}`);
3828
+ return;
3829
+ }
3830
+ }
3831
+ async checked(tmux, args) {
3832
+ const result = await tmux.run(args);
3833
+ if (result.returnCode !== 0) throw new Error(`tmux ${args[0] ?? ""} failed: ${result.stderr.trim() || `exit ${result.returnCode}`}`);
3834
+ }
3835
+ prepareFiles(request, logPath, aux) {
3836
+ cleanupSupervisorFiles(aux);
3837
+ node_fs.default.writeFileSync(logPath, "", { mode: 384 });
3838
+ const env = environment(request.sessionId, request.interactive);
3839
+ const spec = {
3840
+ command: request.command,
3841
+ cwd: request.cwd,
3842
+ env
3843
+ };
3844
+ writeCommandSpec(aux.spec, spec);
3845
+ }
3846
+ auxPaths(id, sessionId) {
3847
+ return supervisorPaths(this.paths.stateDirectory(sessionId), id);
3848
+ }
3849
+ /**
3850
+ * The sink the pane pipes into.
3851
+ *
3852
+ * An interactive run also asks for a raw copy: the scrubbed log it writes
3853
+ * beside it has had cursor movement taken out, which is what a terminal
3854
+ * needs most. Only interactive runs pay for it, because only they can be
3855
+ * attached to.
3856
+ */
3857
+ logPipeCommand(logPath, donePath, request) {
3858
+ const id = node_path.default.basename(logPath, ".log");
3859
+ return shellJoin([
3860
+ process.execPath,
3861
+ runtimeEntry("logSink"),
3862
+ logPath,
3863
+ this.paths.rotatedLogPathFor(id),
3864
+ String(getLogMaxBytes()),
3865
+ donePath,
3866
+ ...request.interactive ? [this.paths.rawLogPathFor(id)] : []
3867
+ ]);
3868
+ }
3869
+ async monitor(tmux, target, aux) {
3870
+ try {
3871
+ const paneCode = await waitForRunEnd(tmux, target, aux.exit);
3872
+ await waitForLogDrain(tmux, target, aux.logDone);
3873
+ return readExitMetadata(aux.exit) ?? {
3874
+ code: paneCode,
3875
+ signal: null
3876
+ };
3877
+ } finally {
3878
+ if (!await tmux.sessionMissing(target).catch(() => false)) await killSession(tmux, target);
3879
+ cleanupSupervisorFiles(aux);
3880
+ }
3881
+ }
3882
+ interactiveRun(handle, tmux, target, completion) {
3883
+ let screen = "";
3884
+ let active = true;
3885
+ const handlers = /* @__PURE__ */ new Set();
3886
+ const refresh = async () => {
3887
+ while (active) {
3888
+ const captured = await tmux.run([
3889
+ "capture-pane",
3890
+ "-p",
3891
+ "-t",
3892
+ target
3893
+ ]).catch((error) => {
3894
+ process.emitWarning(`Could not capture tmux screen for ${handle.name}: ${errorMessage(error)}`);
3895
+ });
3896
+ const next = captured?.returnCode === 0 ? captured.stdout : screen;
3897
+ if (next !== screen) {
3898
+ screen = next;
3899
+ for (const handler of handlers) handler(next);
3900
+ }
3901
+ await delay(100);
3902
+ }
3903
+ };
3904
+ refresh();
3905
+ completion.finally(() => {
3906
+ active = false;
3907
+ }).catch((error) => process.emitWarning(`tmux interactive completion failed for ${handle.name}: ${errorMessage(error)}`));
3908
+ return {
3909
+ ...handle,
3910
+ write: (text) => {
3911
+ tmux.run([
3912
+ "send-keys",
3913
+ "-t",
3914
+ target,
3915
+ "-l",
3916
+ text
3917
+ ]).catch((error) => process.emitWarning(errorMessage(error)));
3918
+ },
3919
+ screen: () => screen,
3920
+ onData: (handler) => {
3921
+ handlers.add(handler);
3922
+ return () => handlers.delete(handler);
3923
+ },
3924
+ resize: (cols, rows) => {
3925
+ tmux.run([
3926
+ "resize-window",
3927
+ "-t",
3928
+ target,
3929
+ "-x",
3930
+ String(cols),
3931
+ "-y",
3932
+ String(rows)
3933
+ ]).catch((error) => process.emitWarning(errorMessage(error)));
3934
+ }
3935
+ };
3936
+ }
3937
+ };
3938
+ function socketName(repositoryPath) {
3939
+ const hash = (0, node_crypto.createHash)("sha256").update(repositoryPath).digest("hex").slice(0, 12);
3940
+ return `${SOCKET_PREFIX}${hash}`;
3941
+ }
3942
+ function environment(sessionId, interactive) {
3943
+ const values = {
3944
+ ...process.env,
3945
+ ...interactive ? {} : NON_INTERACTIVE_ENV$1,
3946
+ ...NO_TERMINAL_INPUT_ENV,
3947
+ PI_SESSION_ID: sessionId
3948
+ };
3949
+ if (!interactive) delete values["FORCE_COLOR"];
3950
+ return Object.fromEntries(Object.entries(values).filter((entry) => entry[1] !== void 0));
3951
+ }
3952
+ async function killSession(tmux, target) {
3953
+ return (await tmux.run([
3954
+ "kill-session",
3955
+ "-t",
3956
+ target
3957
+ ]).catch((error) => {
3958
+ process.emitWarning(`Could not close tmux target ${target}: ${errorMessage(error)}`);
3959
+ }))?.returnCode === 0;
3960
+ }
3961
+ async function panePid(tmux, target) {
3962
+ const message = await tmux.format(target, "#{pane_pid}");
3963
+ const pid = Number(message);
3964
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error(`tmux did not report a pane pid for ${target}`);
3965
+ return pid;
3966
+ }
3967
+ /**
3968
+ * Waits for the run to end, preferring the supervisor's own record of it.
3969
+ *
3970
+ * The sidecar is written before the pane can end and outlives the server the
3971
+ * pane's exit takes down, so asking tmux is the fallback for a supervisor that
3972
+ * died without recording anything.
3973
+ */
3974
+ async function waitForRunEnd(tmux, target, exitPath) {
3975
+ for (;;) {
3976
+ if (node_fs.default.existsSync(exitPath)) return null;
3977
+ const state = await readPaneExit(tmux, target);
3978
+ if (state.exited) return state.code;
3979
+ await delay(100);
3980
+ }
3981
+ }
3982
+ async function waitForLogDrain(tmux, target, donePath) {
3983
+ const deadline = Date.now() + LOG_DRAIN_TIMEOUT_MS$1;
3984
+ while (Date.now() < deadline) {
3985
+ if (node_fs.default.existsSync(donePath)) return;
3986
+ if (await tmux.sessionMissing(target)) return;
3987
+ await delay(25);
3988
+ }
3989
+ }
3990
+ async function waitForPaneStop(tmux, target, timeoutMs) {
3991
+ const deadline = Date.now() + timeoutMs;
3992
+ while (Date.now() < deadline) {
3993
+ if ((await readPaneExit(tmux, target)).exited) return true;
3994
+ await delay(100);
3995
+ }
3996
+ return false;
3997
+ }
3998
+ /** One formatted read answers both questions this poll asks. */
3999
+ async function readPaneExit(tmux, target) {
4000
+ const message = await tmux.format(target, PANE_STATE_FORMAT$1);
4001
+ if (message === void 0) return {
4002
+ exited: true,
4003
+ code: null
4004
+ };
4005
+ const [dead, status, ...rest] = message.trim().split(":");
4006
+ if (rest.join(":") === "") return {
4007
+ exited: true,
4008
+ code: null
4009
+ };
4010
+ if (dead === "1" || dead === "true") {
4011
+ const code = Number(status);
4012
+ return {
4013
+ exited: true,
4014
+ code: Number.isInteger(code) ? code : null
4015
+ };
4016
+ }
4017
+ return {
4018
+ exited: false,
4019
+ code: null
4020
+ };
4021
+ }
4022
+ function signalPane(pid, target) {
4023
+ try {
4024
+ process.kill(pid, "SIGTERM");
4025
+ return "signalled";
4026
+ } catch (error) {
4027
+ if (error.code === "ESRCH") return "gone";
4028
+ process.emitWarning(`Could not signal tmux target ${target}: ${errorMessage(error)}`);
4029
+ return "failed";
4030
+ }
4031
+ }
4032
+ function readTail(target, maxBytes) {
4033
+ let handle;
4034
+ try {
4035
+ const size = node_fs.default.statSync(target).size;
4036
+ const bytes = Math.min(size, maxBytes);
4037
+ const buffer = Buffer.alloc(bytes);
4038
+ handle = node_fs.default.openSync(target, "r");
4039
+ node_fs.default.readSync(handle, buffer, 0, bytes, size - bytes);
4040
+ return buffer.toString("utf8");
4041
+ } catch (error) {
4042
+ if (error.code !== "ENOENT") process.emitWarning(`Could not read tmux log ${target}: ${errorMessage(error)}`);
4043
+ return "";
4044
+ } finally {
4045
+ if (handle !== void 0) node_fs.default.closeSync(handle);
4046
+ }
4047
+ }
4048
+ function delay(ms) {
4049
+ return new Promise((resolve) => {
4050
+ setTimeout(resolve, ms).unref?.();
4051
+ });
4052
+ }
4053
+ function errorMessage(error) {
4054
+ return error instanceof Error ? error.message : String(error);
4055
+ }
4056
+ //#endregion
4057
+ //#region src/services/runnerDependencies/index.ts
4058
+ /** Build once on first access, then hand back the same instance. */
4059
+ function memoize(build) {
4060
+ let value;
4061
+ let built = false;
4062
+ return () => {
4063
+ if (!built) {
4064
+ value = build();
4065
+ built = true;
4066
+ }
4067
+ return value;
4068
+ };
4069
+ }
4070
+ /**
4071
+ * Builds the extension's service graph.
4072
+ *
4073
+ * A factory rather than a module-level singleton: the extension owns one graph
4074
+ * per session, and tests get a fresh one they can substitute ports on through
4075
+ * `overrides` without leaking state between cases.
4076
+ *
4077
+ * Every slot resolves lazily and memoizes, which keeps the previous container's
4078
+ * behaviour: nothing is constructed until it is asked for, so opening the SQLite
4079
+ * process registry stays deferred until something actually needs a runner.
4080
+ */
4081
+ function createRunnerDependencies(overrides) {
4082
+ const environment = overrides.environment;
4083
+ const clock = memoize(() => overrides.clock ?? new SystemClock());
4084
+ const spawner = memoize(() => overrides.spawner ?? new NodeSpawner());
4085
+ const processControl = memoize(() => overrides.processControl ?? new NodeProcessControl());
4086
+ const ptySpawner = memoize(() => overrides.ptySpawner ?? new NodePtySpawner());
4087
+ const paths = memoize(() => overrides.paths ?? new RunnerPaths());
4088
+ const lifeline = memoize(() => overrides.lifeline ?? new NodeLifeline(paths()));
4089
+ const processRegistry = memoize(() => overrides.processRegistry ?? createDefaultProcessRegistry());
4090
+ const runnerRegistry = memoize(() => overrides.runnerRegistry ?? new RunnerRegistry(paths(), processControl(), processRegistry(), environment));
4091
+ const namer = memoize(() => overrides.namer ?? new RunnerNamer(runnerRegistry()));
4092
+ const logFile = memoize(() => overrides.logFile ?? new LogFile(paths()));
4093
+ const logReader = memoize(() => overrides.logReader ?? new LogReader());
4094
+ const launcher = memoize(() => overrides.launcher ?? new Launcher(spawner(), processControl(), logFile(), clock(), paths()));
4095
+ const rmuxBackend = memoize(() => overrides.rmuxBackend ?? new PtyBackendChain(new RmuxBackend(paths()), new TmuxBackend(paths())));
4096
+ const rtkProcessor = memoize(() => overrides.rtkProcessor ?? new RtkProcessor());
4097
+ const ptyHost = memoize(() => overrides.ptyHost ?? new PtyHost(ptySpawner(), logFile(), processControl(), clock()));
4098
+ const bashRunService = memoize(() => overrides.bashRunService ?? new BashRunService(launcher(), rmuxBackend(), namer(), runnerRegistry(), clock(), rtkProcessor()));
4099
+ return {
4100
+ environment,
4101
+ get clock() {
4102
+ return clock();
4103
+ },
4104
+ get spawner() {
4105
+ return spawner();
4106
+ },
4107
+ get processControl() {
4108
+ return processControl();
4109
+ },
4110
+ get ptySpawner() {
4111
+ return ptySpawner();
4112
+ },
4113
+ get paths() {
4114
+ return paths();
4115
+ },
4116
+ get lifeline() {
4117
+ return lifeline();
4118
+ },
4119
+ get processRegistry() {
4120
+ return processRegistry();
4121
+ },
4122
+ get runnerRegistry() {
4123
+ return runnerRegistry();
4124
+ },
4125
+ get namer() {
4126
+ return namer();
4127
+ },
4128
+ get logFile() {
4129
+ return logFile();
4130
+ },
4131
+ get logReader() {
4132
+ return logReader();
4133
+ },
4134
+ get launcher() {
4135
+ return launcher();
4136
+ },
4137
+ get rmuxBackend() {
4138
+ return rmuxBackend();
4139
+ },
4140
+ get rtkProcessor() {
4141
+ return rtkProcessor();
4142
+ },
4143
+ get ptyHost() {
4144
+ return ptyHost();
4145
+ },
4146
+ get bashRunService() {
4147
+ return bashRunService();
4148
+ }
4149
+ };
4150
+ }
4151
+ //#endregion
4152
+ //#region src/extensions/workspaces/sessions/(backend)/tool/bash.mcp.ts
4153
+ var bash_mcp_default = (0, _agimon_ai_doompi_core_mcp_facet.defineMcpTool)(() => {
4154
+ const dependencies = createRunnerDependencies({ environment: Object.freeze({ ...process.env }) });
4155
+ return createHeadlessBashTool({ run: (request) => dependencies.bashRunService.run(request) }, summarizeLog);
4156
+ });
4157
+ //#endregion
4158
+ //#region generated/mcp.ts
4159
+ const at = (value, context) => typeof value === "function" ? value(context) : value;
4160
+ const via = (identity, value) => ({
4161
+ ...identity,
4162
+ ...value
4163
+ });
4164
+ const mcp = (0, _agimon_ai_doompi_core_mcp_facet.defineMcpPlugin)({
4165
+ name: "@agimon-ai/doompi-runner",
4166
+ session: (context) => ({
4167
+ get skills() {
4168
+ return [at(doompi_use_runner_mcp_default, context)];
4169
+ },
4170
+ get tools() {
4171
+ return [via({ name: "bash" }, at(bash_mcp_default, context))];
4172
+ }
4173
+ })
4174
+ });
4175
+ //#endregion
4176
+ exports.default = mcp;
4177
+ exports.mcp = mcp;
4178
+
4179
+ //# sourceMappingURL=mcp.cjs.map