@link-assistant/hive-mind 2.16.0 → 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.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Version floors for the Agent CLI, and the predicates that read them.
3
+ *
4
+ * Each floor is the first Agent release in which a behaviour Hive Mind depends
5
+ * on actually holds; below it the CLI does something subtly wrong rather than
6
+ * failing, so the caller refuses to start instead of trusting the result. The
7
+ * comments on each constant record which upstream issue moved the floor.
8
+ *
9
+ * Extracted from `src/agent.lib.mjs` when issue #2186 pushed that file past the
10
+ * 1350-line warning threshold enforced by `scripts/check-file-line-limits.sh`.
11
+ * `agent.lib.mjs` re-exports every name here, so importers are unaffected.
12
+ *
13
+ * @see https://github.com/link-assistant/hive-mind/issues/2198
14
+ */
15
+
16
+ import semver from 'semver';
17
+
18
+ export const MIN_AGENT_LIVE_INPUT_VERSION = '0.24.1';
19
+
20
+ export const getAgentCliVersion = versionOutput => {
21
+ // `agent --version` can come back as `undefined` when the probe times out or
22
+ // the binary writes nothing to stdout, and `semver.clean(undefined)` throws.
23
+ // The floors below must answer "unknown", not blow up, so the caller reports
24
+ // the missing version instead of a `TypeError`.
25
+ const text = versionOutput == null ? '' : String(versionOutput);
26
+ return semver.clean(text) || semver.coerce(text)?.version || null;
27
+ };
28
+
29
+ export const agentCliSupportsLiveInput = versionOutput => {
30
+ const version = getAgentCliVersion(versionOutput);
31
+ return !!version && semver.gte(version, MIN_AGENT_LIVE_INPUT_VERSION);
32
+ };
33
+
34
+ /**
35
+ * Agent only fails closed on a `--model` argv it cannot parse from js-0.25.8
36
+ * onwards (link-assistant/agent#293, fixed by PR #294): earlier releases logged
37
+ * a CRITICAL record and then answered with their *default* model. Issue #2146
38
+ * requires Formal AI to be the only model a task can reach, and a guard that
39
+ * reads the CRITICAL record can only stop the run after Agent has already
40
+ * decided, so a Formal AI task refuses to start below this release.
41
+ */
42
+ export const MIN_AGENT_FORMAL_AI_VERSION = '0.25.8';
43
+
44
+ /** True when this Agent CLI aborts instead of silently picking another model. */
45
+ export const agentCliFailsClosedOnModelMismatch = versionOutput => {
46
+ const version = getAgentCliVersion(versionOutput);
47
+ return !!version && semver.gte(version, MIN_AGENT_FORMAL_AI_VERSION);
48
+ };
49
+
50
+ /**
51
+ * Agent keeps a rollback snapshot per project under
52
+ * `$XDG_DATA_HOME/link-assistant-agent/snapshot/<project id>`, and that project
53
+ * id is the worktree's *root commit*. Before js-0.26.1 the store was a
54
+ * standalone object database — no `objects/info/alternates` — and nothing ever
55
+ * removed it, so any harness that runs the agent inside a throwaway `git init`
56
+ * checkout minted a brand-new full copy of the repository per invocation and
57
+ * never reclaimed one. Issue #2186 measured 115 orphaned stores / 31 GB in a
58
+ * single 9.5 h task (~5 GB/h, every recorded worktree already deleted) while
59
+ * every Hive Mind disk check — the 10 GB pre-flight gate, `disk-guard`,
60
+ * `hive-cleanup` — reported a healthy workspace, because all of them only look
61
+ * at `/tmp`. link-assistant/agent#298 (PR #300, shipped in 0.26.1) shares the
62
+ * repository's objects through `objects/info/alternates` and prunes projects
63
+ * whose recorded worktree no longer exists, which is what makes an unattended
64
+ * multi-hour run bounded. Older releases are refused rather than left to fill
65
+ * the disk.
66
+ */
67
+ export const MIN_AGENT_SNAPSHOT_HYGIENE_VERSION = '0.26.1';
68
+
69
+ /** True when this Agent CLI shares snapshot objects and prunes dead projects. */
70
+ export const agentCliPrunesOrphanSnapshots = versionOutput => {
71
+ const version = getAgentCliVersion(versionOutput);
72
+ return !!version && semver.gte(version, MIN_AGENT_SNAPSHOT_HYGIENE_VERSION);
73
+ };
@@ -83,6 +83,52 @@ export function createHeartbeat({ logger, getActiveSessionCount, intervalMs = DE
83
83
  };
84
84
  }
85
85
 
86
+ /**
87
+ * Reconcile the isolation backend's own view of still-running executions.
88
+ *
89
+ * Issue #2189: the detached-docker completion watchers are children of the
90
+ * process that launched them, so a bot restart leaves every running container
91
+ * unsupervised — its exit is never written to the log footer, which is one of
92
+ * the ways the reported session stayed in limbo. `$ --resume-all`
93
+ * (start-command >= 0.33.0) re-attaches a watcher to what is still alive and
94
+ * finalizes what died meanwhile; it starts no work on its own.
95
+ *
96
+ * Run *before* the durable store is replayed so the first monitor tick reads
97
+ * settled state rather than racing upstream's reconciliation. An older `$`
98
+ * without the verb, or no isolation at all, is a no-op — never an error.
99
+ *
100
+ * @returns {Promise<{attempted: boolean, reconciled: number, reattached: number, running: number, unsupported: boolean, error: string|null}>}
101
+ */
102
+ async function reconcileIsolationExecutions({ reconcileIsolationSessions, verbose, logger, consoleImpl }) {
103
+ const idle = { attempted: false, reconciled: 0, reattached: 0, running: 0, unsupported: false, error: null };
104
+ if (typeof reconcileIsolationSessions !== 'function') return idle;
105
+ try {
106
+ const result = await reconcileIsolationSessions({ verbose });
107
+ const executions = Array.isArray(result?.executions) ? result.executions : [];
108
+ const count = action => executions.filter(entry => entry?.action === action).length;
109
+ const summary = {
110
+ attempted: true,
111
+ reconciled: count('reconciled'),
112
+ reattached: count('reattached'),
113
+ running: count('running'),
114
+ unsupported: result?.unsupported === true,
115
+ error: result?.success === false && result?.unsupported !== true ? result?.error || 'unknown error' : null,
116
+ };
117
+ if (summary.reconciled > 0 || summary.reattached > 0) {
118
+ consoleImpl.log(`♻️ Reconciled ${executions.length} isolated execution(s) with the isolation backend (${summary.reattached} re-attached, ${summary.reconciled} finalized after running unsupervised)`);
119
+ } else if (verbose) {
120
+ consoleImpl.log(`[VERBOSE] resume-all: ${summary.unsupported ? 'this `$` build has no --resume-all; skipped' : `${executions.length} execution(s), nothing to re-attach`}`);
121
+ }
122
+ logger?.event?.('isolation_executions_reconciled', summary);
123
+ return summary;
124
+ } catch (error) {
125
+ // Startup reconciliation is best effort by construction: it must never be
126
+ // able to stop the bot from coming up.
127
+ consoleImpl.error(`[telegram-bot] Could not reconcile isolated executions: ${error?.message || error}`);
128
+ return { ...idle, attempted: true, error: error?.message || String(error) };
129
+ }
130
+ }
131
+
86
132
  /**
87
133
  * Resume sessions left tracked by a previous run (requirements #2/#4).
88
134
  *
@@ -92,9 +138,14 @@ export function createHeartbeat({ logger, getActiveSessionCount, intervalMs = DE
92
138
  * `sessions_resumed` event either way and never throws: a resume failure must
93
139
  * not stop the bot from coming up.
94
140
  *
95
- * @returns {Promise<{ resumed: any[], skipped: any[], error?: Error }>}
141
+ * Issue #2189 added the step before it: `reconcileIsolationSessions`
142
+ * (`$ --resume-all`) settles the isolation backend's own record of what is
143
+ * still running, so the replayed sessions are matched against the truth.
144
+ *
145
+ * @returns {Promise<{ resumed: any[], skipped: any[], reconciliation: object, error?: Error }>}
96
146
  */
97
- export async function resumeSessionsOnLaunch({ resumeTrackedSessions, botStartTime, verbose = false, logger, consoleImpl = console } = {}) {
147
+ export async function resumeSessionsOnLaunch({ resumeTrackedSessions, reconcileIsolationSessions = null, botStartTime, verbose = false, logger, consoleImpl = console } = {}) {
148
+ const reconciliation = await reconcileIsolationExecutions({ reconcileIsolationSessions, verbose, logger, consoleImpl });
98
149
  try {
99
150
  const { resumed, skipped } = await resumeTrackedSessions({ botStartTime, verbose });
100
151
  if (resumed.length > 0) {
@@ -105,11 +156,11 @@ export async function resumeSessionsOnLaunch({ resumeTrackedSessions, botStartTi
105
156
  skipped: skipped.length,
106
157
  sessions: resumed.map(r => r.sessionName),
107
158
  });
108
- return { resumed, skipped };
159
+ return { resumed, skipped, reconciliation };
109
160
  } catch (error) {
110
161
  consoleImpl.error(`[telegram-bot] Failed to resume tracked sessions: ${error.message}`);
111
162
  logger.error('Failed to resume tracked sessions', { error: error.message });
112
- return { resumed: [], skipped: [], error };
163
+ return { resumed: [], skipped: [], reconciliation, error };
113
164
  }
114
165
  }
115
166
 
package/src/cleanup.mjs CHANGED
@@ -39,6 +39,7 @@ import { isConfirmationYes, readConfirmationLine } from './confirmation.lib.mjs'
39
39
  import { classifyEntries, summarize, formatBytes, describeReason, buildActiveMatchers, DEFAULT_PROTECTED_NAMES, formatEntryContext, formatTaskSummary, DEFAULT_DOCKER_ISOLATION_CLEANUP_MODE, describeDockerIsolationReason, formatDockerIsolationContainerSummary, normalizeDockerIsolationCleanupMode, planDockerIsolationCleanup } from './cleanup.lib.mjs';
40
40
  import { getTempRoot, listTempEntries, getPathSize, readFolderGitInfo, listProcessHeldPaths, getActiveTasks, listSessionTasks, removePath, runSystemCleanup, collectProcessDebugReport, signalOrphanedAgentTrees, listDockerIsolationContainers, removeDockerContainer } from './cleanup.os.lib.mjs';
41
41
  import { formatProcessDebugReport } from './process-debug.lib.mjs';
42
+ import { classifyAgentSnapshotStores, describeAgentSnapshotReason, getAgentDataHome } from './agent-snapshot-store.lib.mjs';
42
43
  import { setupStdioLogInterceptor } from './lib.mjs';
43
44
  import { sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
44
45
 
@@ -138,6 +139,11 @@ System / Ubuntu cleanup (opt-in):
138
139
  --system Shorthand for --apt --journal --npm
139
140
  --sudo Prefix package-manager commands with sudo
140
141
 
142
+ Agent state cleanup (issue #2186):
143
+ --no-agent-snapshots Do not reclaim orphaned @link-assistant/agent
144
+ snapshot stores under
145
+ $XDG_DATA_HOME/link-assistant-agent/snapshot/
146
+
141
147
  Docker isolation cleanup:
142
148
  --docker-isolation[=<mode>] Clean task containers named by session UUID
143
149
  [default: ${DEFAULT_DOCKER_ISOLATION_CLEANUP_MODE}]
@@ -173,6 +179,7 @@ const options = {
173
179
  journal: hasFlag('--journal', '--system'),
174
180
  docker: hasFlag('--docker'),
175
181
  dockerIsolationMode: parseDockerIsolationMode(),
182
+ agentSnapshots: !hasFlag('--no-agent-snapshots'),
176
183
  npm: hasFlag('--npm', '--system'),
177
184
  sudo: hasFlag('--sudo'),
178
185
  };
@@ -371,18 +378,48 @@ async function main() {
371
378
  }
372
379
  }
373
380
 
374
- await log(`\n📊 Summary: keep ${totals.keepCount} (${formatBytes(totals.keepBytes)}), remove ${totals.removeCount} (${formatBytes(totals.removeBytes)}), docker keep ${dockerIsolationPlan.keep.length}, docker remove ${dockerIsolationPlan.remove.length}`);
381
+ // Issue #2186: agent's snapshot stores live in the home directory, outside the
382
+ // tmp root everything above scans, and each one is a full copy of a
383
+ // repository. A store is only listed for removal when the worktree its project
384
+ // record points at is gone, so this can never disturb a live checkout.
385
+ let agentSnapshotPlan = { orphaned: [], keep: [] };
386
+ if (!options.agentSnapshots) {
387
+ await log('\n🗄️ Agent snapshot stores: disabled (--no-agent-snapshots)');
388
+ } else {
389
+ const agentDataHome = getAgentDataHome();
390
+ agentSnapshotPlan = await classifyAgentSnapshotStores({ dataHome: agentDataHome });
391
+ for (const item of [...agentSnapshotPlan.keep, ...agentSnapshotPlan.orphaned]) item.size = getPathSize(item.path);
392
+ const orphanBytes = agentSnapshotPlan.orphaned.reduce((total, item) => total + (item.size || 0), 0);
393
+ await log(`\n🗄️ Agent snapshot stores (${path.join(agentDataHome, 'snapshot')}):`);
394
+ if (agentSnapshotPlan.keep.length === 0 && agentSnapshotPlan.orphaned.length === 0) {
395
+ await log(' (none)');
396
+ } else {
397
+ await log(' KEPT:');
398
+ if (agentSnapshotPlan.keep.length === 0) await log(' (none)');
399
+ for (const item of agentSnapshotPlan.keep.sort((a, b) => (b.size || 0) - (a.size || 0))) {
400
+ await log(` ${formatBytes(item.size).padStart(7)} ${item.path} — ${describeAgentSnapshotReason(item.reason)}${item.worktree ? ` (${item.worktree})` : ''}`);
401
+ }
402
+ await log(` ${options.dryRun ? 'WOULD REMOVE' : 'TO REMOVE'} (${formatBytes(orphanBytes)}):`);
403
+ if (agentSnapshotPlan.orphaned.length === 0) await log(' (none)');
404
+ for (const item of agentSnapshotPlan.orphaned.sort((a, b) => (b.size || 0) - (a.size || 0))) {
405
+ await log(` ${formatBytes(item.size).padStart(7)} ${item.path} — ${describeAgentSnapshotReason(item.reason)}${item.worktree ? ` (${item.worktree})` : ''}`);
406
+ }
407
+ }
408
+ }
409
+
410
+ await log(`\n📊 Summary: keep ${totals.keepCount} (${formatBytes(totals.keepBytes)}), remove ${totals.removeCount} (${formatBytes(totals.removeBytes)}), docker keep ${dockerIsolationPlan.keep.length}, docker remove ${dockerIsolationPlan.remove.length}, agent snapshots remove ${agentSnapshotPlan.orphaned.length}`);
375
411
 
376
412
  // 7. Execute deletion (unless dry-run).
377
413
  const hasTempRemovals = classified.remove.length > 0;
378
414
  const hasDockerRemovals = dockerIsolationPlan.remove.length > 0;
415
+ const hasAgentSnapshotRemovals = agentSnapshotPlan.orphaned.length > 0;
379
416
  if (options.dryRun) {
380
417
  await log('\n✅ Dry run complete. Re-run without --dry-run to delete.');
381
- } else if (!hasTempRemovals && !hasDockerRemovals) {
418
+ } else if (!hasTempRemovals && !hasDockerRemovals && !hasAgentSnapshotRemovals) {
382
419
  await log('\n✅ Nothing to delete.');
383
420
  } else {
384
421
  if (!options.force) {
385
- console.log(`\n⚠️ This will permanently delete ${classified.remove.length} entries (${formatBytes(totals.removeBytes)}) and remove ${dockerIsolationPlan.remove.length} Docker isolation containers.`);
422
+ console.log(`\n⚠️ This will permanently delete ${classified.remove.length} entries (${formatBytes(totals.removeBytes)}), ${agentSnapshotPlan.orphaned.length} orphaned agent snapshot stores and remove ${dockerIsolationPlan.remove.length} Docker isolation containers.`);
386
423
  console.log('Type "yes" to confirm, or Ctrl+C to cancel:');
387
424
  let answer;
388
425
  try {
@@ -414,6 +451,23 @@ async function main() {
414
451
  await log(`\n✅ Deleted ${deleted} entries${failed ? `, ${failed} failed` : ''}.`);
415
452
  }
416
453
 
454
+ if (hasAgentSnapshotRemovals) {
455
+ await log('\n🗄️ Removing orphaned agent snapshot stores...');
456
+ let deleted = 0;
457
+ let failed = 0;
458
+ for (const item of agentSnapshotPlan.orphaned) {
459
+ const ok = removePath(item.path);
460
+ if (ok) {
461
+ deleted++;
462
+ await vlog(` removed ${item.path}`);
463
+ } else {
464
+ failed++;
465
+ await log(` ⚠️ failed to remove ${item.path}`, { level: 'warn' });
466
+ }
467
+ }
468
+ await log(`\n✅ Deleted ${deleted} orphaned agent snapshot stores${failed ? `, ${failed} failed` : ''}.`);
469
+ }
470
+
417
471
  if (hasDockerRemovals) {
418
472
  await log('\n🐳 Removing Docker isolation containers...');
419
473
  let removed = 0;
@@ -27,6 +27,8 @@ import path from 'node:path';
27
27
  import { execFile } from 'node:child_process';
28
28
  import { promisify } from 'node:util';
29
29
 
30
+ import { DEFAULT_AGENT_SNAPSHOT_MIN_IDLE_MS, getAgentDataHome, reclaimAgentSnapshotStores } from './agent-snapshot-store.lib.mjs';
31
+
30
32
  const execFileAsync = promisify(execFile);
31
33
 
32
34
  /**
@@ -224,8 +226,12 @@ export const reclaimSolverWorkspaces = async ({ requiredMB = 0, tmpRoot = DEFAUL
224
226
  *
225
227
  * An unreadable `df` never blocks work: the guard is an optimisation over solve's own pre-flight
226
228
  * check, not a replacement for it.
229
+ *
230
+ * Issue #2186 added a second source of reclaimable space: orphaned
231
+ * `@link-assistant/agent` snapshot stores in the home directory, which no `/tmp`-scoped check
232
+ * could see. Pass `agentDataHome: null` to opt out.
227
233
  */
228
- export const ensureDiskSpaceForWorker = async ({ requiredMB = 10240, tmpRoot = DEFAULT_TMP_ROOT, protectedPaths = new Set(), minIdleMs = DEFAULT_MIN_IDLE_MS, maxWaitMs = 0, pollIntervalMs = 30000, log = async () => {}, now = Date.now, sleep = defaultSleep, getFreeMB = getFreeDiskSpaceMB, fileSystem = fsPromises, procRoot = '/proc', remove = defaultRemove } = {}) => {
234
+ export const ensureDiskSpaceForWorker = async ({ requiredMB = 10240, tmpRoot = DEFAULT_TMP_ROOT, protectedPaths = new Set(), minIdleMs = DEFAULT_MIN_IDLE_MS, maxWaitMs = 0, pollIntervalMs = 30000, log = async () => {}, now = Date.now, sleep = defaultSleep, getFreeMB = getFreeDiskSpaceMB, fileSystem = fsPromises, procRoot = '/proc', remove = defaultRemove, agentDataHome = getAgentDataHome(), agentSnapshotMinIdleMs = DEFAULT_AGENT_SNAPSHOT_MIN_IDLE_MS } = {}) => {
229
235
  const startedAt = now();
230
236
  const reclaimed = [];
231
237
  let freeMB = await getFreeMB(tmpRoot);
@@ -239,6 +245,20 @@ export const ensureDiskSpaceForWorker = async ({ requiredMB = 10240, tmpRoot = D
239
245
  }
240
246
  await log(` 💾 Low disk space: ${freeMB}MB free, ${requiredMB}MB required — reclaiming idle solver workspaces before starting work`, { level: 'warning' });
241
247
  for (;;) {
248
+ // Issue #2186: orphaned agent snapshot stores are pure garbage — their
249
+ // worktree is gone, so nothing can be restored from them — while a solver
250
+ // workspace may still be wanted for debugging. Reclaim them first, and note
251
+ // that they live in the home directory, which every check here used to be
252
+ // blind to.
253
+ if (agentDataHome) {
254
+ const agentResult = await reclaimAgentSnapshotStores({ dataHome: agentDataHome, minIdleMs: agentSnapshotMinIdleMs, stopWhenFreeMB: requiredMB, getFreeMB: () => getFreeMB(tmpRoot), now, log, fileSystem, remove });
255
+ reclaimed.push(...agentResult.removed);
256
+ if (agentResult.freeMB !== null && agentResult.freeMB !== undefined) freeMB = agentResult.freeMB;
257
+ if (freeMB >= requiredMB) {
258
+ await log(` ✅ Disk space recovered: ${freeMB}MB free after reclaiming ${agentResult.removed.length} orphaned agent snapshot store(s)`);
259
+ return { ok: true, freeMB, reason: 'reclaimed', reclaimed, waitedMs: now() - startedAt };
260
+ }
261
+ }
242
262
  const result = await reclaimSolverWorkspaces({ requiredMB, tmpRoot, protectedPaths, minIdleMs, now, log, fileSystem, procRoot, getFreeMB, remove });
243
263
  reclaimed.push(...result.removed);
244
264
  if (result.freeMB !== null && result.freeMB !== undefined) freeMB = result.freeMB;
@@ -29,13 +29,17 @@ export const FORMAL_AI_MEMORY_CONTRACT_MINIMUM_VERSION = '0.336.0';
29
29
  * PR #2147 this is the *initial* pin only: once the container is running,
30
30
  * `src/formal-ai-updater.lib.mjs` replaces it with the newest published image
31
31
  * while no Formal AI task holds a lease. 0.339.0 restored `cargo install
32
- * formal-ai --locked` on stock Rust images (formal-ai#988) and 0.339.1 routes
33
- * command execution through the published command-stream component; the
34
- * memory-contract sources (`src/cli_memory.rs`, `src/server.rs`,
35
- * `src/shared_memory.rs`) are byte-identical to 0.337.0 and
36
- * `src/memory/upgrade.rs` only adds an explicit advisory-lock release.
32
+ * formal-ai --locked` on stock Rust images (formal-ai#988) and 0.339.1 routed
33
+ * command execution through the published command-stream component. 0.345.0 is
34
+ * the current release and is a safe bootstrap for the same reason 0.339.1 was:
35
+ * its Cargo.lock still carries no `openssl-sys`, so the builder stage keeps
36
+ * building on a stock `rust:slim` image, and the four memory-contract sources
37
+ * (`src/cli_memory.rs`, `src/server.rs`, `src/shared_memory.rs`,
38
+ * `src/memory/upgrade.rs`) are byte-identical to 0.339.1 — 0.340.0-0.345.0 only
39
+ * change reasoning data, benchmarks and unrelated handlers. Verified by
40
+ * diffing the published crates; see docs/case-studies/issue-2186.
37
41
  */
38
- export const FORMAL_AI_BOOTSTRAP_VERSION = '0.339.1';
42
+ export const FORMAL_AI_BOOTSTRAP_VERSION = '0.345.0';
39
43
 
40
44
  export const parseFormalAiVersion = stdout => {
41
45
  const line = String(stdout || '')
@@ -1,17 +1,6 @@
1
1
  // Lazy-load config only when needed to avoid loading use-m at module initialization
2
2
  // This prevents network fetches that can hang during --help or --version
3
- import { sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
4
-
5
- const sanitizeEventValue = (value, seen = new WeakSet()) => {
6
- if (typeof value === 'string') return sanitizeCredentialText(value);
7
- if (!value || typeof value !== 'object') return value;
8
- if (seen.has(value)) return value;
9
- seen.add(value);
10
- for (const [key, item] of Object.entries(value)) {
11
- value[key] = sanitizeEventValue(item, seen);
12
- }
13
- return value;
14
- };
3
+ import { sanitizeSentryLog, sanitizeSentryValue } from './instrument.sanitize.lib.mjs';
15
4
 
16
5
  // Check if Sentry should be disabled
17
6
  const shouldDisableSentry = () => {
@@ -78,7 +67,9 @@ if (!shouldDisableSentry()) {
78
67
  environment: process.env.NODE_ENV || 'production',
79
68
  release: `hive-mind@${process.env.npm_package_version || version.default}`,
80
69
 
81
- // Send structured logs to Sentry
70
+ // Send structured logs to Sentry. Stated explicitly even though Sentry
71
+ // 10.71 made it the default, so the setting stays a decision rather than
72
+ // whatever the SDK happens to default to next.
82
73
  enableLogs: true,
83
74
 
84
75
  // Tracing
@@ -98,7 +89,7 @@ if (!shouldDisableSentry()) {
98
89
 
99
90
  // Before send hook to filter out sensitive data
100
91
  beforeSend(event) {
101
- sanitizeEventValue(event);
92
+ sanitizeSentryValue(event);
102
93
 
103
94
  // Filter out sensitive environment variables
104
95
  if (event.contexts && event.contexts.runtime && event.contexts.runtime.env) {
@@ -123,6 +114,13 @@ if (!shouldDisableSentry()) {
123
114
  return event;
124
115
  },
125
116
 
117
+ // Structured logs never pass through beforeSend, so they get the same
118
+ // masking here — otherwise a token printed by `Sentry.logger.*` would
119
+ // leave the process verbatim.
120
+ beforeSendLog(log) {
121
+ return sanitizeSentryLog(log);
122
+ },
123
+
126
124
  // Integration specific options
127
125
  ignoreErrors: [
128
126
  // Ignore specific errors that are expected or not relevant
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Credential sanitization for everything Hive Mind hands to Sentry.
3
+ *
4
+ * `src/instrument.mjs` has always masked credentials in *events* (`beforeSend`).
5
+ * Structured logs are a second, separate pipeline: `enableLogs` sends
6
+ * `Sentry.logger.*` records straight to the transport, and `beforeSend` is never
7
+ * called for them. Sentry 10.71 made `enableLogs` the default, so any consumer
8
+ * that has not opted out now ships that pipeline whether it meant to or not —
9
+ * which is why the same masking is applied through `beforeSendLog` here.
10
+ *
11
+ * Both hooks share one walker so a token can never be masked in one surface and
12
+ * printed verbatim in the other.
13
+ *
14
+ * @see https://github.com/link-assistant/hive-mind/issues/2189
15
+ */
16
+
17
+ import { sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
18
+
19
+ /**
20
+ * Recursively mask credentials in a Sentry payload, in place.
21
+ *
22
+ * Mutates rather than clones on purpose: Sentry hands us the object it is about
23
+ * to serialize, and a copy would leave the original untouched. Cycles are
24
+ * tracked so a self-referencing payload cannot spin forever — the same class of
25
+ * unbounded work this issue is about.
26
+ *
27
+ * @param {*} value - Any part of a Sentry event or log record
28
+ * @param {WeakSet} [seen] - Cycle guard, supplied by the recursion
29
+ * @returns {*} The same value with every string masked
30
+ */
31
+ export const sanitizeSentryValue = (value, seen = new WeakSet()) => {
32
+ if (typeof value === 'string') return sanitizeCredentialText(value);
33
+ if (!value || typeof value !== 'object') return value;
34
+ if (seen.has(value)) return value;
35
+ seen.add(value);
36
+ for (const [key, item] of Object.entries(value)) {
37
+ value[key] = sanitizeSentryValue(item, seen);
38
+ }
39
+ return value;
40
+ };
41
+
42
+ /**
43
+ * `beforeSendLog` hook: mask credentials in a structured log record.
44
+ *
45
+ * Returns the record (never `null`) so sanitization only ever changes the
46
+ * content of a log, never whether it is delivered — dropping logs silently
47
+ * would recreate the blind spot described in Finding F5.
48
+ *
49
+ * @param {Object} log - The log record Sentry is about to send
50
+ * @returns {Object} The same record, masked
51
+ */
52
+ export const sanitizeSentryLog = log => sanitizeSentryValue(log);
@@ -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);