@link-assistant/hive-mind 2.15.2 → 2.17.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 (49) 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 +63 -5
  11. package/src/child-exit.lib.mjs +53 -1
  12. package/src/claude.session-tokens.lib.mjs +10 -8
  13. package/src/claude.session-transcript-repair.lib.mjs +65 -34
  14. package/src/cleanup.mjs +57 -3
  15. package/src/codex.lib.mjs +11 -1
  16. package/src/development-log.lib.mjs +22 -7
  17. package/src/disk-guard.lib.mjs +21 -1
  18. package/src/formal-ai-version.lib.mjs +10 -6
  19. package/src/github-error-reporter.lib.mjs +84 -2
  20. package/src/github.lib.mjs +212 -152
  21. package/src/instrument.mjs +12 -14
  22. package/src/instrument.sanitize.lib.mjs +52 -0
  23. package/src/isolation-runner.lib.mjs +56 -33
  24. package/src/isolation-runner.parsers.lib.mjs +29 -3
  25. package/src/isolation-runner.resume.lib.mjs +263 -0
  26. package/src/log-bounded-read.lib.mjs +411 -0
  27. package/src/log-sanitize-stream.lib.mjs +267 -0
  28. package/src/log-sanitize-worker-entry.mjs +31 -0
  29. package/src/log-sanitize-worker.lib.mjs +186 -0
  30. package/src/log-upload.lib.mjs +16 -4
  31. package/src/pull-request-changes.lib.mjs +1 -1
  32. package/src/session-completion-state.lib.mjs +124 -0
  33. package/src/session-kill-diagnostics.lib.mjs +117 -12
  34. package/src/session-kill-policy.lib.mjs +18 -7
  35. package/src/session-kill-resume.in-place.lib.mjs +136 -0
  36. package/src/session-kill-resume.lib.mjs +48 -18
  37. package/src/session-monitor.kill-sections.lib.mjs +8 -0
  38. package/src/session-monitor.lib.mjs +132 -9
  39. package/src/session-store.lib.mjs +15 -1
  40. package/src/solve.clone-errors.lib.mjs +86 -0
  41. package/src/solve.config.lib.mjs +5 -2
  42. package/src/solve.repository.lib.mjs +36 -63
  43. package/src/solve.resource-diagnostics.lib.mjs +105 -5
  44. package/src/start-command-cli.lib.mjs +60 -0
  45. package/src/telegram-bot.mjs +31 -91
  46. package/src/telegram-log-command.lib.mjs +7 -3
  47. package/src/telegram-overrides-validation.lib.mjs +73 -0
  48. package/src/telegram-terminal-watch-command.lib.mjs +9 -1
  49. package/src/working-session-summary.lib.mjs +1 -1
@@ -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
+ }