@link-assistant/hive-mind 2.16.0 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.hi.md +12 -0
  3. package/README.md +15 -0
  4. package/README.ru.md +15 -0
  5. package/README.zh.md +24 -12
  6. package/package.json +24 -17
  7. package/src/agent-snapshot-store.lib.mjs +252 -0
  8. package/src/agent.lib.mjs +25 -25
  9. package/src/agent.version-gates.lib.mjs +73 -0
  10. package/src/bot-lifecycle.lib.mjs +55 -4
  11. package/src/cleanup.mjs +57 -3
  12. package/src/disk-guard.lib.mjs +21 -1
  13. package/src/formal-ai-version.lib.mjs +10 -6
  14. package/src/github-url-parser.lib.mjs +80 -23
  15. package/src/github-url-recovery.lib.mjs +514 -0
  16. package/src/hive.mjs +10 -0
  17. package/src/instrument.mjs +12 -14
  18. package/src/instrument.sanitize.lib.mjs +52 -0
  19. package/src/isolation-runner.lib.mjs +56 -33
  20. package/src/isolation-runner.parsers.lib.mjs +29 -3
  21. package/src/isolation-runner.resume.lib.mjs +263 -0
  22. package/src/locales/en.lino +7 -0
  23. package/src/locales/hi.lino +7 -0
  24. package/src/locales/ru.lino +7 -0
  25. package/src/locales/zh.lino +7 -0
  26. package/src/pull-request-changes.lib.mjs +1 -1
  27. package/src/session-kill-diagnostics.lib.mjs +47 -5
  28. package/src/session-kill-resume.in-place.lib.mjs +136 -0
  29. package/src/session-kill-resume.lib.mjs +43 -16
  30. package/src/session-monitor.kill-sections.lib.mjs +8 -0
  31. package/src/session-store.lib.mjs +1 -1
  32. package/src/solve.clone-errors.lib.mjs +86 -0
  33. package/src/solve.repository.lib.mjs +36 -63
  34. package/src/solve.resource-diagnostics.lib.mjs +34 -1
  35. package/src/solve.validation.lib.mjs +16 -0
  36. package/src/start-command-cli.lib.mjs +60 -0
  37. package/src/telegram-bot.mjs +51 -95
  38. package/src/telegram-overrides-validation.lib.mjs +73 -0
  39. package/src/working-session-summary.lib.mjs +1 -1
@@ -1,4 +1,3 @@
1
- import { ensureUseM } from './use-m-bootstrap.lib.mjs';
2
1
  /**
3
2
  * Isolation Runner for Telegram bot
4
3
  *
@@ -30,30 +29,22 @@ import { buildRouterGitConfigEntries, buildRouterTaskEnv, getRouterSuppressedCre
30
29
  import { acquireRouterForTask, attachRouterTaskContainer, registerFormalAiWithRouter, releaseRouterForTask } from './router-task-isolation.lib.mjs';
31
30
  import { buildGitConfigEnv, GIT_PUSH_GUARD_CONTAINER_DIR, GIT_PUSH_GUARD_ESCAPE_ENV, hasForcePushOptIn, installGitPushGuard } from './git-push-guard.lib.mjs';
32
31
  export { getDockerIsolationImage, resolveDockerIsolationImageTag } from './hive-mind-image.lib.mjs';
33
- let commandStreamDollarPromise = null;
34
- async function getCommandStreamDollar() {
35
- if (!commandStreamDollarPromise) {
36
- commandStreamDollarPromise = (async () => {
37
- if (typeof globalThis.use === 'undefined') {
38
- await ensureUseM();
39
- }
40
- const { $ } = await globalThis.use('command-stream');
41
- return $;
42
- })();
43
- }
44
- try {
45
- return await commandStreamDollarPromise;
46
- } catch (error) {
47
- commandStreamDollarPromise = null;
48
- throw error;
49
- }
50
- }
51
32
  // Re-export the shared status predicates so existing callers that reach them via the isolation-runner module (e.g. session-monitor's `runner.isExecutingSessionStatus`) keep working. The canonical definitions live in session-status.lib.mjs so the killed/terminated/oom vocabulary stays consistent everywhere (issue #1927).
52
33
  export { isExecutingSessionStatus, isTerminalSessionStatus, isKilledSessionStatus } from './session-status.lib.mjs';
53
34
  // Issue #2175: the `$` output parsers live in their own module to keep this file
54
35
  // under the 1350-line warning threshold. Re-exported so importers are unaffected.
55
36
  import { isUnknownDockerExitCode, parseSessionExitFooter, parseSessionListOutput, parseSessionStatusOutput, parseStartCommandExecutionUuid, readSessionExitFromLog, shouldFallbackToScreenStatus } from './isolation-runner.parsers.lib.mjs';
56
37
  export { isUnknownDockerExitCode, parseSessionExitFooter, parseSessionListOutput, parseSessionStatusOutput, parseStartCommandExecutionUuid, readSessionExitFromLog, shouldFallbackToScreenStatus };
38
+ // Issue #2189: the `$` loader and PATH lookup live in their own module so the
39
+ // resume/attach wrappers can use them without importing this runner (a cycle).
40
+ import { findStartCommandBinary, getCommandStreamDollar } from './start-command-cli.lib.mjs';
41
+ export { findStartCommandBinary };
42
+ // Issue #2189: `$ --resume` / `$ --resume-all`, added in start-command 0.33.0
43
+ // (link-foundation/start#162). Re-exported so callers keep reaching every
44
+ // isolation verb through this module.
45
+ import { resumeAllIsolationSessions, resumeIsolatedSession } from './isolation-runner.resume.lib.mjs';
46
+ export { resumeAllIsolationSessions, resumeIsolatedSession };
47
+ export { parseExecutionResumeAllOutput, parseExecutionResumeOutput, RESUME_ALL_ACTIONS, RESUME_MODES } from './isolation-runner.resume.lib.mjs';
57
48
  // Valid isolation backends
58
49
  const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
59
50
  const DOCKER_CONTAINER_HOME = '/home/box';
@@ -311,20 +302,6 @@ async function runStartCommand(binPath, startCommandArgs) {
311
302
  export function generateSessionId() {
312
303
  return crypto.randomUUID();
313
304
  }
314
- /**
315
- * Find the `$` CLI binary path
316
- * @returns {Promise<string|null>} Path to `$` binary or null
317
- */
318
- async function findStartCommandBinary() {
319
- try {
320
- const $ = await getCommandStreamDollar();
321
- const result = await $({ mirror: false })`which $`;
322
- const path = result.stdout?.toString().trim() || '';
323
- return path || null;
324
- } catch {
325
- return null;
326
- }
327
- }
328
305
  /**
329
306
  * Verbose post-launch diagnostics for a native docker-isolated session.
330
307
  *
@@ -611,6 +588,21 @@ export async function stopIsolatedSession(sessionId, verbose = false) {
611
588
  console.log(`[VERBOSE] isolation-runner: $ --stop ${sessionId} stderr: ${stderr.substring(0, 300)}`);
612
589
  }
613
590
  }
591
+ // Issue #2189: `command-stream`'s `$` resolves — it does not throw — when the
592
+ // child exits non-zero, so the catch below never sees a refusal. `$ --stop`
593
+ // answers `Error: No execution found with UUID or session name: …` on stderr
594
+ // with exit code 1; without this check every such refusal was reported to the
595
+ // operator as a successful stop, and a session nobody stopped looked handled.
596
+ const code = Number.isFinite(result.code) ? result.code : 0;
597
+ if (code !== 0) {
598
+ // describeChildExit rather than an interpolated code: issue #2135 made it
599
+ // the single vocabulary for "how a child ended", so a `$` that was
600
+ // signalled reads the same way here as everywhere else. command-stream
601
+ // normalizes a signalled exit to 128+signum before we see it
602
+ // (node_modules/command-stream/src/$.process-runner-stream-kill.mjs:194),
603
+ // so there is no separate signal to pass.
604
+ return { success: false, output: stdout, error: stderr.trim() || describeChildExit({ command: '`$ --stop`', code }) };
605
+ }
614
606
  return { success: true, output: stdout || stderr, error: null };
615
607
  } catch (error) {
616
608
  const stderr = error?.stderr?.toString?.() || '';
@@ -679,6 +671,37 @@ export async function checkDockerContainerRunning(containerName, verbose = false
679
671
  return false;
680
672
  }
681
673
  }
674
+ /**
675
+ * Check whether the Docker container backing a session still exists at all —
676
+ * running or stopped.
677
+ *
678
+ * Issue #2189 requirement R2: a killed session should be re-entered rather than
679
+ * restarted from scratch, and `$ --resume` can only do that while the container
680
+ * is still there. `checkDockerContainerRunning` answers a different question (a
681
+ * stopped container is "not running" but is exactly the one worth resuming), so
682
+ * the state is read instead of the running flag.
683
+ *
684
+ * @param {string} containerName - Container name (the session UUID)
685
+ * @param {boolean} [verbose] - Enable verbose logging
686
+ * @returns {Promise<boolean>} True when `docker inspect` finds the container
687
+ */
688
+ export async function checkDockerContainerExists(containerName, verbose = false) {
689
+ if (!containerName) return false;
690
+ try {
691
+ const $ = await getCommandStreamDollar();
692
+ const result = await $({ mirror: false })`docker inspect -f ${'{{.State.Status}}'} ${containerName}`;
693
+ const code = Number.isFinite(result.code) ? result.code : 0;
694
+ const state = (result.stdout?.toString() || '').trim();
695
+ const exists = code === 0 && state !== '';
696
+ if (verbose) {
697
+ console.log(`[VERBOSE] isolation-runner: docker inspect state for '${containerName}': ${exists ? state : 'no such container'}`);
698
+ }
699
+ return exists;
700
+ } catch {
701
+ // `docker inspect` exits non-zero when no such container exists.
702
+ return false;
703
+ }
704
+ }
682
705
  export function parseDockerContainerWritableLayerSizeOutput(output) {
683
706
  const text = String(output || '').trim();
684
707
  if (!text) return null;
@@ -73,13 +73,22 @@ export function parseStartCommandExecutionUuid(output) {
73
73
  * `--output-format json` is supported, or human-readable key/value text.
74
74
  * Keep the parser tolerant so completion monitoring survives either format.
75
75
  *
76
+ * start-command 0.33.0 (link-foundation/start#164, #165) added three additive
77
+ * hint fields to a finished record: `exitReason` (e.g.
78
+ * `memory-exhaustion (v8-heap-limit)` or `signal (SIGSEGV)`),
79
+ * `memoryExhausted` and `memoryExhaustedReason` (the log line carrying the
80
+ * evidence). They are hints, never verdicts — upstream never lets them change
81
+ * `status`, `exitCode` or `oomKilled` — and they are absent on older `$`
82
+ * binaries, so they are parsed as nullable and every consumer keeps its own
83
+ * log-marker classification as defense in depth (issue #2189).
84
+ *
76
85
  * @param {string} output - Raw stdout from `$ --status`
77
- * @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, logPath: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, sessionName: string|null, processIds: Object, raw: string}}
86
+ * @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, logPath: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, sessionName: string|null, processIds: Object, oomKilled: boolean|null, exitReason: string|null, memoryExhausted: boolean|null, memoryExhaustedReason: string|null, raw: string}}
78
87
  */
79
88
  export function parseSessionStatusOutput(output) {
80
89
  const raw = (output || '').trim();
81
90
  if (!raw) {
82
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, oomKilled: null, raw: '' };
91
+ return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, oomKilled: null, exitReason: null, memoryExhausted: null, memoryExhaustedReason: null, raw: '' };
83
92
  }
84
93
  const normalizeBooleanField = value => {
85
94
  if (typeof value === 'boolean') return value;
@@ -112,6 +121,9 @@ export function parseSessionStatusOutput(output) {
112
121
  sessionName: data?.sessionName || data?.options?.sessionName || null,
113
122
  processIds,
114
123
  oomKilled: normalizeBooleanField(data?.oomKilled ?? data?.OOMKilled ?? data?.options?.oomKilled ?? data?.state?.oomKilled ?? data?.State?.OOMKilled),
124
+ exitReason: typeof data?.exitReason === 'string' && data.exitReason.trim() ? data.exitReason.trim() : null,
125
+ memoryExhausted: normalizeBooleanField(data?.memoryExhausted),
126
+ memoryExhaustedReason: typeof data?.memoryExhaustedReason === 'string' && data.memoryExhaustedReason.trim() ? data.memoryExhaustedReason.trim() : null,
115
127
  raw,
116
128
  };
117
129
  } catch {
@@ -123,7 +135,11 @@ export function parseSessionStatusOutput(output) {
123
135
  .find(line => line.trim() && !line.includes(' '))
124
136
  ?.trim() || null;
125
137
  const readField = name => {
126
- const match = raw.match(new RegExp(`^\\s*${name}\\s+"?([^"\\n]+)"?\\s*$`, 'mi'));
138
+ // Links notation separates key and value with whitespace (` exitReason x`);
139
+ // `--output-format text` uses a padded colon (`Exit Reason: x`). Accept
140
+ // both — the colon is optional, so every existing camelCase lookup is
141
+ // unchanged and the text labels (which contain a space) become readable too.
142
+ const match = raw.match(new RegExp(`^\\s*${name}\\s*:?\\s+"?([^"\\n]+)"?\\s*$`, 'mi'));
127
143
  return match ? match[1].trim() : null;
128
144
  };
129
145
  const readBooleanField = name => normalizeBooleanField(readField(name));
@@ -154,6 +170,12 @@ export function parseSessionStatusOutput(output) {
154
170
  sessionName: readField('sessionName'),
155
171
  processIds,
156
172
  oomKilled: readBooleanField('oomKilled'),
173
+ // `--output-format text` labels the same three fields `Exit Reason:`,
174
+ // `Memory Exhausted:` and `Memory Evidence:`; links notation uses the camelCase
175
+ // keys. Accept both so the parser does not depend on the output format.
176
+ exitReason: readField('exitReason') || readField('Exit Reason'),
177
+ memoryExhausted: readBooleanField('memoryExhausted') ?? readBooleanField('Memory Exhausted'),
178
+ memoryExhaustedReason: readField('memoryExhaustedReason') || readField('Memory Evidence'),
157
179
  raw,
158
180
  };
159
181
  }
@@ -286,6 +308,10 @@ export function parseSessionListOutput(output) {
286
308
  isolation: isolationCandidate ? isolationCandidate.toLowerCase() : null,
287
309
  workingDirectory: data.workingDirectory || null,
288
310
  sessionName: data.sessionName || data.options?.sessionName || null,
311
+ // Additive 0.33.0 hints (link-foundation/start#164, #165); null on older `$`.
312
+ exitReason: typeof data.exitReason === 'string' && data.exitReason.trim() ? data.exitReason.trim() : null,
313
+ memoryExhausted: typeof data.memoryExhausted === 'boolean' ? data.memoryExhausted : null,
314
+ memoryExhaustedReason: typeof data.memoryExhaustedReason === 'string' && data.memoryExhaustedReason.trim() ? data.memoryExhaustedReason.trim() : null,
289
315
  };
290
316
  })
291
317
  .filter(Boolean);
@@ -0,0 +1,263 @@
1
+ /**
2
+ * `$ --resume` / `$ --resume-all` wrappers (start-command >= 0.33.0).
3
+ *
4
+ * Issue #2189 reported that a killed working session was only ever *offered*
5
+ * for resume, and that when Hive Mind did resume one it had to start a fresh
6
+ * isolated run — the container the work had happened in, with its clone, its
7
+ * build cache and its half-finished branch, was thrown away. The missing
8
+ * capability was filed upstream as link-foundation/start#162 and delivered in
9
+ * `start-command@0.33.0`:
10
+ *
11
+ * - `$ --resume <id> -- <command>` re-enters an existing execution. For a
12
+ * stopped docker session it commits the container filesystem and runs the
13
+ * new command in a container derived from that snapshot, so the workspace
14
+ * survives. The execution UUID is preserved, so `--status`, `--list` and
15
+ * `--upload-log` keep addressing one logical session across restarts.
16
+ * - `$ --resume-all` re-attaches a completion watcher to every execution
17
+ * still marked running and reconciles the ones that ended unsupervised. It
18
+ * never restarts work silently.
19
+ *
20
+ * Both are additive: a Hive Mind talking to an older `$` gets a clean
21
+ * `unsupported` result and the caller falls back to its previous behaviour.
22
+ * Neither wrapper throws.
23
+ *
24
+ * @see https://github.com/link-assistant/hive-mind/issues/2189
25
+ * @see https://github.com/link-foundation/start/issues/162
26
+ */
27
+
28
+ import { describeChildExit } from './child-exit.lib.mjs';
29
+ import { findStartCommandBinary, getCommandStreamDollar, START_COMMAND_MISSING_ERROR } from './start-command-cli.lib.mjs';
30
+
31
+ /** Strategies `$ --resume` can pick, mirroring upstream `ResumeMode`. */
32
+ export const RESUME_MODES = Object.freeze({
33
+ DOCKER_START: 'docker-start',
34
+ DOCKER_SNAPSHOT: 'docker-snapshot',
35
+ RELAUNCH: 'relaunch',
36
+ });
37
+
38
+ /** Outcomes `$ --resume-all` reports per execution, mirroring `ResumeAllAction`. */
39
+ export const RESUME_ALL_ACTIONS = Object.freeze({
40
+ REATTACHED: 'reattached',
41
+ RUNNING: 'running',
42
+ RECONCILED: 'reconciled',
43
+ UNKNOWN: 'unknown',
44
+ });
45
+
46
+ /**
47
+ * Does this `$` failure mean the verb does not exist yet?
48
+ *
49
+ * An older binary rejects the flag while parsing, long before it looks at the
50
+ * store. Distinguishing that from a real refusal ("session is still running")
51
+ * is what lets the caller degrade gracefully instead of reporting a bug.
52
+ *
53
+ * @param {string} message - stderr/message from the failed invocation
54
+ * @returns {boolean}
55
+ */
56
+ export function isUnsupportedStartCommandVerb(message) {
57
+ const text = String(message || '').toLowerCase();
58
+ // 0.32.1 answers `$ --resume-all` with `Error: Unknown wrapper option:
59
+ // --resume-all` (verified against the pinned pre-0.33.0 binary), and other
60
+ // argument parsers word it differently; match the family, not one string.
61
+ return /unknown (\w+ )?(option|argument|flag)|unrecognized option|invalid option|no such option/.test(text);
62
+ }
63
+
64
+ /**
65
+ * Parse the `executionResume` block `$ --resume --output-format json` prints.
66
+ *
67
+ * Tolerates links notation too (`executionResume` followed by indented
68
+ * `key value` pairs), because an operator's `$` may default to it.
69
+ *
70
+ * @param {string} output - Raw stdout
71
+ * @returns {{uuid: string|null, mode: string|null, backend: string|null, sessionName: string|null, previousSessionName: string|null, snapshotImage: string|null, command: string|null, message: string|null}}
72
+ */
73
+ export function parseExecutionResumeOutput(output) {
74
+ const empty = { uuid: null, mode: null, backend: null, sessionName: null, previousSessionName: null, snapshotImage: null, command: null, message: null };
75
+ const raw = (output || '').trim();
76
+ if (!raw) return empty;
77
+ const str = value => (typeof value === 'string' && value.trim() ? value.trim() : null);
78
+ try {
79
+ const parsed = JSON.parse(raw);
80
+ const data = Array.isArray(parsed) ? parsed[0] : parsed;
81
+ return {
82
+ uuid: str(data?.uuid),
83
+ mode: str(data?.mode),
84
+ backend: str(data?.backend),
85
+ sessionName: str(data?.sessionName),
86
+ previousSessionName: str(data?.previousSessionName),
87
+ snapshotImage: str(data?.snapshotImage),
88
+ command: str(data?.command),
89
+ message: str(data?.message),
90
+ };
91
+ } catch {
92
+ // Links notation — fall through.
93
+ }
94
+ // Links notation indents `key value`; `--output-format text` prints
95
+ // `Label: value`. The optional colon covers both with one expression.
96
+ const readField = name => {
97
+ const match = raw.match(new RegExp(`^\\s*${name}\\s*:?\\s+"?([^"\\n]+)"?\\s*$`, 'mi'));
98
+ return str(match?.[1]);
99
+ };
100
+ return {
101
+ uuid: readField('uuid'),
102
+ mode: readField('mode') || readField('Resume Mode'),
103
+ backend: readField('backend'),
104
+ sessionName: readField('sessionName') || readField('Session Name'),
105
+ previousSessionName: readField('previousSessionName'),
106
+ snapshotImage: readField('snapshotImage'),
107
+ command: readField('command'),
108
+ message: readField('message'),
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Parse the `executionResumeAll` block `$ --resume-all --output-format json`
114
+ * prints. Anything unparseable yields an empty list rather than a throw — a
115
+ * startup reconciliation must never be able to stop the bot from starting.
116
+ *
117
+ * @param {string} output - Raw stdout
118
+ * @returns {Array<{uuid: string|null, backend: string|null, sessionName: string|null, state: string|null, action: string|null, exitCode: number|null, message: string|null}>}
119
+ */
120
+ export function parseExecutionResumeAllOutput(output) {
121
+ const raw = (output || '').trim();
122
+ if (!raw) return [];
123
+ let parsed;
124
+ try {
125
+ parsed = JSON.parse(raw);
126
+ } catch {
127
+ return [];
128
+ }
129
+ const records = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.executions) ? parsed.executions : [];
130
+ const str = value => (typeof value === 'string' && value.trim() ? value.trim() : null);
131
+ return records
132
+ .map(entry => {
133
+ if (!entry || typeof entry !== 'object') return null;
134
+ const exitCode = entry.exitCode === null || entry.exitCode === undefined ? null : Number(entry.exitCode);
135
+ return {
136
+ uuid: str(entry.uuid),
137
+ backend: str(entry.backend),
138
+ sessionName: str(entry.sessionName),
139
+ state: str(entry.state),
140
+ action: str(entry.action),
141
+ exitCode: Number.isFinite(exitCode) ? exitCode : null,
142
+ message: str(entry.message),
143
+ };
144
+ })
145
+ .filter(Boolean);
146
+ }
147
+
148
+ /**
149
+ * Normalize what `command-stream`'s `$` hands back for one invocation.
150
+ *
151
+ * `$` does *not* throw on a non-zero exit — it resolves with `code` set (checked
152
+ * against command-stream in experiments/issue-2189-start-command-resume.mjs).
153
+ * Reading only the resolved value would therefore report every refusal
154
+ * ("session is still running", "no execution found", "unknown wrapper option")
155
+ * as a successful resume. Both shapes are folded into one verdict here.
156
+ *
157
+ * @param {object|null} result - Resolved value from `$`
158
+ * @param {*} [error] - Rejection from `$`, when it threw instead
159
+ * @returns {{ok: boolean, stdout: string, message: string|null, unsupported: boolean}}
160
+ */
161
+ function interpretStartCommandResult(result, error = null) {
162
+ const source = error || result || {};
163
+ const stdout = source.stdout?.toString?.().trim() || '';
164
+ const stderr = source.stderr?.toString?.().trim() || '';
165
+ const code = error ? (Number.isFinite(source.code) ? source.code : 1) : Number.isFinite(source.code) ? source.code : 0;
166
+ if (!error && code === 0) return { ok: true, stdout, message: null, unsupported: false };
167
+ // describeChildExit is the repository's single vocabulary for "how a child
168
+ // ended" (issue #2135); command-stream has already normalized a signalled
169
+ // exit to 128+signum by this point, so the code is all there is to say.
170
+ const message = stderr || source.message || describeChildExit({ command: 'start-command', code });
171
+ return { ok: false, stdout, message, unsupported: isUnsupportedStartCommandVerb(`${message}\n${stdout}`) };
172
+ }
173
+
174
+ /**
175
+ * Re-enter an existing execution via `$ --resume <identifier> [-- <command>]`.
176
+ *
177
+ * With a `command`, a stopped docker session is snapshotted and the command runs
178
+ * against that snapshot, so the work already on disk is preserved — this is the
179
+ * "re-enter the same container" half of issue #2189. Without one, the stored
180
+ * command is re-run in place.
181
+ *
182
+ * The command is passed as a single argument after `--`, exactly like the launch
183
+ * path does: start-command 0.33.0 preserves argv boundaries and runs a lone
184
+ * argument verbatim as a shell script, so no quoting is lost.
185
+ *
186
+ * @param {string} identifier - Execution UUID or session name
187
+ * @param {Object} [options]
188
+ * @param {string|null} [options.command] - Command to run against the resumed session
189
+ * @param {boolean} [options.verbose]
190
+ * @returns {Promise<{success: boolean, unsupported: boolean, uuid: string|null, mode: string|null, backend: string|null, sessionName: string|null, previousSessionName: string|null, snapshotImage: string|null, message: string|null, output: string, error: string|null}>}
191
+ */
192
+ export async function resumeIsolatedSession(identifier, { command = null, verbose = false } = {}) {
193
+ const base = { success: false, unsupported: false, uuid: null, mode: null, backend: null, sessionName: null, previousSessionName: null, snapshotImage: null, message: null, output: '', error: null };
194
+ if (!identifier) return { ...base, error: 'No execution identifier was given to resume.' };
195
+
196
+ const binPath = await findStartCommandBinary();
197
+ if (!binPath) {
198
+ if (verbose) console.log('[VERBOSE] isolation-runner: cannot resume - $ binary not found');
199
+ return { ...base, unsupported: true, error: START_COMMAND_MISSING_ERROR };
200
+ }
201
+
202
+ try {
203
+ const $ = await getCommandStreamDollar();
204
+ const raw = command ? await $({ mirror: false })`${binPath} --resume ${identifier} --output-format json -- ${command}` : await $({ mirror: false })`${binPath} --resume ${identifier} --output-format json`;
205
+ const { ok, stdout, message, unsupported } = interpretStartCommandResult(raw);
206
+ if (!ok) {
207
+ if (verbose) console.log(`[VERBOSE] isolation-runner: $ --resume ${identifier} refused${unsupported ? ' (verb not supported by this $ build)' : ''}: ${message}`);
208
+ return { ...base, unsupported, output: stdout, error: message };
209
+ }
210
+ const parsed = parseExecutionResumeOutput(stdout);
211
+ if (verbose) {
212
+ console.log(`[VERBOSE] isolation-runner: $ --resume ${identifier} → mode=${parsed.mode || '(unknown)'} session=${parsed.sessionName || '(unknown)'} uuid=${parsed.uuid || '(unknown)'}`);
213
+ }
214
+ return { ...base, ...parsed, success: true, output: stdout };
215
+ } catch (error) {
216
+ const { stdout, message, unsupported } = interpretStartCommandResult(null, error);
217
+ if (verbose) console.log(`[VERBOSE] isolation-runner: $ --resume ${identifier} failed${unsupported ? ' (verb not supported by this $ build)' : ''}: ${message}`);
218
+ return { ...base, unsupported, output: stdout, error: message };
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Reconcile every execution still marked running via `$ --resume-all`.
224
+ *
225
+ * Run at bot startup: the detached-docker completion watchers are children of
226
+ * the process that launched them, so a bot restart leaves every running
227
+ * container unsupervised — its exit would never be written to the log footer,
228
+ * which is one of the ways issue #2189's session stayed in limbo. `--resume-all`
229
+ * re-attaches a watcher to what is alive and finalizes what died meanwhile. It
230
+ * starts no work on its own.
231
+ *
232
+ * @param {Object} [options]
233
+ * @param {boolean} [options.verbose]
234
+ * @returns {Promise<{success: boolean, unsupported: boolean, executions: Array<Object>, output: string, error: string|null}>}
235
+ */
236
+ export async function resumeAllIsolationSessions({ verbose = false } = {}) {
237
+ const base = { success: false, unsupported: false, executions: [], output: '', error: null };
238
+ const binPath = await findStartCommandBinary();
239
+ if (!binPath) {
240
+ if (verbose) console.log('[VERBOSE] isolation-runner: cannot run $ --resume-all - $ binary not found');
241
+ return { ...base, unsupported: true, error: START_COMMAND_MISSING_ERROR };
242
+ }
243
+
244
+ try {
245
+ const $ = await getCommandStreamDollar();
246
+ const raw = await $({ mirror: false })`${binPath} --resume-all --output-format json`;
247
+ const { ok, stdout, message, unsupported } = interpretStartCommandResult(raw);
248
+ if (!ok) {
249
+ if (verbose) console.log(`[VERBOSE] isolation-runner: $ --resume-all refused${unsupported ? ' (verb not supported by this $ build)' : ''}: ${message}`);
250
+ return { ...base, unsupported, output: stdout, error: message };
251
+ }
252
+ const executions = parseExecutionResumeAllOutput(stdout);
253
+ if (verbose) {
254
+ const summary = executions.map(entry => `${entry.action}:${entry.sessionName || entry.uuid}`).join(', ') || '(none)';
255
+ console.log(`[VERBOSE] isolation-runner: $ --resume-all reconciled ${executions.length} execution(s): ${summary}`);
256
+ }
257
+ return { ...base, success: true, executions, output: stdout };
258
+ } catch (error) {
259
+ const { stdout, message, unsupported } = interpretStartCommandResult(null, error);
260
+ if (verbose) console.log(`[VERBOSE] isolation-runner: $ --resume-all failed${unsupported ? ' (verb not supported by this $ build)' : ''}: ${message}`);
261
+ return { ...base, unsupported, output: stdout, error: message };
262
+ }
263
+ }
@@ -508,6 +508,13 @@ en
508
508
  must
509
509
  be
510
510
  type "URL must be a GitHub {{allowedTypes}} (not {{type}})"
511
+ recovered """
512
+ ℹ️ I repaired the link before starting.
513
+
514
+ You sent: {{original}}
515
+ Using: {{used}}
516
+ Repaired: {{repairs}}
517
+ """
511
518
  language
512
519
  invalid """
513
520
  ❌ Invalid language. Supported: {{supported}}.
@@ -508,6 +508,13 @@ hi
508
508
  must
509
509
  be
510
510
  type "URL GitHub {{allowedTypes}} होना चाहिए ({{type}} नहीं)"
511
+ recovered """
512
+ ℹ️ शुरू करने से पहले लिंक ठीक की गई।
513
+
514
+ आपने भेजा: {{original}}
515
+ उपयोग किया जा रहा है: {{used}}
516
+ ठीक किया गया: {{repairs}}
517
+ """
511
518
  language
512
519
  invalid """
513
520
  ❌ अमान्य भाषा। समर्थित: {{supported}}।
@@ -508,6 +508,13 @@ ru
508
508
  must
509
509
  be
510
510
  type "URL должен быть GitHub {{allowedTypes}} (не {{type}})"
511
+ recovered """
512
+ ℹ️ Ссылка была исправлена перед запуском.
513
+
514
+ Вы отправили: {{original}}
515
+ Используется: {{used}}
516
+ Исправлено: {{repairs}}
517
+ """
511
518
  language
512
519
  invalid """
513
520
  ❌ Неверный язык. Поддерживаются: {{supported}}.
@@ -508,6 +508,13 @@ zh
508
508
  must
509
509
  be
510
510
  type "URL 必须是 GitHub {{allowedTypes}}(不是 {{type}})"
511
+ recovered """
512
+ ℹ️ 开始前已修复链接。
513
+
514
+ 您发送的:{{original}}
515
+ 实际使用:{{used}}
516
+ 修复内容:{{repairs}}
517
+ """
511
518
  language
512
519
  invalid """
513
520
  ❌ 语言无效。支持的语言:{{supported}}。
@@ -106,7 +106,7 @@ const measureDiff = diff => {
106
106
  section = null;
107
107
  };
108
108
 
109
- for (let start = 0; start < diff.length; ) {
109
+ for (let start = 0; start < diff.length;) {
110
110
  let end = diff.indexOf('\n', start);
111
111
  if (end === -1) end = diff.length;
112
112
  const line = diff.slice(start, end);
@@ -54,6 +54,16 @@ export const KILL_DIAGNOSTICS_LOG_BYTES = 1024 * 1024;
54
54
  */
55
55
  export const HEAP_EXHAUSTED_PERCENT = 90;
56
56
 
57
+ /**
58
+ * Prefix start-command 0.33.0 puts on every `exitReason` that reports memory
59
+ * exhaustion, whatever the mechanism — `memory-exhaustion (v8-heap-limit)`,
60
+ * `memory-exhaustion (kernel-oom-killer)`, `memory-exhaustion (go-runtime)`,
61
+ * `memory-exhaustion (allocation-failure)`. Matching the prefix rather than the
62
+ * exact strings means a new upstream mechanism is classified correctly without
63
+ * a Hive Mind release. See link-foundation/start#164 and #165.
64
+ */
65
+ export const UPSTREAM_MEMORY_EXHAUSTION_PREFIX = 'memory-exhaustion';
66
+
57
67
  /** Disk is considered full at or above this used percentage… */
58
68
  export const DISK_FULL_USED_PERCENT = 95;
59
69
  /** …or below this much free space, whichever triggers first. */
@@ -268,9 +278,12 @@ function describeDisk(disk, timestamp) {
268
278
  * @param {number|null} [params.exitCode]
269
279
  * @param {Object|null} [params.system] - collectSystemKillDiagnostics() result
270
280
  * @param {boolean} [params.stopRequestedByUser] - The operator asked for the stop
281
+ * @param {boolean|null} [params.reportedMemoryExhausted] - `$ --status` `memoryExhausted` (start-command >= 0.33.0)
282
+ * @param {string|null} [params.reportedMemoryExhaustedReason] - `$ --status` `memoryExhaustedReason` (the evidence line)
283
+ * @param {string|null} [params.reportedExitReason] - `$ --status` `exitReason` hint, e.g. `memory-exhaustion (v8-heap-limit)`
271
284
  * @returns {{cause: string, summary: string, evidence: string[], memory: Object|null, disk: Object|null, victims: Array}}
272
285
  */
273
- export function describeKillCause({ logText = null, resourceMarkers = null, oomKilled = false, exitCode = null, system = null, stopRequestedByUser = false } = {}) {
286
+ export function describeKillCause({ logText = null, resourceMarkers = null, oomKilled = false, exitCode = null, system = null, stopRequestedByUser = false, reportedMemoryExhausted = null, reportedMemoryExhaustedReason = null, reportedExitReason = null } = {}) {
274
287
  const parsed = resourceMarkers || (logText ? parseResourceMarkers(logText) : { markers: [], byPhase: {} });
275
288
  const memoryMarker = selectLastMemoryResourceMarker(parsed);
276
289
  const heapMarker = selectLastHeapResourceMarker(parsed);
@@ -310,6 +323,30 @@ export function describeKillCause({ logText = null, resourceMarkers = null, oomK
310
323
  evidence.push(`the ${fatalMemoryMarker.runtime} runtime aborted on its own heap limit: \`${fatalMemoryMarker.line}\` (a self-abort is invisible to \`docker inspect\` and to cgroup OOM counters)`);
311
324
  }
312
325
 
326
+ // Issue #2189 also went upstream: start-command 0.33.0 (link-foundation/start
327
+ // #164, #165) performs the same tail scan inside `$` and reports it as
328
+ // `memoryExhausted` / `memoryExhaustedReason` / `exitReason`. Consuming it
329
+ // catches the case our own scan cannot: the fatal line scrolled out of the
330
+ // bounded window we read, but `$` saw it when the command exited. The local
331
+ // scan stays as defense in depth — these fields are absent on an older `$`.
332
+ const reportedExitReasonText = typeof reportedExitReason === 'string' && reportedExitReason.trim() ? reportedExitReason.trim() : null;
333
+ const reportedMemoryExhaustion = abnormalExit && (reportedMemoryExhausted === true || (reportedExitReasonText !== null && reportedExitReasonText.startsWith(UPSTREAM_MEMORY_EXHAUSTION_PREFIX)));
334
+ // `memory-exhaustion (v8-heap-limit)` → `v8-heap-limit`: the prefix is already
335
+ // said in words by the surrounding sentence, so only the mechanism is new.
336
+ const reportedMechanism =
337
+ reportedMemoryExhaustion && reportedExitReasonText
338
+ ? reportedExitReasonText
339
+ .slice(UPSTREAM_MEMORY_EXHAUSTION_PREFIX.length)
340
+ .trim()
341
+ .replace(/^\((.*)\)$/, '$1') || null
342
+ : null;
343
+ if (reportedMemoryExhaustion) {
344
+ const detail = reportedMemoryExhaustedReason ? `: \`${reportedMemoryExhaustedReason}\`` : '';
345
+ evidence.push(`\`$ --status\` reports memory exhaustion${reportedMechanism ? ` (\`${reportedMechanism}\`)` : ''}${detail}`);
346
+ } else if (reportedExitReasonText && abnormalExit) {
347
+ evidence.push(`\`$ --status\` reports \`exitReason = ${reportedExitReasonText}\``);
348
+ }
349
+
313
350
  const ratio = memoryRatio(memory);
314
351
  const memoryExhausted = ratio !== null && ratio <= MEMORY_EXHAUSTED_AVAILABLE_RATIO;
315
352
  // A heap already at the limit is only evidence of a kill when the session
@@ -322,7 +359,7 @@ export function describeKillCause({ logText = null, resourceMarkers = null, oomK
322
359
  let cause = KILL_CAUSE_UNKNOWN;
323
360
  if (stopRequestedByUser) {
324
361
  cause = KILL_CAUSE_FORCED_KILL;
325
- } else if (victims.length > 0 || (cgroupOomKills !== null && cgroupOomKills > 0) || oomKilled || memoryExhausted || fatalMemoryMarker || heapExhausted) {
362
+ } else if (victims.length > 0 || (cgroupOomKills !== null && cgroupOomKills > 0) || oomKilled || memoryExhausted || fatalMemoryMarker || heapExhausted || reportedMemoryExhaustion) {
326
363
  cause = KILL_CAUSE_OUT_OF_MEMORY;
327
364
  } else if (diskFull) {
328
365
  cause = KILL_CAUSE_DISK_FULL;
@@ -340,6 +377,11 @@ export function describeKillCause({ logText = null, resourceMarkers = null, oomK
340
377
  // Same shape as the fatal-marker case, but reconstructed from telemetry when
341
378
  // the fatal line itself was lost (truncated tail, killed before flushing).
342
379
  summary = `out of memory — the runtime's own heap was exhausted: ${heapLine}${memoryLine ? ` (host memory was fine: ${memoryLine})` : ''}`;
380
+ } else if (cause === KILL_CAUSE_OUT_OF_MEMORY && reportedMemoryExhaustion && victims.length === 0 && !oomKilled && !(cgroupOomKills > 0) && !memoryExhausted) {
381
+ // Only `$` saw the evidence (our bounded window missed the fatal line).
382
+ // Quote what it saw rather than falling back to the host-memory phrasing,
383
+ // which would again read as a contradiction on a healthy machine.
384
+ summary = `out of memory — start-command reported memory exhaustion${reportedMechanism ? ` (${reportedMechanism})` : ''}${reportedMemoryExhaustedReason ? `: \`${reportedMemoryExhaustedReason}\`` : ''}${memoryLine ? ` (host memory was fine: ${memoryLine})` : ''}`;
343
385
  } else if (cause === KILL_CAUSE_OUT_OF_MEMORY) {
344
386
  const victim = victims.length > 0 ? `, kernel OOM killer terminated \`${victims[victims.length - 1].comm || 'unknown'}\` (pid ${victims[victims.length - 1].pid ?? '?'})` : '';
345
387
  summary = `out of memory${memoryLine ? ` — ${memoryLine}` : ''}${victim}`;
@@ -352,7 +394,7 @@ export function describeKillCause({ logText = null, resourceMarkers = null, oomK
352
394
  summary = 'unknown — no resource marker, cgroup counter or kernel OOM report was available';
353
395
  }
354
396
 
355
- return { cause, summary, evidence, memory, heap: heapMemory, heapUsedPercent, disk, victims, fatalMemoryMarker };
397
+ return { cause, summary, evidence, memory, heap: heapMemory, heapUsedPercent, disk, victims, fatalMemoryMarker, reportedMemoryExhaustion, reportedExitReason: reportedExitReasonText };
356
398
  }
357
399
 
358
400
  /**
@@ -429,7 +471,7 @@ export function formatKillResumeSection({ sessionId = null, attempt = null, maxA
429
471
  * @param {Object} [options]
430
472
  * @returns {Promise<{section: string, diagnosis: Object|null}>}
431
473
  */
432
- export async function buildKillDiagnosticsSection(logPath, { verbose = false, readFile = fsPromises.readFile, maxLogBytes = KILL_DIAGNOSTICS_LOG_BYTES, oomKilled = false, exitCode = null, stopRequestedByUser = false, locale = null, collectSystem = collectSystemKillDiagnostics } = {}) {
474
+ export async function buildKillDiagnosticsSection(logPath, { verbose = false, readFile = fsPromises.readFile, maxLogBytes = KILL_DIAGNOSTICS_LOG_BYTES, oomKilled = false, exitCode = null, stopRequestedByUser = false, locale = null, collectSystem = collectSystemKillDiagnostics, reportedMemoryExhausted = null, reportedMemoryExhaustedReason = null, reportedExitReason = null } = {}) {
433
475
  try {
434
476
  let logText = '';
435
477
  if (logPath) {
@@ -441,7 +483,7 @@ export async function buildKillDiagnosticsSection(logPath, { verbose = false, re
441
483
  logText = await readLogTextBounded(logPath, { readFile, maxBytes: maxLogBytes, verbose });
442
484
  }
443
485
  const system = await collectSystem({ verbose });
444
- const diagnosis = describeKillCause({ logText, oomKilled, exitCode, system, stopRequestedByUser });
486
+ const diagnosis = describeKillCause({ logText, oomKilled, exitCode, system, stopRequestedByUser, reportedMemoryExhausted, reportedMemoryExhaustedReason, reportedExitReason });
445
487
  if (verbose) console.log(`[VERBOSE] kill-diagnostics: cause=${diagnosis.cause} — ${diagnosis.summary}`);
446
488
  return { section: formatKillDiagnosticsSection(diagnosis, { locale }), diagnosis };
447
489
  } catch (error) {