mixdog 0.9.96 → 0.9.97

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 (170) hide show
  1. package/README.md +0 -17
  2. package/package.json +8 -7
  3. package/scripts/build-tui.mjs +6 -6
  4. package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
  5. package/src/defaults/skills/setup/SKILL.md +1 -1
  6. package/src/lib/keychain-cjs.cjs +1 -1
  7. package/src/lib/mixdog-debug.cjs +2 -3
  8. package/src/rules/lead/01-general.md +1 -2
  9. package/src/rules/lead/lead-tool.md +0 -3
  10. package/src/rules/shared/01-tool.md +11 -10
  11. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  12. package/src/runtime/agent/orchestrator/mcp/client.mjs +81 -33
  13. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  14. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  15. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -6
  16. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -4
  18. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +20 -2
  19. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +4 -3
  20. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +40 -25
  21. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  22. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  23. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  24. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  25. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  26. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +59 -42
  27. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -2
  28. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +12 -22
  29. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  30. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +2 -0
  32. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +4 -4
  33. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +80 -30
  34. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +41 -22
  35. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +6 -2
  36. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +55 -11
  37. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  38. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +55 -18
  39. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -0
  40. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  41. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  43. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +67 -21
  44. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +2 -1
  45. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  46. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -1
  47. package/src/runtime/channels/lib/config.mjs +33 -33
  48. package/src/runtime/channels/lib/inbound-handler.mjs +7 -7
  49. package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
  50. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  51. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  52. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  53. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  54. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  55. package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
  56. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  57. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  58. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  59. package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
  60. package/src/runtime/channels/lib/worker-main.mjs +38 -38
  61. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  62. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  63. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  64. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  65. package/src/runtime/channels/tool-defs.mjs +1 -1
  66. package/src/runtime/memory/index.mjs +72 -211
  67. package/src/runtime/memory/lib/agent-ipc.mjs +9 -8
  68. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +2 -2
  69. package/src/runtime/memory/lib/http-router.mjs +1 -6
  70. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  71. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  72. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  73. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  74. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  75. package/src/runtime/memory/lib/memory.mjs +1 -1
  76. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  77. package/src/runtime/search/index.mjs +2 -2
  78. package/src/runtime/shared/abort-race.mjs +70 -0
  79. package/src/runtime/shared/child-spawn-gate.mjs +101 -63
  80. package/src/runtime/shared/config.mjs +11 -24
  81. package/src/runtime/shared/llm/index.mjs +1 -1
  82. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  83. package/src/runtime/shared/resource-admission.mjs +6 -5
  84. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  85. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  86. package/src/runtime/shared/tool-surface.mjs +91 -22
  87. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  88. package/src/runtime/shared/turn-snapshot.mjs +17 -2
  89. package/src/session-runtime/channel-config-api.mjs +5 -5
  90. package/src/session-runtime/config-lifecycle.mjs +27 -27
  91. package/src/session-runtime/lifecycle-api.mjs +22 -3
  92. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  93. package/src/session-runtime/prewarm.mjs +1 -1
  94. package/src/session-runtime/provider-models.mjs +1 -1
  95. package/src/session-runtime/remote-control.mjs +6 -7
  96. package/src/session-runtime/runtime-core.mjs +52 -28
  97. package/src/session-runtime/self-update.mjs +1 -1
  98. package/src/session-runtime/session-lifecycle.mjs +36 -15
  99. package/src/session-runtime/session-title.mjs +1 -1
  100. package/src/session-runtime/session-turn-api.mjs +55 -15
  101. package/src/session-runtime/settings-api.mjs +7 -7
  102. package/src/standalone/agent-dispatch-broker.mjs +1 -1
  103. package/src/standalone/channel-admin.mjs +42 -53
  104. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +16 -18
  105. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +45 -46
  106. package/src/standalone/channel-worker.mjs +124 -647
  107. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +187 -132
  108. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  109. package/src/standalone/{engine-daemon-client.mjs → session-client.mjs} +151 -86
  110. package/src/standalone/session-protocol.mjs +8 -4
  111. package/src/standalone/session-runtime-pool.mjs +393 -0
  112. package/src/standalone/session-runtime-record.mjs +20 -0
  113. package/src/standalone/session-runtime-worker.mjs +203 -0
  114. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +170 -157
  115. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +139 -61
  116. package/src/standalone/session-wire.mjs +55 -0
  117. package/src/tui/App.jsx +4 -4
  118. package/src/tui/app/app-format.mjs +1 -1
  119. package/src/tui/app/channel-pickers.mjs +41 -41
  120. package/src/tui/app/input-parsers.mjs +2 -2
  121. package/src/tui/app/onboarding-steps.mjs +2 -2
  122. package/src/tui/app/project-picker.mjs +8 -8
  123. package/src/tui/app/prompt-submit.mjs +8 -8
  124. package/src/tui/app/settings-picker.mjs +25 -25
  125. package/src/tui/app/use-prompt-queue-history.mjs +1 -1
  126. package/src/tui/app/use-transcript-scroll.mjs +1 -1
  127. package/src/tui/components/ToolExecution.jsx +1 -1
  128. package/src/tui/components/prompt-input/restore-policy.mjs +1 -1
  129. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  130. package/src/tui/dist/index.mjs +154 -127
  131. package/src/tui/hooks/useSession.mjs +16 -0
  132. package/src/tui/index.jsx +11 -11
  133. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  134. package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
  135. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  136. package/src/tui/{engine → session}/context-state.mjs +3 -3
  137. package/src/tui/{engine → session}/labels.mjs +2 -2
  138. package/src/tui/{engine → session}/live-share.mjs +7 -7
  139. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  140. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  141. package/src/tui/session/oauth-flows.mjs +140 -0
  142. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  143. package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
  144. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  145. package/src/tui/{engine → session}/session-api-ext.mjs +30 -17
  146. package/src/tui/{engine → session}/session-api.mjs +7 -60
  147. package/src/tui/{engine → session}/session-flow.mjs +1 -1
  148. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  149. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  150. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  151. package/src/tui/{engine → session}/tool-card-results.mjs +4 -4
  152. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  153. package/src/tui/{engine → session}/tool-result-text.mjs +3 -3
  154. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  155. package/src/tui/{engine → session}/turn.mjs +29 -107
  156. package/src/tui/{engine-local-session.mjs → session-local.mjs} +59 -75
  157. package/src/tui/session.mjs +22 -0
  158. package/src/ui/statusline-segments.mjs +1 -1
  159. package/src/ui/statusline.mjs +1 -1
  160. package/vendor/ink/build/components/App.js +10 -1
  161. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  162. package/src/standalone/engine-daemon-protocol.mjs +0 -32
  163. package/src/tui/engine/turn-watchdog.mjs +0 -92
  164. package/src/tui/engine.mjs +0 -22
  165. package/src/tui/hooks/useEngine.mjs +0 -16
  166. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  167. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  168. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  169. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  170. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -1,11 +1,19 @@
1
1
  // Eager tool-dispatch controller, extracted from agent-loop.mjs. Owns the
2
2
  // per-turn pending promise map, the intra-turn in-flight signature set, and
3
- // the mutation epoch. Every valid call may start while the provider is still
4
- // streaming, including apply_patch and shell in the same batch. Tool-local
5
- // conflict guards own safety; results are collected later in call order.
3
+ // the mutation epoch. Every valid call dispatches while the provider is still
4
+ // streaming. Calls execute in parallel except that shell after apply_patch
5
+ // waits for every earlier patch in the turn; results are collected later in
6
+ // call order.
6
7
  import { normalizeToolEnvelope } from './tool-envelope.mjs';
7
8
  import { isInvalidToolArgsMarker } from '../providers/openai-compat-stream.mjs';
8
- import { _intraTurnSig, _isReadTool, _isScopedCacheableTool, _stripMcpPrefix } from './loop/tool-classify.mjs';
9
+ import {
10
+ _intraTurnSig,
11
+ _isMutationTool,
12
+ _isReadTool,
13
+ _isScopedCacheableTool,
14
+ _isShellTool,
15
+ _stripMcpPrefix,
16
+ } from './loop/tool-classify.mjs';
9
17
  import { tryReadCached, tryScopedToolCached } from './read-dedup.mjs';
10
18
  import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
11
19
  import { executeTool } from './loop/tool-exec.mjs';
@@ -18,6 +26,10 @@ export function createEagerDispatcher({
18
26
  executeToolFn = executeTool,
19
27
  }) {
20
28
  const pending = new Map();
29
+ // Cumulative settlement barrier for patches already emitted in this
30
+ // assistant turn. Patches remain path-parallel with each other; only a
31
+ // later shell waits, so validation cannot observe pre-patch files.
32
+ let patchBarrier = Promise.resolve();
21
33
  // Streaming-time intra-turn dedup. When the LLM emits two
22
34
  // tool_use blocks with identical (name, args) signatures in
23
35
  // sequence, the provider's onToolCall fires for both BEFORE
@@ -90,9 +102,11 @@ export function createEagerDispatcher({
90
102
  // this call there, never start it eagerly here.
91
103
  if (preDispatchDenyForSession(sessionRef, call, toolKind) !== null) return null;
92
104
  const entry = { startedAt: Date.now(), endedAt: null, mutationEpoch: epoch.mutation };
105
+ const precedingPatches = _isShellTool(call.name) ? patchBarrier : null;
93
106
  if (_dedupEligible) _eagerInFlightSigs.set(_sig, call.id);
94
107
  entry.promise = (async () => {
95
108
  try {
109
+ if (precedingPatches) await precedingPatches;
96
110
  await opts.beforeToolExecution?.();
97
111
  return { ok: true, value: await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: getNextIteration(), deferShellCwdCommit: true }) };
98
112
  } catch (error) {
@@ -152,6 +166,10 @@ export function createEagerDispatcher({
152
166
  return settled;
153
167
  });
154
168
  pending.set(call.id, entry);
169
+ if (_isMutationTool(call.name)) {
170
+ const currentPatch = entry.promise;
171
+ patchBarrier = Promise.allSettled([patchBarrier, currentPatch]).then(() => undefined);
172
+ }
155
173
  return entry;
156
174
  };
157
175
  const startEagerRun = (calls, startIndex, dupSet) => {
@@ -23,6 +23,7 @@ import {
23
23
  } from './tool-helpers.mjs';
24
24
  import { isOnDeferredToolSurface, prepareDeferredToolCallThrough } from './deferred-call-through.mjs';
25
25
  import { preDispatchDenyForSession, routeWebFetchCall } from './pre-dispatch-deny.mjs';
26
+ import { runWithToolExecutionOwner } from '../../../../shared/tool-execution-owner.mjs';
26
27
 
27
28
  let codeGraphRuntimePromise = null;
28
29
  async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
@@ -32,6 +33,11 @@ async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options
32
33
  return mod.executeCodeGraphTool(name, args, cwd, signal, options);
33
34
  }
34
35
 
36
+ export function resolveLiveToolCwd(cwd, sessionRef) {
37
+ const liveCwd = typeof sessionRef?.cwd === 'string' ? sessionRef.cwd : '';
38
+ return liveCwd || cwd;
39
+ }
40
+
35
41
  export function _scopedCacheOutcomeForCall(sessionRef, toolCallId, toolName, callerSessionId, executeOpts = {}) {
36
42
  if (executeOpts.scopedCacheOutcome) {
37
43
  if (sessionRef && toolCallId) {
@@ -51,7 +57,16 @@ export function _scopedCacheOutcomeForCall(sessionRef, toolCallId, toolName, cal
51
57
  return outcome;
52
58
  }
53
59
 
54
- export async function executeTool(name, args, cwd, callerSessionId, sessionRef, executeOpts = {}) {
60
+ export function executeTool(name, args, cwd, callerSessionId, sessionRef, executeOpts = {}) {
61
+ return runWithToolExecutionOwner(callerSessionId, () =>
62
+ executeToolOwned(name, args, cwd, callerSessionId, sessionRef, executeOpts));
63
+ }
64
+
65
+ async function executeToolOwned(name, args, cwd, callerSessionId, sessionRef, executeOpts = {}) {
66
+ // cwd is captured when the turn starts. The deferred cwd tool updates
67
+ // sessionRef.cwd in place, so every later tool call must re-read that live
68
+ // value instead of continuing to use the stale turn snapshot.
69
+ cwd = resolveLiveToolCwd(cwd, sessionRef);
55
70
  const scopedCacheOutcome = _scopedCacheOutcomeForCall(
56
71
  sessionRef,
57
72
  executeOpts.toolCallId,
@@ -174,7 +189,10 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
174
189
  && mcpToolHasField(name, 'cwd')
175
190
  && (args == null || args.cwd == null);
176
191
  const finalArgs = needsCwdInjection ? { ...(args || {}), cwd } : args;
177
- return executeMcpTool(name, finalArgs);
192
+ return executeMcpTool(name, finalArgs, {
193
+ signal: executeOpts.signal || null,
194
+ ownerKey: callerSessionId,
195
+ });
178
196
  }
179
197
  if (name === 'code_graph') {
180
198
  // cwd chain: args.cwd (caller-explicit) → session cwd → undefined (handler throws)
@@ -40,9 +40,10 @@ export function isEagerDispatchable(name, tools) {
40
40
  return set.has(name);
41
41
  }
42
42
 
43
- // Full-parallel dispatch policy: EVERY valid tool call in an assistant turn
44
- // starts immediately. Mutations rely on their own conflict scope (apply_patch
45
- // uses per-path locks), so disjoint writes never wait behind a global barrier.
43
+ // Full dispatch policy: EVERY valid tool call in an assistant turn is queued
44
+ // immediately. Mutations rely on their own conflict scope (apply_patch uses
45
+ // per-path locks); the eager dispatcher adds only the apply_patch -> shell
46
+ // execution barrier required for same-turn validation.
46
47
  // isEagerDispatchable above remains the READ-ONLY classifier used for dedup
47
48
  // eligibility, steering, and post-mutation result invalidation.
48
49
  export function isParallelDispatchable(name) {
@@ -67,6 +67,17 @@ import {
67
67
  import { _getAgentLoop } from './runtime-loaders.mjs';
68
68
  import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
69
69
  import { recordProviderContextBaseline } from '../loop/compact-policy.mjs';
70
+ import { runAbortable, settleWithin, throwIfAborted } from '../../../../shared/abort-race.mjs';
71
+
72
+ export const DEFAULT_ASK_CLEANUP_SETTLE_MS = 2_000;
73
+
74
+ export async function settleAskCleanup(promise, { timeoutMs } = {}) {
75
+ const configured = Number(process.env.MIXDOG_ASK_CLEANUP_SETTLE_MS);
76
+ const budget = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
77
+ ? Number(timeoutMs)
78
+ : (Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_ASK_CLEANUP_SETTLE_MS);
79
+ return await settleWithin(promise, budget);
80
+ }
70
81
 
71
82
  export function persistedAssistantTranscriptMetadata(value, fallbackAt = Date.now()) {
72
83
  if (!value || typeof value !== 'object') return null;
@@ -196,7 +207,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
196
207
  if (process.env.MIXDOG_DEBUG_AGENT) {
197
208
  process.stderr.write(`[agent-trace] t0-ask-start sessionHash=${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 8)} role=? iteration=0 promptSrc=${_promptSrc} prefetchFiles=${_prefetchFiles} callers=${_prefetchCallers} references=${_prefetchRefs}\n`);
198
209
  }
199
- const unlock = await acquireSessionLock(sessionId);
210
+ const unlock = await acquireSessionLock(sessionId, askOpts?.signal);
200
211
  // Start crash-spool hydration without delaying the user turn. A completed
201
212
  // background hydration joins the id-deduped memory drain below.
202
213
  const takeoverHydration = hydratePendingMessages(sessionId);
@@ -309,9 +320,10 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
309
320
  // computation would run detached. Capture the linked signal, install the
310
321
  // fresh controller, then re-cascade it — aborting the new controller
311
322
  // immediately when the parent already fired, or re-arming the listener.
312
- const _linkedParentSignal = runtime.parentAbortLink?.signal;
323
+ const _linkedParentSignal = askOpts?.signal || runtime.parentAbortLink?.signal;
313
324
  // Fresh controller per ask — the previous ask's controller may have aborted.
314
325
  runtime.controller = createAbortController();
326
+ const turnSignal = runtime.controller.signal;
315
327
  runtime.generation = askGeneration;
316
328
  runtime.closed = false;
317
329
  runtime.session = preSession;
@@ -469,7 +481,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
469
481
  // even to the real task. Single-turn structure with a labelled
470
482
  // `# Task` block forces the model to treat the brief as the work
471
483
  // unit, not as another piece of context to ack.
472
- const explicitPrefetchResult = await _tryBridgeExplicitPrefetch(session, explicitPrefetch);
484
+ const explicitPrefetchResult = await _tryBridgeExplicitPrefetch(session, explicitPrefetch, turnSignal);
473
485
  let _contextBlock = '';
474
486
  if (context) {
475
487
  _contextBlock += `# Additional context\n${_capCtx(context)}\n\n`;
@@ -574,7 +586,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
574
586
  },
575
587
  });
576
588
  } catch { /* trace must never break the ask path */ }
577
- const agentLoop = await _getAgentLoop();
589
+ const agentLoop = await runAbortable(turnSignal, () => _getAgentLoop());
578
590
  const _trackTextDelta = (chunk) => {
579
591
  _turnInterruption.recordTextDelta(chunk);
580
592
  _scheduleTurnCheckpoint();
@@ -766,6 +778,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
766
778
  session.toolApprovalHook = priorToolApprovalHook;
767
779
  }
768
780
  }
781
+ throwIfAborted(turnSignal);
769
782
  // Post-loop validation: if closeSession() landed while we were awaiting,
770
783
  // drop the save so the tombstone on disk isn't overwritten.
771
784
  const currentRuntime = _getRuntimeEntry(sessionId);
@@ -900,7 +913,10 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
900
913
  // Unified total-prompt field. Anthropic = input+cache_read+cache_write
901
914
  // (additive); OpenAI OAuth/API/Gemini = input_tokens already includes the
902
915
  // cached portion (inclusive), so the fallback must not double-count.
903
- const { isInclusiveProvider, computeCostUsd } = await import('../../../../shared/llm/cost.mjs');
916
+ const { isInclusiveProvider, computeCostUsd } = await runAbortable(
917
+ turnSignal,
918
+ () => import('../../../../shared/llm/cost.mjs'),
919
+ );
904
920
  const inclusive = isInclusiveProvider(session.provider);
905
921
  const promptTokens = typeof result.usage.promptTokens === 'number'
906
922
  ? result.usage.promptTokens
@@ -1042,15 +1058,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
1042
1058
  }
1043
1059
  try {
1044
1060
  const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
1045
- if (finalized.responsePreserved) {
1046
- await finalizePendingMessageDelivery(
1061
+ const cleanup = finalized.responsePreserved
1062
+ ? finalizePendingMessageDelivery(
1047
1063
  activeSession, _turnPendingEntries, durableSave,
1048
1064
  () => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
1049
- );
1050
- } else {
1051
- await durableSave;
1052
- }
1053
- clearTurnCheckpoint(sessionId, _turnCheckpointToken);
1065
+ )
1066
+ : durableSave;
1067
+ const cleanupResult = await settleAskCleanup(cleanup);
1068
+ if (cleanupResult.settled) clearTurnCheckpoint(sessionId, _turnCheckpointToken);
1054
1069
  } catch { /* cancellation cleanup is best-effort */ }
1055
1070
  if (currentRuntime) currentRuntime.session = activeSession;
1056
1071
  } else releasePendingMessages(sessionId, _turnPendingEntries);
@@ -1088,35 +1103,34 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
1088
1103
  else releasePendingMessages(sessionId, _turnPendingEntries);
1089
1104
  try {
1090
1105
  const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
1091
- if (finalized.responsePreserved) {
1092
- await finalizePendingMessageDelivery(
1106
+ const cleanup = finalized.responsePreserved
1107
+ ? finalizePendingMessageDelivery(
1093
1108
  activeSession, _turnPendingEntries, durableSave,
1094
1109
  () => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
1095
- );
1096
- } else {
1097
- await durableSave;
1098
- }
1099
- _errorStateDurable = true;
1110
+ )
1111
+ : durableSave;
1112
+ _errorStateDurable = (await settleAskCleanup(cleanup)).settled;
1100
1113
  } catch { /* provider-failure history persistence is best-effort */ }
1101
1114
  const currentRuntime = _getRuntimeEntry(sessionId);
1102
1115
  if (currentRuntime) currentRuntime.session = activeSession;
1103
1116
  } else {
1104
- const promptPersisted = await persistCompactedOutgoingAfterAskFailure({
1117
+ const compactPersist = await settleAskCleanup(persistCompactedOutgoingAfterAskFailure({
1105
1118
  sessionId,
1106
1119
  activeSession,
1107
1120
  askGeneration,
1108
1121
  turnOutgoing: _turnOutgoing,
1109
1122
  error: err,
1110
- });
1123
+ }));
1124
+ const promptPersisted = compactPersist.settled && compactPersist.value === true;
1111
1125
  if (promptPersisted) {
1112
1126
  recordPendingMessageDelivery(activeSession, _turnPendingEntries);
1113
1127
  try {
1114
1128
  const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
1115
- await finalizePendingMessageDelivery(
1129
+ const cleanup = finalizePendingMessageDelivery(
1116
1130
  activeSession, _turnPendingEntries, durableSave,
1117
1131
  () => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
1118
1132
  );
1119
- _errorStateDurable = true;
1133
+ _errorStateDurable = (await settleAskCleanup(cleanup)).settled;
1120
1134
  } catch {}
1121
1135
  } else {
1122
1136
  releasePendingMessages(sessionId, _turnPendingEntries);
@@ -1124,8 +1138,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
1124
1138
  }
1125
1139
  if (!_errorStateDurable && activeSession) {
1126
1140
  try {
1127
- await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
1128
- _errorStateDurable = true;
1141
+ _errorStateDurable = (await settleAskCleanup(
1142
+ saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
1143
+ )).settled;
1129
1144
  } catch { /* retain checkpoint when canonical persistence fails */ }
1130
1145
  }
1131
1146
  if (_errorStateDurable) clearTurnCheckpoint(sessionId, _turnCheckpointToken);
@@ -77,8 +77,7 @@ function addCompactUsageToSession(session, usage) {
77
77
  session.totalUncachedInputTokens = (session.totalUncachedInputTokens || 0) + uncachedInputTokens;
78
78
  session.tokensCumulative = (session.tokensCumulative || 0) + inputTokens + outputTokens;
79
79
  }
80
- // A dead/unreachable memory runtime makes each proxy RPC wait ~30s (waitForPort)
81
- // and retry once (memory-runtime-proxy.mjs) — that must NEVER wedge the
80
+ // A stalled memory call must NEVER wedge the
82
81
  // compact//clear path. Bound every recall-fasttrack memory call with a short
83
82
  // local timeout: on timeout we abort (best-effort cancel via a chained signal)
84
83
  // and treat it exactly like an RPC failure, so compaction proceeds WITHOUT
@@ -93,7 +92,7 @@ function recallMemoryTimeoutMs(session) {
93
92
  // misconfigured tiny value can't turn the bound into a busy no-wait.
94
93
  return Math.max(250, configured || RECALL_MEMORY_CALL_TIMEOUT_MS);
95
94
  }
96
- // Cold-start allowance (clear/manual path only): a booting memory daemon can
95
+ // Cold-start allowance (clear/manual path only): a booting memory runtime can
97
96
  // miss the tight first bound (waitForPort + first-RPC warmup ~2-10s). On a
98
97
  // timeout we retry ONCE with a longer bound before honoring the bail-to-
99
98
  // semantic contract, so a rebooting runtime succeeds instead of instantly
@@ -6,8 +6,10 @@ import { executeInternalTool } from '../../internal-tools.mjs';
6
6
  import { classifyResultKind } from '../result-classification.mjs';
7
7
  import { tryPrefetchCached, setPrefetchCached } from '../read-dedup.mjs';
8
8
  import { _executeCodeGraphToolLazy } from './runtime-loaders.mjs';
9
+ import { runAbortable, throwIfAborted } from '../../../../shared/abort-race.mjs';
9
10
 
10
- export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
11
+ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch, signal = null) {
12
+ throwIfAborted(signal);
11
13
  if (!explicitPrefetch || typeof explicitPrefetch !== 'object') return null;
12
14
  if (!isAgentOwner(session)) return null;
13
15
  const parts = [];
@@ -52,7 +54,10 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
52
54
  totalEntries.push(...files);
53
55
  // R20: per-file prefetch cache (cross-dispatch, process-local).
54
56
  // Try each file from cache first; batch misses into one disk read.
55
- const { resolve: _pfResolve, isAbsolute: _pfIsAbs, normalize: _pfNorm } = await import('path');
57
+ const { resolve: _pfResolve, isAbsolute: _pfIsAbs, normalize: _pfNorm } = await runAbortable(
58
+ signal,
59
+ () => import('path'),
60
+ );
56
61
  const _pfCwd = session.cwd || null;
57
62
  function _pfAbsPath(f) {
58
63
  const abs = _pfIsAbs(f) ? f : _pfResolve(_pfCwd || process.cwd(), f);
@@ -79,7 +84,7 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
79
84
  if (missFiles.length > 0) {
80
85
  // Read each miss file individually so we can cache per-file.
81
86
  // The files list is small (typically 2-5), so N awaits is fine.
82
- await Promise.all(missFiles.map(async (f) => {
87
+ await runAbortable(signal, () => Promise.all(missFiles.map(async (f) => {
83
88
  const opts = _readOptsByFile.get(f) || {};
84
89
  const readArgs = { path: f };
85
90
  if (opts.mode === 'full') {
@@ -95,7 +100,7 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
95
100
  if (out !== null) {
96
101
  missResults[f] = String(out);
97
102
  }
98
- }));
103
+ })));
99
104
  // Cache successful miss results.
100
105
  for (const { file, abs } of fileMisses) {
101
106
  const content = missResults[file];
@@ -157,7 +162,7 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
157
162
  return { symbol, out: null };
158
163
  });
159
164
  });
160
- const callerResults = await Promise.allSettled(callerTasks);
165
+ const callerResults = await runAbortable(signal, () => Promise.allSettled(callerTasks));
161
166
  for (const r of callerResults) {
162
167
  const { symbol, out } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
163
168
  if (out && classifyResultKind(String(out)) !== 'error') {
@@ -182,7 +187,7 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
182
187
  return { symbol, out: null };
183
188
  });
184
189
  });
185
- const refResults = await Promise.allSettled(refTasks);
190
+ const refResults = await runAbortable(signal, () => Promise.allSettled(refTasks));
186
191
  for (const r of refResults) {
187
192
  const { symbol, out } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
188
193
  if (out && classifyResultKind(String(out)) !== 'error') {
@@ -192,6 +197,7 @@ export async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
192
197
  }
193
198
  }
194
199
  }
200
+ throwIfAborted(signal);
195
201
  if (session && typeof session === 'object' && (callers.length > 0 || references.length > 0)) {
196
202
  if (!session.prefetchStats) session.prefetchStats = { files: 0, cached: 0, miss: 0, failed: 0, callers: 0, references: 0 };
197
203
  session.prefetchStats.callers = (session.prefetchStats.callers || 0) + callers.length;
@@ -2,8 +2,10 @@
2
2
  // Per-session mutex extracted verbatim from manager.mjs. Queues concurrent
3
3
  // askSession calls (and their drained pending-message tail turns) to prevent
4
4
  // message loss / interleaving.
5
+ import { abortReason } from '../../../../shared/abort-race.mjs';
6
+
5
7
  const _sessionLocks = new Map();
6
- export function acquireSessionLock(sessionId) {
8
+ export function acquireSessionLock(sessionId, signal = null) {
7
9
  let entry = _sessionLocks.get(sessionId);
8
10
  if (!entry) {
9
11
  entry = { promise: Promise.resolve(), count: 0 };
@@ -15,9 +17,43 @@ export function acquireSessionLock(sessionId) {
15
17
  entry.promise = new Promise(r => { release = r; });
16
18
  // Self-heal: if the previous holder rejected, swallow so subsequent
17
19
  // queued waiters don't propagate that rejection and brick the lock chain.
18
- return prev.catch(() => {}).then(() => () => {
20
+ const previousReady = prev.catch(() => {});
21
+ let slotReleased = false;
22
+ const releaseSlot = () => {
23
+ if (slotReleased) return;
24
+ slotReleased = true;
19
25
  entry.count--;
20
26
  if (entry.count === 0) _sessionLocks.delete(sessionId);
21
27
  release();
28
+ };
29
+ const unlock = () => releaseSlot();
30
+ if (!signal) return previousReady.then(() => unlock);
31
+
32
+ // A cancelled waiter must reject immediately without opening a hole in the
33
+ // mutex. Its reserved slot is skipped only after the previous holder exits,
34
+ // so later waiters still cannot overlap the live owner.
35
+ return new Promise((resolve, reject) => {
36
+ let settled = false;
37
+ const removeAbort = () => {
38
+ try { signal.removeEventListener('abort', onAbort); } catch {}
39
+ };
40
+ const onAbort = () => {
41
+ if (settled) return;
42
+ settled = true;
43
+ removeAbort();
44
+ void previousReady.then(releaseSlot);
45
+ reject(abortReason(signal, `Session "${sessionId}" lock wait aborted`));
46
+ };
47
+ signal.addEventListener('abort', onAbort, { once: true });
48
+ if (signal.aborted) {
49
+ onAbort();
50
+ return;
51
+ }
52
+ previousReady.then(() => {
53
+ if (settled) return;
54
+ settled = true;
55
+ removeAbort();
56
+ resolve(unlock);
57
+ });
22
58
  });
23
59
  }
@@ -121,7 +121,11 @@ export function _queueSessionSummaryUpsert(session, version = null) {
121
121
  */
122
122
  export function _queueSessionSummaryUpsertRow(row, version = null) {
123
123
  if (!row) return;
124
- _setCachedBaseSummary(row);
124
+ const fileProbe = probePath(join(getStoreDir(), `${row.id}.json`));
125
+ const durableRow = fileProbe.state === PROBE_PRESENT
126
+ ? { ...row, storageMtimeMs: fileProbe.mtimeMs, storageSize: fileProbe.size }
127
+ : row;
128
+ _setCachedBaseSummary(durableRow);
125
129
  if (version === null || (_summaryCacheVersions.get(row.id) || 0) === version) {
126
130
  _summaryCacheUpserts.delete(row.id);
127
131
  if (_summaryCacheLatestRows.get(row.id)?.version === version) {
@@ -130,7 +134,7 @@ export function _queueSessionSummaryUpsertRow(row, version = null) {
130
134
  _summaryCacheRemovals.delete(row.id);
131
135
  _invalidateSummaryRowsView();
132
136
  }
133
- _upsertSessionSummaryRow(row);
137
+ _upsertSessionSummaryRow(durableRow);
134
138
  }
135
139
 
136
140
  export function _queueSessionSummaryRemoval(id) {
@@ -220,7 +224,10 @@ export function _scanStoredSessionSummaryRows() {
220
224
  else markInvalid(f);
221
225
  continue;
222
226
  }
223
- const row = session ? _sessionSummary(session) : null;
227
+ const summary = session ? _sessionSummary(session) : null;
228
+ const row = summary
229
+ ? { ...summary, storageMtimeMs: fileProbe.mtimeMs, storageSize: fileProbe.size }
230
+ : null;
224
231
  _summaryScanCache.set(f, { mtimeMs: fileProbe.mtimeMs, size: fileProbe.size, row });
225
232
  changed = true;
226
233
  if (row) rows.push(row);
@@ -154,6 +154,8 @@ export function _sessionSummary(session) {
154
154
  preview: messageProjection.preview,
155
155
  generation: typeof session.generation === 'number' ? session.generation : 0,
156
156
  implicitBashSessionId: session.implicitBashSessionId || null,
157
+ storageMtimeMs: _positiveNumber(session.storageMtimeMs, 0),
158
+ storageSize: _positiveNumber(session.storageSize, 0),
157
159
  // Lifecycle provenance for catalog filters: resume-machinery scratch
158
160
  // forks (detachedReason='cli-resume') must never surface in Recent.
159
161
  detachedReason: session.detachedReason || null,
@@ -190,6 +192,8 @@ function _normalizeSummaryRow(row) {
190
192
  preview: _cleanPreview(row.preview || ''),
191
193
  generation: typeof row.generation === 'number' ? row.generation : 0,
192
194
  implicitBashSessionId: row.implicitBashSessionId || null,
195
+ storageMtimeMs: _positiveNumber(row.storageMtimeMs, 0),
196
+ storageSize: _positiveNumber(row.storageSize, 0),
193
197
  detachedReason: row.detachedReason || null,
194
198
  };
195
199
  }