mixdog 0.9.87 → 0.9.89

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 (167) hide show
  1. package/package.json +13 -7
  2. package/scripts/tool-failures.mjs +60 -24
  3. package/src/agents/heavy-worker/AGENT.md +3 -4
  4. package/src/agents/worker/AGENT.md +1 -2
  5. package/src/cli.mjs +8 -0
  6. package/src/defaults/cycle3-review-prompt.md +4 -4
  7. package/src/rules/agent/00-core.md +3 -0
  8. package/src/rules/agent/42-cycle3-agent.md +5 -6
  9. package/src/rules/lead/01-general.md +2 -0
  10. package/src/rules/shared/01-tool.md +29 -35
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
  12. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
  17. package/src/runtime/agent/orchestrator/config.mjs +29 -11
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
  21. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
  25. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
  26. package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  30. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
  35. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
  42. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
  43. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
  44. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  46. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  54. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  55. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  56. package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
  57. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  58. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  59. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  60. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  61. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  62. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  63. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  64. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  65. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  66. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  67. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  68. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  69. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  70. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  71. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  72. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  73. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  74. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  77. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  85. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  86. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  87. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  88. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
  89. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  90. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
  91. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
  92. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
  93. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  94. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
  95. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  96. package/src/runtime/media/lanes.mjs +2 -2
  97. package/src/runtime/media/renditions.mjs +101 -17
  98. package/src/runtime/media/renditions.test.mjs +54 -0
  99. package/src/runtime/media/store.mjs +111 -43
  100. package/src/runtime/media/store.test.mjs +53 -11
  101. package/src/runtime/memory/index.mjs +26 -50
  102. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  103. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  104. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  105. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  106. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  107. package/src/runtime/memory/lib/http-router.mjs +4 -0
  108. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  109. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
  110. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  111. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  112. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  113. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  114. package/src/runtime/memory/tool-defs.mjs +3 -3
  115. package/src/runtime/shared/atomic-file.mjs +5 -1
  116. package/src/runtime/shared/child-guardian.mjs +73 -2
  117. package/src/runtime/shared/provider-api-key.mjs +22 -5
  118. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  119. package/src/session-runtime/config-helpers.mjs +6 -5
  120. package/src/session-runtime/lifecycle-api.mjs +16 -4
  121. package/src/session-runtime/media-api.mjs +31 -16
  122. package/src/session-runtime/prewarm.mjs +2 -24
  123. package/src/session-runtime/runtime-core.mjs +33 -15
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +0 -6
  126. package/src/session-runtime/session-title.mjs +228 -0
  127. package/src/session-runtime/session-turn-api.mjs +11 -7
  128. package/src/session-runtime/workflow-agents-api.mjs +16 -0
  129. package/src/session-runtime/workflow.mjs +7 -5
  130. package/src/standalone/agent-tool/job-views.mjs +90 -36
  131. package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
  132. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  133. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  134. package/src/standalone/agent-tool.mjs +17 -17
  135. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  136. package/src/standalone/channel-daemon.mjs +8 -0
  137. package/src/standalone/explore-tool.mjs +1 -1
  138. package/src/standalone/memory-runtime-proxy.mjs +4 -7
  139. package/src/standalone/projects.mjs +4 -1
  140. package/src/standalone/usage-dashboard.mjs +25 -3
  141. package/src/tui/App.jsx +5 -3
  142. package/src/tui/app/resume-picker.mjs +7 -2
  143. package/src/tui/app/route-pickers.mjs +11 -117
  144. package/src/tui/app/slash-dispatch.mjs +13 -3
  145. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  146. package/src/tui/app/transcript-window.mjs +17 -93
  147. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  148. package/src/tui/app/use-transcript-window.mjs +64 -114
  149. package/src/tui/components/Markdown.jsx +19 -2
  150. package/src/tui/components/Message.jsx +8 -3
  151. package/src/tui/components/ToolExecution.jsx +7 -3
  152. package/src/tui/dist/index.mjs +243 -223
  153. package/src/tui/engine/live-share.mjs +15 -5
  154. package/src/tui/engine/render-timing.mjs +2 -2
  155. package/src/tui/engine/session-api-ext.mjs +74 -4
  156. package/src/tui/engine/session-flow.mjs +3 -1
  157. package/src/tui/engine.mjs +2 -2
  158. package/src/tui/hooks/useSharedTick.mjs +0 -2
  159. package/src/tui/index.jsx +5 -5
  160. package/src/ui/statusline-agents.mjs +44 -11
  161. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  162. package/src/workflows/default/WORKFLOW.md +3 -3
  163. package/src/workflows/solo/WORKFLOW.md +5 -3
  164. package/src/runtime/media/index.mjs +0 -18
  165. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  166. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  167. package/src/standalone/agent-shard/shard-pool.mjs +0 -443
@@ -4,7 +4,7 @@
4
4
  // in-flight controller, tears down runtime/bash/dedup/offload/pending state,
5
5
  // and defers the runtime-map clear. abortSessionTurn cancels the current turn
6
6
  // without tombstoning.
7
- import { loadSession, markSessionClosed, bumpSessionGeneration } from '../store.mjs';
7
+ import { loadSession, markSessionClosed, bumpSessionGeneration, getSessionLifecycleCommitError } from '../store.mjs';
8
8
  import { clearReadDedupSession } from '../read-dedup.mjs';
9
9
  import { clearOffloadSession } from '../tool-result-offload.mjs';
10
10
  import { SessionClosedError } from './session-errors.mjs';
@@ -70,9 +70,43 @@ export function closeSession(id, reason = 'manual', opts = {}) {
70
70
  // write could silently overwrite the session after the user resumes
71
71
  // it back (BL: burned-session late-save clobber).
72
72
  const newGen = tombstone ? markSessionClosed(id, reason) : bumpSessionGeneration(id, reason);
73
+ // Durable lifecycle barrier check. `null` is ambiguous — it covers both a
74
+ // legitimate veto (session gone, live owner, contended commit) and a
75
+ // FAILED durable write. Only the latter publishes a lifecycle commit
76
+ // error. When the barrier never landed there is no tombstone and no
77
+ // generation bump on disk, so nothing fences a late save: tearing down
78
+ // here would abort the provider and clear the crash checkpoint while the
79
+ // session remains open and resumable on disk — the exact way a failed
80
+ // close silently destroys a turn. Leave every runtime structure intact,
81
+ // surface the cause, and report failure to the caller.
82
+ // ANY nonnumeric result means the barrier is not on disk — a write
83
+ // failure, a liveness/ownership veto, a contended commit lock, or a
84
+ // missing record. The detail map only explains WHY when the cause was a
85
+ // failed write; its absence is not evidence of success. Treating a
86
+ // detail-less null as a close is what let a vetoed/failed close abort the
87
+ // provider and drop the crash checkpoint while the session stayed open and
88
+ // resumable on disk. Leave every runtime structure intact and report
89
+ // failure in all of them.
90
+ if (typeof newGen !== 'number') {
91
+ const commitError = getSessionLifecycleCommitError(id);
92
+ const detail = commitError
93
+ ? `${commitError.message}${commitError.code ? ` (${commitError.code})` : ''}`
94
+ : 'no durable barrier written (veto, contended commit, or missing record)';
95
+ if (entry) {
96
+ entry.lastError = `session close failed: ${detail}`;
97
+ entry.closeBarrierError = commitError || { message: detail, code: null, reason, at: Date.now() };
98
+ }
99
+ try {
100
+ process.stderr.write(
101
+ `[agent-close] session=${id} reason=${reason} tombstone=${tombstone} `
102
+ + `FAILED durable lifecycle barrier: ${detail}\n`,
103
+ );
104
+ } catch { /* best-effort */ }
105
+ return false;
106
+ }
73
107
  // prepareCloseSnapshot was folded into the synchronous generation write
74
108
  // above. Only now is it safe to remove the crash fallback.
75
- if (typeof newGen === 'number') clearTurnCheckpoint(id);
109
+ clearTurnCheckpoint(id);
76
110
  // 2. Mark runtime as closed so post-await validation in askSession fires.
77
111
  if (entry) {
78
112
  entry.closed = true;
@@ -134,3 +168,67 @@ export function abortSessionTurn(id, reason = 'turn-abort') {
134
168
  try { globalThis.__mixdogCloseProviderConnectionsForSession?.(id, `turn-abort:${reason}`); } catch { /* ignore */ }
135
169
  return true;
136
170
  }
171
+
172
+ // Stages that still own live provider/tool work. An entry in one of them is
173
+ // never unloaded — the caller's "this agent finished" belief lost a race.
174
+ const _UNLOAD_BLOCKED_STAGES = new Set([
175
+ 'connecting', 'requesting', 'streaming', 'tool_running', 'cancelling',
176
+ ]);
177
+
178
+ /**
179
+ * Runtime-only unload for a session whose work is DONE.
180
+ *
181
+ * Releases the heavy process-local runtime a finished agent still pins —
182
+ * persistent bash shells (real child processes), pooled provider connections,
183
+ * the read-dedup cache, and the liveness/metric runtime entry — WITHOUT
184
+ * touching lifecycle state. Deliberately NOT a close:
185
+ * - no markSessionClosed() tombstone and no bumpSessionGeneration(): the
186
+ * session file stays open and resumable, so a same-tag follow-up
187
+ * (`agent type=send`) resumes the SAME session with its full transcript
188
+ * instead of respawning a fresh one through tombstone absorption;
189
+ * - no controller abort: an unload never cancels work, it only reclaims
190
+ * what a settled turn left behind;
191
+ * - offload sidecars, the turn checkpoint and persisted pending messages
192
+ * are left intact — they are the follow-up turn's context, not runtime.
193
+ * Terminal ownership stays with the caller's own reap path (agent-tool
194
+ * scheduleReap → closeSession('terminal-reap')).
195
+ *
196
+ * Returns false (changing nothing) when the session is still in flight.
197
+ */
198
+ export function unloadSessionRuntime(id, reason = 'runtime-unload') {
199
+ if (!id) return false;
200
+ const entry = _getRuntimeEntry(id);
201
+ // Liveness veto: an unaborted controller or a non-settled stage means the
202
+ // session still owns provider/tool work. Unloading under it would kill the
203
+ // shells and connections of a running turn.
204
+ //
205
+ // `closed === true` is NOT an exemption. closeSession() flips closed and
206
+ // sets stage='cancelling' while the provider call is still unwinding (it
207
+ // defers _clearSessionRuntime to the next tick precisely because the turn
208
+ // has not settled yet), and a close whose durable barrier failed leaves an
209
+ // unaborted controller behind. Both are live work: veto and touch nothing.
210
+ if (entry) {
211
+ if (entry.controller && !entry.controller.signal?.aborted) return false;
212
+ if (_UNLOAD_BLOCKED_STAGES.has(entry.stage)) return false;
213
+ }
214
+ _stopToolActivityHeartbeat(id);
215
+ // Prefer the in-memory runtime session: shells opened in the last turn may
216
+ // not be on disk yet (same BL-bash-disk-sync reason as closeSession).
217
+ const persisted = entry?.session || loadSession(id);
218
+ const bashSessionId = persisted?.implicitBashSessionId || null;
219
+ const allBashIds = Array.isArray(persisted?.allBashSessionIds)
220
+ ? persisted.allBashSessionIds.filter(Boolean)
221
+ : (bashSessionId ? [bashSessionId] : []);
222
+ if (bashSessionId && !allBashIds.includes(bashSessionId)) allBashIds.push(bashSessionId);
223
+ for (const bsid of allBashIds) {
224
+ try { _closeBashSessionLazy(bsid, `runtime-unload:${id}`); } catch { /* ignore */ }
225
+ }
226
+ try { globalThis.__mixdogCloseProviderConnectionsForSession?.(id, `runtime-unload:${reason}`); } catch { /* ignore */ }
227
+ // Pure caches — a resumed turn rebuilds them from the transcript/disk.
228
+ try { clearReadDedupSession(id); } catch { /* ignore */ }
229
+ _clearSessionRuntime(id);
230
+ if (process.env.MIXDOG_DEBUG_SESSION_LOG) {
231
+ try { process.stderr.write(`[agent-unload] session=${id} reason=${reason} shells=${allBashIds.length}\n`); } catch { /* best-effort */ }
232
+ }
233
+ return true;
234
+ }
@@ -47,6 +47,34 @@ export function listSessions(opts = {}) {
47
47
  // (e.g. agent list includeClosed:true).
48
48
  return sessions.filter(s => !hiddenIds.has(s.id) && (includeClosed || s.closed !== true));
49
49
  }
50
+ export async function updateSessionGeneratedTitle(id, title, stage) {
51
+ const session = loadSession(id);
52
+ if (!session || session.closed === true) return false;
53
+ const normalized = String(title || '').replace(/\s+/g, ' ').trim().slice(0, 100);
54
+ const normalizedStage = stage === 'third' ? 'third' : stage === 'first' ? 'first' : '';
55
+ if (!normalized || !normalizedStage) return false;
56
+ if (session.titleLocked === true) return false;
57
+ if (session.generatedTitleStage === 'third') return false;
58
+ if (normalizedStage === 'first' && session.generatedTitleStage === 'first') return false;
59
+ if (session.title === normalized && session.generatedTitleStage === normalizedStage) return false;
60
+ session.title = normalized;
61
+ session.generatedTitleStage = normalizedStage;
62
+ session.titleUpdatedAt = Date.now();
63
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
64
+ return true;
65
+ }
66
+ export async function updateSessionManualTitle(id, title) {
67
+ const session = loadSession(id);
68
+ if (!session || session.closed === true) return false;
69
+ const normalized = String(title || '').replace(/\s+/g, ' ').trim().slice(0, 100);
70
+ if (!normalized) return false;
71
+ if (session.title === normalized && session.titleLocked === true) return false;
72
+ session.title = normalized;
73
+ session.titleLocked = true;
74
+ session.titleUpdatedAt = Date.now();
75
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
76
+ return true;
77
+ }
50
78
  // --- Clear messages (keep system prompt + provider/model/cwd) ---
51
79
  export async function clearSessionMessages(sessionId, options = {}) {
52
80
  const session = loadSession(sessionId);
@@ -538,16 +538,62 @@ function _rememberPreparedResume(sessionId, prepared) {
538
538
  }
539
539
  }
540
540
 
541
+ function _preparedResumeForSession(session, preset) {
542
+ const cached = _preparedResumes.get(session.id);
543
+ if (cached?.session === session && cached?.preset === preset) return cached;
544
+ const prepared = _prepareResumeTools(session, preset);
545
+ _rememberPreparedResume(session.id, prepared);
546
+ return prepared;
547
+ }
548
+
541
549
  // Desktop session-row hover/idle prefetch. loadSession validates the atomic
542
550
  // file signature before reusing its parsed object, so a later external write
543
551
  // naturally misses this preparation and rebuilds from the new session object.
544
552
  export function prefetchSession(sessionId, preset = 'full') {
545
553
  const session = loadSession(sessionId);
546
554
  if (!session || session.closed === true) return false;
547
- _rememberPreparedResume(sessionId, _prepareResumeTools(session, preset));
555
+ _preparedResumeForSession(session, preset);
548
556
  return true;
549
557
  }
550
558
 
559
+ // Read-only desktop-pane projection. It shares the exact prepared tool surface
560
+ // that resumeSession will consume, but clones the session instead of claiming
561
+ // ownership or persisting refreshed metadata. This lets every visible pane run
562
+ // the live context-pressure calculation before it is ever focused.
563
+ export function prepareSessionProjection(session, preset = 'full') {
564
+ if (!session?.id) return null;
565
+ const prepared = _preparedResumeForSession(session, preset);
566
+ let contextMeta = null;
567
+ try {
568
+ const provider = session.provider ? getProvider(session.provider) : null;
569
+ if (provider && session.model) {
570
+ contextMeta = resolveSessionContextMeta(provider, session.model, session);
571
+ }
572
+ } catch {
573
+ // A cold metadata/provider failure must not make the transcript vanish.
574
+ }
575
+ return {
576
+ ...session,
577
+ toolSpec: prepared.toolSpec,
578
+ tools: prepared.tools,
579
+ ...(contextMeta ? {
580
+ contextWindow: contextMeta.contextWindow,
581
+ rawContextWindow: contextMeta.rawContextWindow,
582
+ effectiveContextWindowPercent: contextMeta.effectiveContextWindowPercent,
583
+ autoCompactTokenLimit: contextMeta.autoCompactTokenLimit,
584
+ compactBoundaryTokens: contextMeta.compactBoundaryTokens,
585
+ compaction: {
586
+ ...(session.compaction || {}),
587
+ boundaryTokens: contextMeta.compactBoundaryTokens,
588
+ contextWindow: contextMeta.contextWindow,
589
+ rawContextWindow: contextMeta.rawContextWindow,
590
+ effectiveContextWindowPercent: contextMeta.effectiveContextWindowPercent,
591
+ autoCompactTokenLimit: contextMeta.autoCompactTokenLimit,
592
+ },
593
+ } : {}),
594
+ };
595
+ }
596
+
551
597
  // Owner-pid hint for liveness signals that carry NO pid of their own:
552
598
  // `session.lastHeartbeatAt` is persisted in the session file and therefore
553
599
  // survives its writer forever, and pre-pid `.hb` sidecars only hold a
@@ -620,6 +666,38 @@ export function isSessionOwnerGone(sessionId) {
620
666
  return !_isActivelyOwnedElsewhere(session, sessionId);
621
667
  }
622
668
 
669
+ function _recoverTurnCheckpointDurably(session, sessionId) {
670
+ const recovery = recoverTurnCheckpoint(session);
671
+ if (!recovery.changed) return recovery;
672
+ // Recovery is a durable reconnect boundary, not a renderer-only view.
673
+ // Persist before removing the checkpoint so a second crash leaves at
674
+ // least one complete copy of the interrupted turn.
675
+ saveSession(session, {
676
+ sync: true,
677
+ expectedGeneration: session.generation,
678
+ });
679
+ if (recovery.turnToken) {
680
+ clearTurnCheckpoint(sessionId, recovery.turnToken);
681
+ }
682
+ // A force-killed owner cannot clear its activity/presence sidecars. Once
683
+ // its checkpoint is committed as interrupted, neither sidecar may keep a
684
+ // restored pane busy or make a new prompt queue behind a dead process.
685
+ deleteSessionPresence(sessionId);
686
+ void deleteHeartbeat(sessionId);
687
+ return recovery;
688
+ }
689
+
690
+ // Reconnect-safe historical read used by desktop pane peeks. Unlike a normal
691
+ // read-only load, it resolves a dead owner's durable turn checkpoint before
692
+ // returning the transcript. A genuinely live foreign owner remains untouched.
693
+ export function recoverSessionAfterProcessRestart(sessionId) {
694
+ const session = loadSession(sessionId);
695
+ if (!session || session.closed === true) return session || null;
696
+ if (_isActivelyOwnedElsewhere(session, sessionId)) return session;
697
+ _recoverTurnCheckpointDurably(session, sessionId);
698
+ return session;
699
+ }
700
+
623
701
  export async function resumeSession(sessionId, preset, options = {}) {
624
702
  let session = loadSession(sessionId);
625
703
  if (!session)
@@ -658,19 +736,7 @@ export async function resumeSession(sessionId, preset, options = {}) {
658
736
  }
659
737
  return attached;
660
738
  }
661
- const crashRecovery = recoverTurnCheckpoint(session);
662
- if (crashRecovery.changed) {
663
- // Resume must not expose a recovered frame that exists only in memory.
664
- // Persist synchronously, then remove the sidecar; a second crash at
665
- // either boundary therefore leaves at least one complete copy.
666
- saveSession(session, {
667
- sync: true,
668
- expectedGeneration: session.generation,
669
- });
670
- if (crashRecovery.turnToken) {
671
- clearTurnCheckpoint(sessionId, crashRecovery.turnToken);
672
- }
673
- }
739
+ _recoverTurnCheckpointDurably(session, sessionId);
674
740
  const oldTools = session.tools || [];
675
741
  const cached = _preparedResumes.get(sessionId);
676
742
  _preparedResumes.delete(sessionId);
@@ -5,6 +5,7 @@ import {
5
5
  unlinkSync,
6
6
  writeFileSync,
7
7
  } from 'fs';
8
+ import { writeFile } from 'fs/promises';
8
9
  import { randomBytes } from 'crypto';
9
10
  import { join } from 'path';
10
11
  import { getPluginData } from '../../config.mjs';
@@ -54,10 +55,134 @@ export function turnMessagesForCheckpoint(messages, currentUserContent) {
54
55
  return turn.map(checkpointMessage);
55
56
  }
56
57
 
57
- export function writeTurnCheckpoint(checkpoint) {
58
+ /** Read-only projection for a turn still owned by another process. Unlike
59
+ * recoverTurnCheckpoint this never mutates the session, clears the checkpoint,
60
+ * or marks a live turn as interrupted. */
61
+ export function projectTurnCheckpointMessages(session, checkpoint) {
62
+ const source = Array.isArray(session?.messages) ? session.messages : [];
63
+ if (!session?.id || checkpoint?.sessionId !== session.id
64
+ || !Array.isArray(checkpoint?.turnMessages)) return source;
65
+ const sessionGeneration = Number(session.generation) || 0;
66
+ const checkpointGeneration = Number(checkpoint.generation) || 0;
67
+ const owner = String(session.owner || '').trim().toLowerCase();
68
+ const agent = String(session.agent || '').trim().toLowerCase();
69
+ const detachedLiveAgent = session.closed === true
70
+ && (owner === 'agent' || (agent && agent !== 'lead'))
71
+ && Boolean(String(session.ownerSessionId || session.parentSessionId || '').trim());
72
+ const markerToken = typeof session.activeTurnCheckpoint?.turnToken === 'string'
73
+ ? session.activeTurnCheckpoint.turnToken
74
+ : null;
75
+ const markerMatches = markerToken === checkpoint.turnToken;
76
+ const checkpointIsNewer = Number(checkpoint.updatedAt) > Number(session.updatedAt || 0);
77
+ // External workers deliberately detach the durable session before their
78
+ // shard-owned turn finishes. closeSession writes the lifecycle fence by
79
+ // advancing the session generation once, while that already-running turn
80
+ // keeps publishing under its original generation. Accept exactly that
81
+ // one-generation handoff only when the durable marker token still proves
82
+ // identity and the checkpoint was written after the detach.
83
+ const detachedGenerationHandoff = detachedLiveAgent
84
+ && sessionGeneration === checkpointGeneration + 1
85
+ && markerMatches
86
+ && checkpointIsNewer;
87
+ if ((checkpointGeneration !== sessionGeneration && !detachedGenerationHandoff)
88
+ || (markerToken && !markerMatches)
89
+ || (!markerToken && !checkpointIsNewer)) return source;
90
+ let start = -1;
91
+ for (let index = source.length - 1; index >= 0; index -= 1) {
92
+ if (matchingUserMessage(source[index], checkpoint.currentUserContent)) {
93
+ start = index;
94
+ break;
95
+ }
96
+ }
97
+ return [
98
+ ...(start >= 0 ? source.slice(0, start) : source),
99
+ ...checkpoint.turnMessages,
100
+ ];
101
+ }
102
+
103
+ // ── Async checkpoint writes ──────────────────────────────────────────────────
104
+ // The throttled streaming-delta flushes used to serialize+write synchronously
105
+ // on the engine thread (up to ~6 stalls/second during a tool-heavy turn). The
106
+ // async lane keeps per-session latest-wins coalescing (one in-flight write,
107
+ // one queued payload) with an epoch so clear/stop can retire stale writes —
108
+ // no locks, sessions stay fully parallel. The FIRST write of a turn stays
109
+ // sync (opts.sync) because it is the crash-durability anchor for the prompt.
110
+ const _pendingCheckpointWrites = new Map(); // sessionId → { queued, writing, epoch }
111
+ const _asyncWriteWarned = new Set(); // sessionId — warn once per session
112
+
113
+ function _warnAsyncWriteOnce(sessionId, error) {
114
+ if (_asyncWriteWarned.has(sessionId)) return;
115
+ _asyncWriteWarned.add(sessionId);
116
+ try {
117
+ process.stderr.write(`[turn-checkpoint] async write failed session=${sessionId}: ${error?.message || error}\n`);
118
+ } catch { /* stderr best-effort */ }
119
+ }
120
+
121
+ /** Retire queued + in-flight async writes for a session (epoch bump). */
122
+ export function cancelPendingTurnCheckpoint(sessionId) {
123
+ const entry = _pendingCheckpointWrites.get(sessionId);
124
+ if (!entry) return;
125
+ entry.queued = null;
126
+ entry.epoch += 1;
127
+ if (!entry.writing) _pendingCheckpointWrites.delete(sessionId);
128
+ }
129
+
130
+ function _pumpCheckpointWrites(sessionId) {
131
+ const entry = _pendingCheckpointWrites.get(sessionId);
132
+ if (!entry || entry.writing) return;
133
+ if (!entry.queued) {
134
+ _pendingCheckpointWrites.delete(sessionId);
135
+ return;
136
+ }
137
+ const { target, json } = entry.queued;
138
+ entry.queued = null;
139
+ entry.writing = true;
140
+ const epoch = entry.epoch;
141
+ const temp = `${target}.${randomBytes(6).toString('hex')}.tmp`;
142
+ writeFile(temp, json, 'utf8')
143
+ .then(() => {
144
+ // A clear/stop after this write was dequeued must win: renaming a
145
+ // retired payload would resurrect a checkpoint the turn already
146
+ // finalized. The epoch check and rename run in one synchronous
147
+ // block, so cancel (also synchronous) can never interleave.
148
+ if (entry.epoch !== epoch) {
149
+ try { unlinkSync(temp); } catch { /* best-effort */ }
150
+ return;
151
+ }
152
+ renameWithRetrySync(temp, target);
153
+ })
154
+ .catch((error) => {
155
+ try { unlinkSync(temp); } catch { /* best-effort */ }
156
+ _warnAsyncWriteOnce(sessionId, error);
157
+ })
158
+ .finally(() => {
159
+ entry.writing = false;
160
+ _pumpCheckpointWrites(sessionId);
161
+ });
162
+ }
163
+
164
+ // Hard-exit parity with the old sync behavior: a queued-but-unwritten payload
165
+ // is flushed synchronously so a clean process exit never loses the newest
166
+ // checkpoint state (crash loss remains bounded by the caller's throttle).
167
+ process.on('exit', () => {
168
+ for (const [sessionId, entry] of _pendingCheckpointWrites) {
169
+ if (!entry.queued) continue;
170
+ const { target, json } = entry.queued;
171
+ entry.queued = null;
172
+ const temp = `${target}.${randomBytes(6).toString('hex')}.tmp`;
173
+ try {
174
+ writeFileSync(temp, json, 'utf8');
175
+ renameWithRetrySync(temp, target);
176
+ } catch (error) {
177
+ try { unlinkSync(temp); } catch { /* best-effort */ }
178
+ _warnAsyncWriteOnce(sessionId, error);
179
+ }
180
+ }
181
+ });
182
+
183
+ export function writeTurnCheckpoint(checkpoint, { sync = true } = {}) {
58
184
  if (!checkpoint?.sessionId || !checkpoint?.turnToken) return false;
59
185
  const target = turnCheckpointPath(checkpoint.sessionId);
60
- const temp = `${target}.${randomBytes(6).toString('hex')}.tmp`;
61
186
  const payload = {
62
187
  ...checkpoint,
63
188
  version: TURN_CHECKPOINT_VERSION,
@@ -66,6 +191,16 @@ export function writeTurnCheckpoint(checkpoint) {
66
191
  .map(checkpointMessage),
67
192
  updatedAt: Date.now(),
68
193
  };
194
+ if (!sync) {
195
+ const json = JSON.stringify(payload);
196
+ const entry = _pendingCheckpointWrites.get(checkpoint.sessionId)
197
+ ?? { queued: null, writing: false, epoch: 0 };
198
+ _pendingCheckpointWrites.set(checkpoint.sessionId, entry);
199
+ entry.queued = { target, json };
200
+ _pumpCheckpointWrites(checkpoint.sessionId);
201
+ return true;
202
+ }
203
+ const temp = `${target}.${randomBytes(6).toString('hex')}.tmp`;
69
204
  try {
70
205
  writeFileSync(temp, JSON.stringify(payload), 'utf8');
71
206
  renameWithRetrySync(temp, target);
@@ -94,12 +229,17 @@ export function readTurnCheckpoint(sessionId) {
94
229
 
95
230
  export function clearTurnCheckpoint(sessionId, turnToken = null) {
96
231
  const target = turnCheckpointPath(sessionId);
232
+ // Queued async writes are stale the moment the caller decides to clear
233
+ // this turn's checkpoint; retire them BEFORE the token guard reads disk
234
+ // so a lagging write can never resurrect the file after the unlink.
235
+ if (!turnToken) cancelPendingTurnCheckpoint(sessionId);
97
236
  if (!existsSync(target)) return true;
98
237
  if (turnToken) {
99
238
  const current = readTurnCheckpoint(sessionId);
100
239
  // A token guard prevents an older turn's late terminal save from
101
240
  // deleting the checkpoint already created by its queued follow-up.
102
241
  if (current?.turnToken && current.turnToken !== turnToken) return false;
242
+ cancelPendingTurnCheckpoint(sessionId);
103
243
  }
104
244
  try {
105
245
  unlinkSync(target);