@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
package/src/agent.lib.mjs CHANGED
@@ -349,32 +349,14 @@ export const mapModelToId = model => {
349
349
  return agentModels[model] || model;
350
350
  };
351
351
 
352
- export const MIN_AGENT_LIVE_INPUT_VERSION = '0.24.1';
352
+ // The Agent CLI version floors live in their own module (issue #2198: this
353
+ // file crossed the 1350-line warning threshold). `validateAgentConnection`
354
+ // below reads them directly, and they are re-exported so importers of
355
+ // `agent.lib.mjs` -- tests/test-codex-support.mjs and
356
+ // tests/test-issue-2186-agent-snapshot-leak.mjs among them -- do not move.
357
+ import { MIN_AGENT_LIVE_INPUT_VERSION, MIN_AGENT_FORMAL_AI_VERSION, MIN_AGENT_SNAPSHOT_HYGIENE_VERSION, getAgentCliVersion } from './agent.version-gates.lib.mjs';
353
358
 
354
- export const getAgentCliVersion = versionOutput => {
355
- return semver.clean(versionOutput) || semver.coerce(versionOutput)?.version || null;
356
- };
357
-
358
- export const agentCliSupportsLiveInput = versionOutput => {
359
- const version = getAgentCliVersion(versionOutput);
360
- return !!version && semver.gte(version, MIN_AGENT_LIVE_INPUT_VERSION);
361
- };
362
-
363
- /**
364
- * Agent only fails closed on a `--model` argv it cannot parse from js-0.25.8
365
- * onwards (link-assistant/agent#293, fixed by PR #294): earlier releases logged
366
- * a CRITICAL record and then answered with their *default* model. Issue #2146
367
- * requires Formal AI to be the only model a task can reach, and a guard that
368
- * reads the CRITICAL record can only stop the run after Agent has already
369
- * decided, so a Formal AI task refuses to start below this release.
370
- */
371
- export const MIN_AGENT_FORMAL_AI_VERSION = '0.25.8';
372
-
373
- /** True when this Agent CLI aborts instead of silently picking another model. */
374
- export const agentCliFailsClosedOnModelMismatch = versionOutput => {
375
- const version = getAgentCliVersion(versionOutput);
376
- return !!version && semver.gte(version, MIN_AGENT_FORMAL_AI_VERSION);
377
- };
359
+ export { MIN_AGENT_LIVE_INPUT_VERSION, MIN_AGENT_FORMAL_AI_VERSION, MIN_AGENT_SNAPSHOT_HYGIENE_VERSION, getAgentCliVersion, agentCliSupportsLiveInput, agentCliFailsClosedOnModelMismatch, agentCliPrunesOrphanSnapshots } from './agent.version-gates.lib.mjs';
378
360
 
379
361
  // Function to validate Agent connection
380
362
  export const validateAgentConnection = async (model = defaultModels.agent, options = {}) => {
@@ -411,6 +393,24 @@ export const validateAgentConnection = async (model = defaultModels.agent, optio
411
393
  }
412
394
  }
413
395
 
396
+ if (!agentVersion || !semver.gte(agentVersion, MIN_AGENT_SNAPSHOT_HYGIENE_VERSION)) {
397
+ await log(`āŒ Hive Mind requires @link-assistant/agent >= ${MIN_AGENT_SNAPSHOT_HYGIENE_VERSION}`, { level: 'error' });
398
+ await log(' Older releases write a full, standalone copy of the repository into', { level: 'error' });
399
+ await log(' ~/.local/share/link-assistant-agent/snapshot/ per project and never reclaim it', { level: 'error' });
400
+ await log(' (link-assistant/agent#298): issue #2186 lost 31 GB to 115 orphaned stores in one task.', { level: 'error' });
401
+ if (agentVersion) {
402
+ await log(` Installed Agent CLI version: ${agentVersion}`, { level: 'error' });
403
+ } else {
404
+ await log(' Could not determine the installed Agent CLI version.', { level: 'error' });
405
+ }
406
+ await log(' Update with: bun install -g @link-assistant/agent@latest', { level: 'error' });
407
+ return false;
408
+ }
409
+
410
+ // The two capability gates below are subsumed by the floor above while it
411
+ // stays the highest of the three. They are kept because each states an
412
+ // independent contract with its own diagnosis, and any one of the floors
413
+ // can move on its own.
414
414
  if (requireLiveInput && (!agentVersion || !semver.gte(agentVersion, MIN_AGENT_LIVE_INPUT_VERSION))) {
415
415
  await log(`āŒ Agent live stream-json input requires @link-assistant/agent >= ${MIN_AGENT_LIVE_INPUT_VERSION}`, { level: 'error' });
416
416
  if (agentVersion) {
@@ -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
+ };
@@ -12,7 +12,7 @@
12
12
  * @see https://github.com/link-assistant/hive-mind/issues/1927
13
13
  */
14
14
 
15
- import { RESOURCE_PHASE_BOT_HEARTBEAT, captureResourceSnapshot, summarizeResourceSnapshot } from './solve.resource-diagnostics.lib.mjs';
15
+ import { RESOURCE_PHASE_BOT_HEARTBEAT, captureResourceSnapshot, formatHeapUsage, isHeapUnderPressure, summarizeResourceSnapshot } from './solve.resource-diagnostics.lib.mjs';
16
16
 
17
17
  const DEFAULT_HEARTBEAT_INTERVAL_MS = 60 * 1000;
18
18
 
@@ -51,6 +51,13 @@ export function createHeartbeat({ logger, getActiveSessionCount, intervalMs = DE
51
51
  uptimeSec: Math.floor(processImpl.uptime()),
52
52
  resources,
53
53
  });
54
+ // Issue #2189: the bot walked from 1.78 GB to 1.84 GB of RSS against its
55
+ // own ~2 GB heap cap while re-reporting one dead session, and nothing in
56
+ // the log said so until it died (#733). The heartbeat is the one place
57
+ // that samples the bot itself, so it is where the warning belongs.
58
+ if (isHeapUnderPressure(resources?.memory) && typeof logger.warn === 'function') {
59
+ logger.warn(`Bot V8 heap is under pressure: ${formatHeapUsage(resources.memory)} — the process will abort with "JavaScript heap out of memory" if it keeps growing`, { heap: resources.memory });
60
+ }
54
61
  } catch {
55
62
  /* heartbeat must never crash the bot */
56
63
  }
@@ -76,6 +83,52 @@ export function createHeartbeat({ logger, getActiveSessionCount, intervalMs = DE
76
83
  };
77
84
  }
78
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
+
79
132
  /**
80
133
  * Resume sessions left tracked by a previous run (requirements #2/#4).
81
134
  *
@@ -85,9 +138,14 @@ export function createHeartbeat({ logger, getActiveSessionCount, intervalMs = DE
85
138
  * `sessions_resumed` event either way and never throws: a resume failure must
86
139
  * not stop the bot from coming up.
87
140
  *
88
- * @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 }>}
89
146
  */
90
- 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 });
91
149
  try {
92
150
  const { resumed, skipped } = await resumeTrackedSessions({ botStartTime, verbose });
93
151
  if (resumed.length > 0) {
@@ -98,11 +156,11 @@ export async function resumeSessionsOnLaunch({ resumeTrackedSessions, botStartTi
98
156
  skipped: skipped.length,
99
157
  sessions: resumed.map(r => r.sessionName),
100
158
  });
101
- return { resumed, skipped };
159
+ return { resumed, skipped, reconciliation };
102
160
  } catch (error) {
103
161
  consoleImpl.error(`[telegram-bot] Failed to resume tracked sessions: ${error.message}`);
104
162
  logger.error('Failed to resume tracked sessions', { error: error.message });
105
- return { resumed: [], skipped: [], error };
163
+ return { resumed: [], skipped: [], reconciliation, error };
106
164
  }
107
165
  }
108
166
 
@@ -74,6 +74,58 @@ export const describeChildExit = ({ command, code = null, signal = null }) => {
74
74
  */
75
75
  export const isLikelyOutOfMemoryExit = ({ code = null, signal = null }) => signal === 'SIGABRT' || signal === 'SIGKILL' || (signal === null && code === 134);
76
76
 
77
+ /**
78
+ * Fatal lines a runtime prints when it exhausts its *own* heap.
79
+ *
80
+ * Issue #2189: a session died of `FATAL ERROR: Reached heap limit Allocation
81
+ * failed - JavaScript heap out of memory` and was reported to the user as a
82
+ * "forced kill … memory (10.3 GB of 11.7 GB RAM available)". Both statements
83
+ * were individually true: V8 stopped at its own ~2 GB old-space cap long before
84
+ * the machine or the container cgroup felt any pressure, so `docker inspect`
85
+ * said `OOMKilled=false` and `/sys/fs/cgroup/memory.events` said `oom_kill=0`.
86
+ * Nothing outside the process can observe a runtime self-abort — the only
87
+ * evidence is the text the runtime printed on its way out, which was sitting in
88
+ * the log the diagnostics were already reading.
89
+ *
90
+ * The patterns are deliberately specific (a bare "out of memory" also appears in
91
+ * Hive Mind's own diagnostic wording, which ends up in the same logs). Hive Mind
92
+ * spawns more than Node, so the other runtimes it drives are covered too.
93
+ */
94
+ export const FATAL_MEMORY_PATTERNS = [
95
+ { id: 'v8-heap-limit', runtime: 'Node.js/V8', pattern: /FATAL ERROR:[^\n]*Reached heap limit/ },
96
+ { id: 'v8-ineffective-mark-compacts', runtime: 'Node.js/V8', pattern: /FATAL ERROR:[^\n]*Ineffective mark-compacts near heap limit/ },
97
+ { id: 'v8-heap-out-of-memory', runtime: 'Node.js/V8', pattern: /JavaScript heap out of memory/ },
98
+ { id: 'v8-last-few-gcs', runtime: 'Node.js/V8', pattern: /<--- Last few GCs --->/ },
99
+ { id: 'v8-array-buffer-allocation', runtime: 'Node.js/V8', pattern: /Array buffer allocation failed/ },
100
+ { id: 'rust-allocation-failed', runtime: 'Rust', pattern: /memory allocation of \d+ bytes failed/ },
101
+ { id: 'go-runtime-out-of-memory', runtime: 'Go', pattern: /fatal error: runtime: out of memory/ },
102
+ { id: 'cpp-bad-alloc', runtime: 'C/C++', pattern: /std::bad_alloc/ },
103
+ ];
104
+
105
+ /**
106
+ * Find the first runtime self-abort marker in a piece of log text.
107
+ *
108
+ * Callers must only treat a hit as a cause when the process actually ended
109
+ * abnormally — the marker upgrades an existing kill to "out of memory", it never
110
+ * invents one, so an unrelated log that merely quotes the string cannot turn a
111
+ * healthy run into a reported crash.
112
+ *
113
+ * @param {string|null} text - Log text (a tail is enough; the marker is printed last)
114
+ * @returns {{id: string, runtime: string, line: string}|null}
115
+ */
116
+ export const findFatalMemoryMarker = text => {
117
+ if (!text || typeof text !== 'string') return null;
118
+ for (const { id, runtime, pattern } of FATAL_MEMORY_PATTERNS) {
119
+ const match = pattern.exec(text);
120
+ if (!match) continue;
121
+ const lineStart = text.lastIndexOf('\n', match.index) + 1;
122
+ const lineEndIndex = text.indexOf('\n', match.index);
123
+ const line = text.slice(lineStart, lineEndIndex < 0 ? undefined : lineEndIndex).trim();
124
+ return { id, runtime, line: line.length > 300 ? `${line.slice(0, 300)}…` : line };
125
+ }
126
+ return null;
127
+ };
128
+
77
129
  /**
78
130
  * Wire `close`/`error` handlers that never lose a signal.
79
131
  *
@@ -104,4 +156,4 @@ export const attachChildExitHandlers = ({ child, command, label, errorLabel = la
104
156
  });
105
157
  };
106
158
 
107
- export default { describeChildExit, isLikelyOutOfMemoryExit, attachChildExitHandlers };
159
+ export default { describeChildExit, isLikelyOutOfMemoryExit, findFatalMemoryMarker, attachChildExitHandlers };
@@ -22,6 +22,7 @@ import Decimal from 'decimal.js-light';
22
22
  import { accumulateModelUsage, createEmptySubSessionUsage, getRawRequestInputTokens, mergeResultModelUsage } from './claude.budget-stats.lib.mjs';
23
23
  import { calculateModelCost } from './claude.cost.lib.mjs';
24
24
  import { fetchModelInfo } from './model-info.lib.mjs';
25
+ import { forEachLogLine } from './log-bounded-read.lib.mjs';
25
26
 
26
27
  export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsage = null, options = {}) => {
27
28
  const homeDir = options.homeDir || os.homedir();
@@ -43,10 +44,12 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
43
44
  let currentSubSession = createEmptySubSessionUsage();
44
45
  const compactifications = [];
45
46
  try {
46
- const fileContent = await fs.readFile(sessionFile, 'utf8');
47
- const lines = fileContent.trim().split('\n');
48
- for (const line of lines) {
49
- if (!line.trim()) continue;
47
+ // Issue #2189: read the transcript one record at a time. A long Claude
48
+ // session produces a JSONL of unbounded size, and `readFile(...).split('\n')`
49
+ // held the whole file *and* the array of its lines before the first entry
50
+ // was priced — a full-file allocation just to sum token counters.
51
+ await forEachLogLine(sessionFile, line => {
52
+ if (!line.trim()) return;
50
53
  try {
51
54
  const entry = JSON.parse(line);
52
55
  if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
@@ -59,7 +62,7 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
59
62
  trigger: entry.compactMetadata?.trigger || 'unknown',
60
63
  });
61
64
  currentSubSession = createEmptySubSessionUsage();
62
- continue;
65
+ return;
63
66
  }
64
67
  if (entry.message && entry.message.usage && entry.message.model) {
65
68
  // Issue #1501: Skip duplicate JSONL entries (same message ID = same API response)
@@ -67,7 +70,7 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
67
70
  if (msgId) {
68
71
  if (seenMessageIds.has(msgId)) {
69
72
  duplicateCount++;
70
- continue;
73
+ return;
71
74
  }
72
75
  seenMessageIds.add(msgId);
73
76
  }
@@ -100,9 +103,8 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
100
103
  }
101
104
  } catch {
102
105
  // Skip lines that aren't valid JSON
103
- continue;
104
106
  }
105
- }
107
+ });
106
108
  if (currentSubSession.messageCount > 0) {
107
109
  subSessions.push(currentSubSession);
108
110
  }
@@ -25,6 +25,8 @@ import { promises as fs } from 'fs';
25
25
  import os from 'os';
26
26
  import path from 'path';
27
27
 
28
+ import { fileEndsWithNewline, forEachLogLine } from './log-bounded-read.lib.mjs';
29
+
28
30
  /**
29
31
  * Resolve the on-disk session transcript path for a Claude Code session. Claude Code stores each
30
32
  * session as `~/.claude/projects/<cwd-with-slashes-as-dashes>/<sessionId>.jsonl` (mirrors the
@@ -52,6 +54,36 @@ const isCorruptedThinkingBlock = block => {
52
54
  return false;
53
55
  };
54
56
 
57
+ /**
58
+ * Repair one transcript record.
59
+ *
60
+ * Returns the line to write back (unchanged unless a corrupted block was
61
+ * dropped), how many corrupted blocks it dropped, and whether the line counted
62
+ * as a scanned message line. Keeping the decision in one pure function lets the
63
+ * repair stream the file twice — count, then rewrite — with identical results.
64
+ *
65
+ * @param {string} line - One raw JSONL record
66
+ * @returns {{text: string, removed: number, scanned: number}}
67
+ */
68
+ const repairTranscriptLine = line => {
69
+ if (!line.trim()) return { text: line, removed: 0, scanned: 0 };
70
+ let entry;
71
+ try {
72
+ entry = JSON.parse(line);
73
+ } catch {
74
+ return { text: line, removed: 0, scanned: 1 }; // preserve anything we can't parse verbatim
75
+ }
76
+ const content = entry?.message?.content;
77
+ if (!Array.isArray(content)) return { text: line, removed: 0, scanned: 1 };
78
+ const corrupted = content.filter(isCorruptedThinkingBlock).length;
79
+ if (corrupted === 0) return { text: line, removed: 0, scanned: 1 };
80
+ const cleaned = content.filter(b => !isCorruptedThinkingBlock(b));
81
+ // Never leave an assistant message with an empty content array (invalid for the API).
82
+ if (cleaned.length === 0) return { text: line, removed: 0, scanned: 1 };
83
+ entry.message.content = cleaned;
84
+ return { text: JSON.stringify(entry), removed: corrupted, scanned: 1 };
85
+ };
86
+
55
87
  /**
56
88
  * Strip corrupted (empty-text) thinking blocks from a Claude Code session transcript so the session
57
89
  * can be resumed. Conservative and side-effect-safe:
@@ -75,48 +107,27 @@ export const repairCorruptedThinkingBlocks = async ({ tempDir, sessionId, homeDi
75
107
  }
76
108
  const sessionFile = resolveSessionTranscriptPath(tempDir, sessionId, homeDir);
77
109
  result.sessionFile = sessionFile;
78
- let fileContent;
110
+ let sessionStat;
79
111
  try {
80
- fileContent = await fs.readFile(sessionFile, 'utf8');
112
+ sessionStat = await fs.stat(sessionFile);
81
113
  } catch {
82
114
  // No transcript on disk (e.g. fresh run never persisted, or path mismatch) — nothing to repair.
83
115
  return { ...result, reason: 'session transcript not found' };
84
116
  }
85
117
 
86
118
  try {
87
- const lines = fileContent.split('\n');
88
- const out = [];
119
+ // Issue #2189: a session transcript grows with the session — the captured
120
+ // incident's was 134 MB — so this is done in two streaming passes instead of
121
+ // holding the file, its array of lines and the rebuilt output in the heap at
122
+ // once. Pass 1 only counts: a transcript with nothing to repair (the common
123
+ // case) is never rewritten and never copied.
89
124
  let removedBlocks = 0;
90
125
  let scannedLines = 0;
91
- for (const line of lines) {
92
- if (!line.trim()) {
93
- out.push(line);
94
- continue;
95
- }
96
- scannedLines++;
97
- let entry;
98
- try {
99
- entry = JSON.parse(line);
100
- } catch {
101
- out.push(line); // preserve anything we can't parse verbatim
102
- continue;
103
- }
104
- const content = entry?.message?.content;
105
- if (Array.isArray(content)) {
106
- const corrupted = content.filter(isCorruptedThinkingBlock).length;
107
- if (corrupted > 0) {
108
- const cleaned = content.filter(b => !isCorruptedThinkingBlock(b));
109
- // Never leave an assistant message with an empty content array (invalid for the API).
110
- if (cleaned.length > 0) {
111
- entry.message.content = cleaned;
112
- removedBlocks += corrupted;
113
- out.push(JSON.stringify(entry));
114
- continue;
115
- }
116
- }
117
- }
118
- out.push(line);
119
- }
126
+ await forEachLogLine(sessionFile, line => {
127
+ const repaired = repairTranscriptLine(line);
128
+ scannedLines += repaired.scanned;
129
+ removedBlocks += repaired.removed;
130
+ });
120
131
 
121
132
  result.scannedLines = scannedLines;
122
133
  if (removedBlocks === 0) {
@@ -135,7 +146,27 @@ export const repairCorruptedThinkingBlocks = async ({ tempDir, sessionId, homeDi
135
146
  }
136
147
  }
137
148
 
138
- await fs.writeFile(sessionFile, out.join('\n'), 'utf8');
149
+ // Pass 2: rewrite through a sibling temp file and rename over the original,
150
+ // so an interrupted repair can never leave a half-written transcript (which
151
+ // would be worse than the corruption being repaired).
152
+ const keepTrailingNewline = await fileEndsWithNewline(sessionFile);
153
+ const tempFile = `${sessionFile}.repair-${process.pid}`;
154
+ await fs.rm(tempFile, { force: true });
155
+ const handle = await fs.open(tempFile, 'wx', sessionStat.mode & 0o777);
156
+ try {
157
+ let pendingSeparator = '';
158
+ await forEachLogLine(sessionFile, line => {
159
+ const repaired = repairTranscriptLine(line);
160
+ return handle.write(`${pendingSeparator}${repaired.text}`, null, 'utf8').then(() => {
161
+ pendingSeparator = '\n';
162
+ });
163
+ });
164
+ if (keepTrailingNewline) await handle.write('\n', null, 'utf8');
165
+ } finally {
166
+ await handle.close();
167
+ }
168
+ await fs.rename(tempFile, sessionFile);
169
+
139
170
  result.repaired = true;
140
171
  result.removedBlocks = removedBlocks;
141
172
  await log(`🩹 Repaired session transcript: stripped ${removedBlocks} corrupted thinking block(s) from ${scannedLines} message line(s) (Issue #1834). Backup: ${backupFile}`, { verbose: true });
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;
package/src/codex.lib.mjs CHANGED
@@ -49,10 +49,15 @@ import Decimal from 'decimal.js-light';
49
49
  import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
50
50
  import { CODEX_CACHE_READ_USAGE_PATHS, CODEX_CACHE_WRITE_USAGE_PATHS, CODEX_MODEL_DIAGNOSTIC_PATHS, CODEX_REASONING_USAGE_PATHS, CODEX_USAGE_FIELD_NAMES, createCodexTokenFieldAvailability, getFirstObservedNumber, hasAnyObservedPath, hasOwnPath } from './codex.usage-fields.lib.mjs';
51
51
  const CODEX_LONG_CONTEXT_PRICE_THRESHOLD = 272000;
52
+ // Issue #2189: ceiling for reading Codex's `--output-last-message` artifact. A
53
+ // final assistant message is a few hundred kilobytes at most; anything larger is
54
+ // a malfunction and must not be turned into an unbounded string.
55
+ const CODEX_LAST_MESSAGE_MAX_BYTES = 1024 * 1024;
52
56
  const getCodexExecEnv = (verbose = false) => (verbose ? { ...process.env, RUST_LOG: 'debug' } : { ...process.env });
53
57
  // Issue #2175: diagnostic-line parsing lives in its own module to keep this file
54
58
  // under the 1350-line warning threshold.
55
59
  import { parseCodexDiagnosticLine, rebuildCodexSubSessionsFromCompactifications } from './codex.diagnostics.lib.mjs';
60
+ import { readLogHeadText } from './log-bounded-read.lib.mjs'; // Issue #2189
56
61
  export const createCodexTokenUsage = requestedModelId => ({
57
62
  inputTokens: 0,
58
63
  outputTokens: 0,
@@ -891,7 +896,12 @@ export const executeCodexCommand = async params => {
891
896
  let lastMessageFromFile = null;
892
897
  let lastMessageReadError = null;
893
898
  try {
894
- lastMessageFromFile = (await fs.readFile(lastMessageFile, 'utf8')).trim();
899
+ // Issue #2189: this file holds Codex's final assistant message, but its
900
+ // size is decided by the tool, not by us. Size it first so a runaway or
901
+ // corrupted artifact cannot become an unbounded string in a process that
902
+ // has just finished a long run.
903
+ const { size } = await fs.stat(lastMessageFile);
904
+ lastMessageFromFile = size > CODEX_LAST_MESSAGE_MAX_BYTES ? `${(await readLogHeadText(lastMessageFile, { maxBytes: CODEX_LAST_MESSAGE_MAX_BYTES })).trim()}\n…[last message truncated: ${size} bytes on disk, see ${lastMessageFile}]` : (await fs.readFile(lastMessageFile, 'utf8')).trim();
895
905
  } catch (readError) {
896
906
  lastMessageReadError = readError;
897
907
  }