mixdog 0.9.96 → 0.9.99

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 (193) hide show
  1. package/README.md +0 -17
  2. package/package.json +16 -8
  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/agent/30-explorer.md +43 -32
  9. package/src/rules/lead/01-general.md +1 -2
  10. package/src/rules/lead/lead-tool.md +0 -3
  11. package/src/rules/shared/01-tool.md +11 -10
  12. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  13. package/src/runtime/agent/orchestrator/agent-trace.mjs +19 -5
  14. package/src/runtime/agent/orchestrator/mcp/client.mjs +81 -33
  15. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +7 -0
  16. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +30 -3
  17. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +5 -3
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  19. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +23 -1
  20. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  21. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -6
  22. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -4
  24. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +20 -2
  25. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +4 -3
  26. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +42 -26
  27. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  28. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +44 -6
  29. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  30. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -1
  31. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  32. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +3 -6
  33. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  34. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  35. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +59 -42
  36. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -2
  37. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +12 -22
  38. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  39. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +2 -0
  41. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +103 -12
  42. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +4 -4
  43. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +80 -30
  44. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +10 -30
  45. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +41 -22
  46. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +6 -2
  47. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +55 -11
  48. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  49. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +55 -18
  50. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -0
  51. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  52. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -105
  55. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +2 -1
  56. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  57. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -1
  58. package/src/runtime/attachments/pdf-extract.mjs +83 -0
  59. package/src/runtime/attachments/store.mjs +515 -0
  60. package/src/runtime/attachments/store.test.mjs +188 -0
  61. package/src/runtime/channels/lib/config.mjs +33 -33
  62. package/src/runtime/channels/lib/inbound-handler.mjs +7 -7
  63. package/src/runtime/channels/lib/interaction-handlers.mjs +4 -194
  64. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  65. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  66. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  67. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  68. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  69. package/src/runtime/channels/lib/runtime-paths.mjs +5 -34
  70. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  71. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  72. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  73. package/src/runtime/channels/lib/worker-ipc.mjs +4 -70
  74. package/src/runtime/channels/lib/worker-main.mjs +39 -58
  75. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  76. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  77. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  78. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  79. package/src/runtime/channels/tool-defs.mjs +1 -1
  80. package/src/runtime/memory/index.mjs +72 -211
  81. package/src/runtime/memory/lib/agent-ipc.mjs +9 -8
  82. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +2 -2
  83. package/src/runtime/memory/lib/http-router.mjs +1 -6
  84. package/src/runtime/memory/lib/http-wire.mjs +39 -4
  85. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  86. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  87. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  88. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  89. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  90. package/src/runtime/memory/lib/memory.mjs +1 -1
  91. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  92. package/src/runtime/search/index.mjs +2 -2
  93. package/src/runtime/shared/abort-race.mjs +70 -0
  94. package/src/runtime/shared/child-spawn-gate.mjs +101 -63
  95. package/src/runtime/shared/config.mjs +11 -24
  96. package/src/runtime/shared/json-metrics.mjs +94 -0
  97. package/src/runtime/shared/llm/index.mjs +1 -1
  98. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  99. package/src/runtime/shared/resource-admission.mjs +6 -5
  100. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  101. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  102. package/src/runtime/shared/tool-surface.mjs +91 -22
  103. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  104. package/src/runtime/shared/turn-snapshot.mjs +30 -2
  105. package/src/runtime/shared/turn-worktree-snapshot.mjs +19 -0
  106. package/src/session-runtime/channel-config-api.mjs +5 -5
  107. package/src/session-runtime/config-lifecycle.mjs +27 -27
  108. package/src/session-runtime/lifecycle-api.mjs +22 -3
  109. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  110. package/src/session-runtime/prewarm.mjs +1 -1
  111. package/src/session-runtime/provider-models.mjs +12 -2
  112. package/src/session-runtime/quick-model-rows.mjs +38 -7
  113. package/src/session-runtime/quick-search-models.mjs +15 -17
  114. package/src/session-runtime/remote-control.mjs +6 -7
  115. package/src/session-runtime/runtime-core.mjs +61 -32
  116. package/src/session-runtime/runtime-tunables.mjs +9 -6
  117. package/src/session-runtime/self-update.mjs +1 -1
  118. package/src/session-runtime/session-lifecycle.mjs +36 -15
  119. package/src/session-runtime/session-title.mjs +28 -4
  120. package/src/session-runtime/session-turn-api.mjs +55 -15
  121. package/src/session-runtime/settings-api.mjs +7 -7
  122. package/src/standalone/agent-dispatch-broker.mjs +1 -1
  123. package/src/standalone/channel-admin.mjs +42 -53
  124. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +16 -18
  125. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +73 -58
  126. package/src/standalone/channel-worker.mjs +124 -647
  127. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +190 -132
  128. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  129. package/src/standalone/{engine-daemon-client.mjs → session-client.mjs} +151 -86
  130. package/src/standalone/session-protocol.mjs +9 -4
  131. package/src/standalone/session-runtime-pool.mjs +393 -0
  132. package/src/standalone/session-runtime-record.mjs +20 -0
  133. package/src/standalone/session-runtime-worker.mjs +203 -0
  134. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +192 -157
  135. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +142 -63
  136. package/src/standalone/session-wire.mjs +55 -0
  137. package/src/tui/App.jsx +4 -4
  138. package/src/tui/app/app-format.mjs +1 -1
  139. package/src/tui/app/channel-pickers.mjs +41 -41
  140. package/src/tui/app/input-parsers.mjs +2 -2
  141. package/src/tui/app/onboarding-steps.mjs +2 -2
  142. package/src/tui/app/project-picker.mjs +8 -8
  143. package/src/tui/app/prompt-submit.mjs +15 -17
  144. package/src/tui/app/settings-picker.mjs +25 -25
  145. package/src/tui/app/use-prompt-handlers.mjs +7 -3
  146. package/src/tui/app/use-prompt-queue-history.mjs +1 -1
  147. package/src/tui/app/use-transcript-scroll.mjs +1 -1
  148. package/src/tui/components/ToolExecution.jsx +1 -1
  149. package/src/tui/components/prompt-input/restore-policy.mjs +1 -1
  150. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  151. package/src/tui/dist/index.mjs +403 -170
  152. package/src/tui/hooks/useSession.mjs +16 -0
  153. package/src/tui/index.jsx +11 -11
  154. package/src/tui/paste-attachments.mjs +13 -7
  155. package/src/tui/prompt-history-store.mjs +52 -4
  156. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  157. package/src/tui/{engine → session}/agent-job-feed.mjs +9 -5
  158. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  159. package/src/tui/{engine → session}/context-state.mjs +3 -3
  160. package/src/tui/{engine → session}/labels.mjs +2 -2
  161. package/src/tui/{engine → session}/live-share.mjs +28 -14
  162. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  163. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  164. package/src/tui/session/oauth-flows.mjs +140 -0
  165. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  166. package/src/tui/{engine → session}/queue-helpers.mjs +8 -8
  167. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  168. package/src/tui/{engine → session}/session-api-ext.mjs +42 -21
  169. package/src/tui/{engine → session}/session-api.mjs +17 -61
  170. package/src/tui/{engine → session}/session-flow.mjs +5 -3
  171. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  172. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  173. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  174. package/src/tui/{engine → session}/tool-card-results.mjs +4 -4
  175. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  176. package/src/tui/{engine → session}/tool-result-text.mjs +3 -3
  177. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  178. package/src/tui/{engine → session}/turn.mjs +29 -107
  179. package/src/tui/{engine-local-session.mjs → session-local.mjs} +72 -78
  180. package/src/tui/session.mjs +22 -0
  181. package/src/ui/statusline-segments.mjs +1 -1
  182. package/src/ui/statusline.mjs +1 -1
  183. package/vendor/ink/build/components/App.js +10 -1
  184. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  185. package/src/standalone/engine-daemon-protocol.mjs +0 -32
  186. package/src/tui/engine/turn-watchdog.mjs +0 -92
  187. package/src/tui/engine.mjs +0 -22
  188. package/src/tui/hooks/useEngine.mjs +0 -16
  189. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  190. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  191. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  192. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  193. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -9,6 +9,7 @@ import { readStreamOutcome } from '../../providers/lib/stream-outcome.mjs';
9
9
  import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
10
10
  import { loadSession, saveSession, saveSessionAsync, saveSessionAsyncDeferred, readSessionLifecycleFromDisk } from '../store.mjs';
11
11
  import { createAbortController } from '../../../../shared/abort-controller.mjs';
12
+ import { estimateJsonBytes } from '../../../../shared/json-metrics.mjs';
12
13
  import { logLlmCall } from '../../../../shared/llm/usage-log.mjs';
13
14
  import { appendAgentTrace } from '../../agent-trace.mjs';
14
15
  import { recordStandaloneStatusTelemetry } from './status-telemetry.mjs';
@@ -67,6 +68,17 @@ import {
67
68
  import { _getAgentLoop } from './runtime-loaders.mjs';
68
69
  import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
69
70
  import { recordProviderContextBaseline } from '../loop/compact-policy.mjs';
71
+ import { runAbortable, settleWithin, throwIfAborted } from '../../../../shared/abort-race.mjs';
72
+
73
+ export const DEFAULT_ASK_CLEANUP_SETTLE_MS = 2_000;
74
+
75
+ export async function settleAskCleanup(promise, { timeoutMs } = {}) {
76
+ const configured = Number(process.env.MIXDOG_ASK_CLEANUP_SETTLE_MS);
77
+ const budget = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
78
+ ? Number(timeoutMs)
79
+ : (Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_ASK_CLEANUP_SETTLE_MS);
80
+ return await settleWithin(promise, budget);
81
+ }
70
82
 
71
83
  export function persistedAssistantTranscriptMetadata(value, fallbackAt = Date.now()) {
72
84
  if (!value || typeof value !== 'object') return null;
@@ -196,7 +208,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
196
208
  if (process.env.MIXDOG_DEBUG_AGENT) {
197
209
  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
210
  }
199
- const unlock = await acquireSessionLock(sessionId);
211
+ const unlock = await acquireSessionLock(sessionId, askOpts?.signal);
200
212
  // Start crash-spool hydration without delaying the user turn. A completed
201
213
  // background hydration joins the id-deduped memory drain below.
202
214
  const takeoverHydration = hydratePendingMessages(sessionId);
@@ -309,9 +321,10 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
309
321
  // computation would run detached. Capture the linked signal, install the
310
322
  // fresh controller, then re-cascade it — aborting the new controller
311
323
  // immediately when the parent already fired, or re-arming the listener.
312
- const _linkedParentSignal = runtime.parentAbortLink?.signal;
324
+ const _linkedParentSignal = askOpts?.signal || runtime.parentAbortLink?.signal;
313
325
  // Fresh controller per ask — the previous ask's controller may have aborted.
314
326
  runtime.controller = createAbortController();
327
+ const turnSignal = runtime.controller.signal;
315
328
  runtime.generation = askGeneration;
316
329
  runtime.closed = false;
317
330
  runtime.session = preSession;
@@ -469,7 +482,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
469
482
  // even to the real task. Single-turn structure with a labelled
470
483
  // `# Task` block forces the model to treat the brief as the work
471
484
  // unit, not as another piece of context to ack.
472
- const explicitPrefetchResult = await _tryBridgeExplicitPrefetch(session, explicitPrefetch);
485
+ const explicitPrefetchResult = await _tryBridgeExplicitPrefetch(session, explicitPrefetch, turnSignal);
473
486
  let _contextBlock = '';
474
487
  if (context) {
475
488
  _contextBlock += `# Additional context\n${_capCtx(context)}\n\n`;
@@ -556,7 +569,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
556
569
  const _prefetchBytes = Buffer.byteLength(explicitPrefetchResult || '', 'utf8');
557
570
  const _promptBytes = promptContentBytes(prompt);
558
571
  const _userTurnBytes = promptContentBytes(_userTurnContent);
559
- const _messagesBytes = Buffer.byteLength(JSON.stringify(historyMessages || []), 'utf8');
572
+ const _messagesBytes = estimateJsonBytes(historyMessages || []);
560
573
  const _totalBytes = _userTurnBytes + _messagesBytes;
561
574
  appendAgentTrace({
562
575
  kind: 'context',
@@ -574,7 +587,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
574
587
  },
575
588
  });
576
589
  } catch { /* trace must never break the ask path */ }
577
- const agentLoop = await _getAgentLoop();
590
+ const agentLoop = await runAbortable(turnSignal, () => _getAgentLoop());
578
591
  const _trackTextDelta = (chunk) => {
579
592
  _turnInterruption.recordTextDelta(chunk);
580
593
  _scheduleTurnCheckpoint();
@@ -766,6 +779,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
766
779
  session.toolApprovalHook = priorToolApprovalHook;
767
780
  }
768
781
  }
782
+ throwIfAborted(turnSignal);
769
783
  // Post-loop validation: if closeSession() landed while we were awaiting,
770
784
  // drop the save so the tombstone on disk isn't overwritten.
771
785
  const currentRuntime = _getRuntimeEntry(sessionId);
@@ -900,7 +914,10 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
900
914
  // Unified total-prompt field. Anthropic = input+cache_read+cache_write
901
915
  // (additive); OpenAI OAuth/API/Gemini = input_tokens already includes the
902
916
  // cached portion (inclusive), so the fallback must not double-count.
903
- const { isInclusiveProvider, computeCostUsd } = await import('../../../../shared/llm/cost.mjs');
917
+ const { isInclusiveProvider, computeCostUsd } = await runAbortable(
918
+ turnSignal,
919
+ () => import('../../../../shared/llm/cost.mjs'),
920
+ );
904
921
  const inclusive = isInclusiveProvider(session.provider);
905
922
  const promptTokens = typeof result.usage.promptTokens === 'number'
906
923
  ? result.usage.promptTokens
@@ -1042,15 +1059,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
1042
1059
  }
1043
1060
  try {
1044
1061
  const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
1045
- if (finalized.responsePreserved) {
1046
- await finalizePendingMessageDelivery(
1062
+ const cleanup = finalized.responsePreserved
1063
+ ? finalizePendingMessageDelivery(
1047
1064
  activeSession, _turnPendingEntries, durableSave,
1048
1065
  () => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
1049
- );
1050
- } else {
1051
- await durableSave;
1052
- }
1053
- clearTurnCheckpoint(sessionId, _turnCheckpointToken);
1066
+ )
1067
+ : durableSave;
1068
+ const cleanupResult = await settleAskCleanup(cleanup);
1069
+ if (cleanupResult.settled) clearTurnCheckpoint(sessionId, _turnCheckpointToken);
1054
1070
  } catch { /* cancellation cleanup is best-effort */ }
1055
1071
  if (currentRuntime) currentRuntime.session = activeSession;
1056
1072
  } else releasePendingMessages(sessionId, _turnPendingEntries);
@@ -1088,35 +1104,34 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
1088
1104
  else releasePendingMessages(sessionId, _turnPendingEntries);
1089
1105
  try {
1090
1106
  const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
1091
- if (finalized.responsePreserved) {
1092
- await finalizePendingMessageDelivery(
1107
+ const cleanup = finalized.responsePreserved
1108
+ ? finalizePendingMessageDelivery(
1093
1109
  activeSession, _turnPendingEntries, durableSave,
1094
1110
  () => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
1095
- );
1096
- } else {
1097
- await durableSave;
1098
- }
1099
- _errorStateDurable = true;
1111
+ )
1112
+ : durableSave;
1113
+ _errorStateDurable = (await settleAskCleanup(cleanup)).settled;
1100
1114
  } catch { /* provider-failure history persistence is best-effort */ }
1101
1115
  const currentRuntime = _getRuntimeEntry(sessionId);
1102
1116
  if (currentRuntime) currentRuntime.session = activeSession;
1103
1117
  } else {
1104
- const promptPersisted = await persistCompactedOutgoingAfterAskFailure({
1118
+ const compactPersist = await settleAskCleanup(persistCompactedOutgoingAfterAskFailure({
1105
1119
  sessionId,
1106
1120
  activeSession,
1107
1121
  askGeneration,
1108
1122
  turnOutgoing: _turnOutgoing,
1109
1123
  error: err,
1110
- });
1124
+ }));
1125
+ const promptPersisted = compactPersist.settled && compactPersist.value === true;
1111
1126
  if (promptPersisted) {
1112
1127
  recordPendingMessageDelivery(activeSession, _turnPendingEntries);
1113
1128
  try {
1114
1129
  const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
1115
- await finalizePendingMessageDelivery(
1130
+ const cleanup = finalizePendingMessageDelivery(
1116
1131
  activeSession, _turnPendingEntries, durableSave,
1117
1132
  () => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
1118
1133
  );
1119
- _errorStateDurable = true;
1134
+ _errorStateDurable = (await settleAskCleanup(cleanup)).settled;
1120
1135
  } catch {}
1121
1136
  } else {
1122
1137
  releasePendingMessages(sessionId, _turnPendingEntries);
@@ -1124,8 +1139,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
1124
1139
  }
1125
1140
  if (!_errorStateDurable && activeSession) {
1126
1141
  try {
1127
- await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
1128
- _errorStateDurable = true;
1142
+ _errorStateDurable = (await settleAskCleanup(
1143
+ saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
1144
+ )).settled;
1129
1145
  } catch { /* retain checkpoint when canonical persistence fails */ }
1130
1146
  }
1131
1147
  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
@@ -355,6 +355,9 @@ function normalizePendingMessageEntry(entry) {
355
355
  id: pendingMessageId(entry),
356
356
  enqueuedAt: Number(entry.enqueuedAt) || Date.now(),
357
357
  };
358
+ const entryOptions = entry.options && typeof entry.options === 'object'
359
+ ? { options: entry.options }
360
+ : {};
358
361
  const marker = entry.notificationKind === COMPLETION_NOTIFICATION_KIND
359
362
  ? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Number(entry.enqueuedAt) || Date.now() }
360
363
  : null;
@@ -366,13 +369,13 @@ function normalizePendingMessageEntry(entry) {
366
369
  if (content == null) return null;
367
370
  const text = typeof entry.text === 'string' ? entry.text.trim() : promptContentText(content).trim();
368
371
  let out = null;
369
- if (Array.isArray(content)) out = content.length > 0 ? { content, text } : null;
372
+ if (Array.isArray(content)) out = content.length > 0 ? { content, text, ...entryOptions } : null;
370
373
  else if (typeof content === 'string') {
371
374
  const value = content.trim();
372
- out = value ? { content: value, text: text || value } : null;
375
+ out = value ? { content: value, text: text || value, ...entryOptions } : null;
373
376
  } else {
374
377
  const fallback = promptContentText(content).trim();
375
- out = fallback ? { content: fallback, text: text || fallback } : null;
378
+ out = fallback ? { content: fallback, text: text || fallback, ...entryOptions } : null;
376
379
  }
377
380
  if (!out) return null;
378
381
  // The lifecycle token rides along every derived copy (see carryLifecycleToken).
@@ -394,7 +397,12 @@ function pendingMessageQueueEntry(entry) {
394
397
  const marker = isCompletionNotificationEntry(normalized)
395
398
  ? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: normalized.enqueuedAt }
396
399
  : null;
397
- const base = { ...identity, content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
400
+ const base = {
401
+ ...identity,
402
+ content: normalized.content,
403
+ text: normalized.text || promptContentText(normalized.content).trim(),
404
+ ...(normalized.options ? { options: normalized.options } : {}),
405
+ };
398
406
  return carryLifecycleToken(marker ? { ...base, ...marker } : base, entry);
399
407
  }
400
408
 
@@ -427,6 +435,20 @@ function normalizePersistedEntry(entry, options = {}) {
427
435
  const message = entry.message.trim();
428
436
  return message ? { id, message, enqueuedAt } : null;
429
437
  }
438
+ if (Object.prototype.hasOwnProperty.call(entry, 'content')) {
439
+ const normalized = normalizePendingMessageEntry(entry);
440
+ if (!normalized) return null;
441
+ if (typeof normalized.content === 'string' && !normalized.options) {
442
+ return { id, message: normalized.text || normalized.content, enqueuedAt };
443
+ }
444
+ return {
445
+ id,
446
+ content: normalized.content,
447
+ text: normalized.text,
448
+ ...(normalized.options ? { options: normalized.options } : {}),
449
+ enqueuedAt,
450
+ };
451
+ }
430
452
  const t = pendingMessageText(entry);
431
453
  return t ? { id, message: t, enqueuedAt } : null;
432
454
  }
@@ -1195,10 +1217,26 @@ async function _flushForeignDrainBatch() {
1195
1217
  && !isCompletionNotificationEntry(entry)
1196
1218
  && !isLegacyUnmarkedCompletionNotification(text)
1197
1219
  && text && !isInternalRuntimeNotificationText(text);
1220
+ const normalized = normalizePendingMessageEntry(entry);
1221
+ const structured = Array.isArray(normalized?.content) || Boolean(normalized?.options);
1198
1222
  if (foreignUser && isStaleUserInjection(entry)) {
1199
- taken.push({ text: lateDeliveryText(text, entry), id });
1223
+ const lateText = lateDeliveryText(text, entry);
1224
+ const content = Array.isArray(normalized?.content)
1225
+ ? [{ type: 'text', text: `${lateText.slice(0, lateText.length - text.length)}` }, ...normalized.content]
1226
+ : lateText;
1227
+ taken.push({
1228
+ ...(structured ? { content } : {}),
1229
+ text: lateText,
1230
+ id,
1231
+ ...(normalized?.options ? { options: normalized.options } : {}),
1232
+ });
1200
1233
  } else if (foreignUser) {
1201
- taken.push({ text, id });
1234
+ taken.push({
1235
+ ...(structured ? { content: normalized?.content ?? text } : {}),
1236
+ text,
1237
+ id,
1238
+ ...(normalized?.options ? { options: normalized.options } : {}),
1239
+ });
1202
1240
  } else {
1203
1241
  kept.push(entry);
1204
1242
  }
@@ -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;
@@ -4,13 +4,14 @@ import { existsSync, readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { isInternalRuntimeNotificationText as contractIsInternalRuntimeNotificationText } from '../../../../shared/tool-execution-contract.mjs';
6
6
  import { SUMMARY_PREFIX } from '../compact.mjs';
7
+ import { attachmentTextForPart, isAttachmentReference } from '../../../../attachments/store.mjs';
7
8
 
8
9
  export function promptContentText(content) {
9
10
  if (typeof content === 'string') return content;
10
11
  if (Array.isArray(content)) {
11
12
  return content.map((part) => {
12
13
  if (typeof part === 'string') return part;
13
- if (part?.type === 'text') return part.text || '';
14
+ if (part?.type === 'text') return isAttachmentReference(part) ? attachmentTextForPart(part) : (part.text || '');
14
15
  if (part?.type === 'image') return '[Image]';
15
16
  return part?.text || '';
16
17
  }).filter(Boolean).join('\n');
@@ -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
  }
@@ -4,12 +4,9 @@ import { renameWithRetrySync } from '../../../../shared/atomic-file.mjs';
4
4
  import { sanitizeContentForStoredHistory } from '../../providers/media-normalization.mjs';
5
5
  import { readTopLevelLifecycleRecord, isLifecycleUnreadable } from '../lifecycle-scan.mjs';
6
6
 
7
- // The live in-memory session (and every model request)
8
- // retains attached image bytes across turns so multi-turn recognition works.
9
- // The persisted session JSON, however, replaces image content with a short
10
- // text placeholder at serialization time — keeping session files small without
11
- // starving the model of the image mid-conversation. Returns the same object
12
- // reference when nothing changed (no-image sessions pay only a shallow scan).
7
+ // Inline legacy media is replaced with placeholders on disk. New prompt media
8
+ // already arrives as durable content-addressed refs, so session JSON stays small
9
+ // while provider lowering can still resolve it across turns.
13
10
  // Per-message disk projection shared by _sessionForDisk and the save-worker
14
11
  // delta path (tail-only projection). Returns the SAME array reference when no
15
12
  // message changed. Idempotent: already-sanitized content passes through
@@ -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
  }