mixdog 0.9.95 → 0.9.96

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 (178) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/package.json +12 -9
  6. package/scripts/.tmp-coverage-report.mjs +96 -0
  7. package/scripts/build-tui.mjs +17 -1
  8. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  9. package/src/defaults/mixdog-config.template.json +5 -8
  10. package/src/rules/agent/30-explorer.md +7 -4
  11. package/src/rules/shared/01-tool.md +20 -29
  12. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  14. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  16. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  17. package/src/runtime/agent/orchestrator/mcp/client.mjs +26 -11
  18. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  19. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  20. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  22. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  23. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  25. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  26. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  29. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  30. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  31. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  32. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -4
  34. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -33
  35. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  36. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  38. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
  39. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +5 -0
  41. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  42. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  44. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  45. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  46. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  47. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  48. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
  49. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -33
  50. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  51. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +118 -18
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  56. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
  63. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +23 -9
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  66. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  70. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +87 -15
  73. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
  74. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  76. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  77. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +29 -1
  78. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  79. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  80. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +10 -10
  81. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -8
  82. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  83. package/src/runtime/channels/lib/config.mjs +2 -2
  84. package/src/runtime/channels/lib/inbound-handler.mjs +1 -1
  85. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  86. package/src/runtime/memory/index.mjs +5 -38
  87. package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
  88. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  89. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  90. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
  91. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  92. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  93. package/src/runtime/memory/lib/http-router.mjs +8 -0
  94. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  95. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  96. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  97. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  98. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  99. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  100. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  101. package/src/runtime/memory/tool-defs.mjs +1 -5
  102. package/src/runtime/search/tool-defs.mjs +2 -2
  103. package/src/runtime/shared/agent-route-config.mjs +119 -0
  104. package/src/runtime/shared/atomic-file.mjs +80 -4
  105. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  106. package/src/runtime/shared/child-guardian.mjs +204 -108
  107. package/src/runtime/shared/child-spawn-gate.mjs +60 -31
  108. package/src/runtime/shared/config.mjs +130 -44
  109. package/src/runtime/shared/resource-admission.mjs +146 -97
  110. package/src/runtime/shared/stream-progress.mjs +6 -0
  111. package/src/runtime/shared/tool-surface.mjs +0 -5
  112. package/src/runtime/shared/turn-snapshot.mjs +31 -5
  113. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  114. package/src/session-runtime/config-helpers.mjs +39 -37
  115. package/src/session-runtime/model-route-api.mjs +7 -5
  116. package/src/session-runtime/provider-auth-api.mjs +0 -7
  117. package/src/session-runtime/runtime-core.mjs +11 -11
  118. package/src/session-runtime/runtime-tunables.mjs +4 -4
  119. package/src/session-runtime/session-lifecycle.mjs +9 -2
  120. package/src/session-runtime/session-turn-api.mjs +41 -9
  121. package/src/session-runtime/settings-api.mjs +5 -17
  122. package/src/session-runtime/tool-defs.mjs +3 -3
  123. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  124. package/src/session-runtime/workflow.mjs +14 -24
  125. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  126. package/src/standalone/agent-tool/helpers.mjs +2 -2
  127. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  128. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  129. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  130. package/src/standalone/backend-daemon.mjs +97 -36
  131. package/src/standalone/channel-admin.mjs +12 -12
  132. package/src/standalone/channel-daemon-client.mjs +17 -1
  133. package/src/standalone/channel-daemon-transport.mjs +90 -8
  134. package/src/standalone/engine-daemon-client.mjs +380 -205
  135. package/src/standalone/engine-daemon-protocol.mjs +3 -4
  136. package/src/standalone/engine-daemon-service.mjs +378 -134
  137. package/src/standalone/engine-daemon-transport.mjs +268 -77
  138. package/src/standalone/explore-tool.mjs +187 -70
  139. package/src/standalone/fair-call-scheduler.mjs +264 -0
  140. package/src/standalone/session-protocol.mjs +188 -0
  141. package/src/tui/App.jsx +30 -17
  142. package/src/tui/app/app-view.jsx +5 -2
  143. package/src/tui/app/core-memory-picker.mjs +19 -19
  144. package/src/tui/app/doctor.mjs +5 -11
  145. package/src/tui/app/message-selector.mjs +103 -0
  146. package/src/tui/app/onboarding-steps.mjs +1 -2
  147. package/src/tui/app/project-picker.mjs +80 -56
  148. package/src/tui/app/prompt-submit.mjs +46 -41
  149. package/src/tui/app/resume-picker.mjs +2 -2
  150. package/src/tui/app/route-pickers.mjs +3 -1
  151. package/src/tui/app/settings-picker.mjs +6 -8
  152. package/src/tui/app/transcript-window.mjs +38 -0
  153. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  154. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  155. package/src/tui/app/use-prompt-hint.mjs +3 -2
  156. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  157. package/src/tui/app/use-transcript-scroll.mjs +9 -2
  158. package/src/tui/app/use-transcript-window.mjs +50 -17
  159. package/src/tui/components/PromptInput.jsx +71 -38
  160. package/src/tui/components/Spinner.jsx +1 -1
  161. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  162. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  163. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  164. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  165. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  166. package/src/tui/dist/index.mjs +920 -1592
  167. package/src/tui/engine/live-share.mjs +34 -0
  168. package/src/tui/engine/session-api-ext.mjs +1 -4
  169. package/src/tui/engine/session-api.mjs +103 -33
  170. package/src/tui/engine/session-flow.mjs +7 -1
  171. package/src/tui/engine/tool-card-results.mjs +4 -2
  172. package/src/tui/engine/tool-result-text.mjs +10 -0
  173. package/src/tui/engine/turn.mjs +9 -69
  174. package/src/tui/engine-local-session.mjs +47 -31
  175. package/src/tui/index.jsx +1 -2
  176. package/src/tui/spinner-meta.mjs +4 -4
  177. package/src/tui/spinner-verbs.mjs +51 -3
  178. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
@@ -1,7 +1,8 @@
1
1
  // Steering / pending-message queue with sync buffered + atomic-file persistence.
2
2
  // Extracted verbatim from manager.mjs (behavior-preserving).
3
3
  import { join } from 'path';
4
- import { readFileSync, statSync } from 'fs';
4
+ import { readFileSync } from 'fs';
5
+ import { stat } from 'fs/promises';
5
6
  import { createHash, randomBytes } from 'crypto';
6
7
  import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
7
8
  import { updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
@@ -20,8 +21,8 @@ const PENDING_ORPHAN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
21
  const PENDING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
21
22
  // Replay window for genuine user/steering entries. A cross-surface submit is
22
23
  // meant for a LIVE owner; entries that predate this process and exceeded the
23
- // window are still DELIVERED (CC parity, reference messageQueueManager.ts:
24
- // queued user input is never silently discarded), but annotated with an
24
+ // window are still DELIVERED (queued user input is never silently
25
+ // discarded), but annotated with an
25
26
  // explicit late-delivery header so a session resumed hours later reads them
26
27
  // as clearly-late input instead of a surprise self-injection.
27
28
  const STALE_USER_INJECTION_TTL_MS = 30 * 60 * 1000;
@@ -44,7 +45,7 @@ function isStaleUserInjection(entry, now = Date.now()) {
44
45
  return (now - enqueuedAt) > STALE_USER_INJECTION_TTL_MS;
45
46
  }
46
47
 
47
- // CC parity: never silently discard queued user input. A stale entry is
48
+ // Never silently discard queued user input. A stale entry is
48
49
  // delivered with this explicit age-annotated header so neither the user nor
49
50
  // the model mistakes it for fresh input.
50
51
  function lateDeliveryText(text, entry, now = Date.now()) {
@@ -823,7 +824,7 @@ export function hydratePendingMessages(sessionId, options = {}) {
823
824
  if (!id || inDelivery.has(id) || acked.has(id)) return false;
824
825
  return true;
825
826
  });
826
- // CC parity: stale genuine user/steering entries DELIVER with a
827
+ // Stale genuine user/steering entries DELIVER with a
827
828
  // late-delivery header instead of being silently dropped (the old
828
829
  // behavior discarded them; user report: remote/steering sends
829
830
  // silently ignored around owner restarts). Completion entries
@@ -1092,8 +1093,14 @@ export function enqueueRemotePendingMessage(sessionId, message) {
1092
1093
  // several sessions (desktop tabs, TUI + engine hosts) and a single shared
1093
1094
  // counter let the first drain of a tick swallow the mtime bump for every
1094
1095
  // other session, stranding their foreign submits until the next spool write.
1095
- const FOREIGN_SPOOL_SCAN_LIMIT = 64;
1096
+ const FOREIGN_SPOOL_SCAN_LIMIT = Math.max(
1097
+ 128,
1098
+ Number(process.env.MIXDOG_FOREIGN_SPOOL_SCAN_LIMIT) || 512,
1099
+ );
1096
1100
  const _foreignSpoolScanMtimes = new Map();
1101
+ const _foreignDrainRequests = new Map();
1102
+ let _foreignDrainScheduled = false;
1103
+ let _foreignDrainRunning = false;
1097
1104
 
1098
1105
  function _rememberForeignSpoolScan(sessionId, mtime) {
1099
1106
  _foreignSpoolScanMtimes.delete(sessionId);
@@ -1105,28 +1112,7 @@ function _rememberForeignSpoolScan(sessionId, mtime) {
1105
1112
  }
1106
1113
  }
1107
1114
 
1108
- /**
1109
- * Owner-side drain of FOREIGN user injections for a session this process
1110
- * owns: atomically removes (and returns the text of) genuine user/steering
1111
- * entries that were persisted by ANOTHER process — entries known locally
1112
- * (own steering buffers, hydrated, in-delivery, acked) and completion/
1113
- * internal-notification entries are left untouched for the normal
1114
- * askSession hydrate path.
1115
- */
1116
- export async function drainForeignUserInjections(sessionId) {
1117
- if (!isValidPendingSessionId(sessionId)) return [];
1118
- // DELIVERY path, not cleanup: this takes rows out of the spool and hands
1119
- // them to a live turn. A tombstoned (or otherwise invalidated) session may
1120
- // neither receive nor lose input, so the tombstone-TOLERANT epoch check
1121
- // used by the explicit cleanup mutations (ack / clear) is wrong here — the
1122
- // token is refused outright when it was captured from a closed session.
1123
- // Checked BEFORE the mtime memo so a later reopen still sees this spool
1124
- // state as unscanned.
1125
- const epochToken = currentPendingLifecycleToken(sessionId);
1126
- if (pendingLifecycleInvalidated(sessionId)) return [];
1127
- let mtime = 0;
1128
- try { mtime = statSync(pendingMessagesPath()).mtimeMs || 0; } catch { return []; }
1129
- if (_foreignSpoolScanMtimes.get(sessionId) === mtime) return [];
1115
+ function _foreignLocalIds(sessionId) {
1130
1116
  const localIds = new Set();
1131
1117
  for (const map of [_sessionPendingMessages, _pendingPersistBuffers, _hydratedPendingMessages]) {
1132
1118
  for (const entry of map.get(sessionId) || []) {
@@ -1146,59 +1132,86 @@ export async function drainForeignUserInjections(sessionId) {
1146
1132
  if (typeof id === 'string' && id) localIds.add(id);
1147
1133
  }
1148
1134
  } catch { /* ledger unavailable — in-memory sets still guard */ }
1149
- const taken = [];
1150
- // The mtime memo may ONLY be armed by a scan that actually reached a
1151
- // lifecycle-valid decision under the spool lock. Arming it upfront meant a
1152
- // drain refused in-lock (a close/detach/reopen landing in the window) still
1153
- // recorded this spool state as "seen": after the reopen the new owner
1154
- // polled the same unchanged mtime and skipped it, stranding the rows until
1155
- // some unrelated spool WRITE bumped the file. A refusal must leave the
1156
- // spool immediately pollable again.
1157
- let lifecycleDecided = false;
1135
+ return localIds;
1136
+ }
1137
+
1138
+ function _settleForeignDrain(request, value) {
1139
+ for (const resolve of request.waiters) {
1140
+ try { resolve(value); } catch { /* a consumer cannot break the batch */ }
1141
+ }
1142
+ }
1143
+
1144
+ function _scheduleForeignDrainBatch() {
1145
+ if (_foreignDrainScheduled || _foreignDrainRunning || _foreignDrainRequests.size === 0) return;
1146
+ _foreignDrainScheduled = true;
1147
+ setImmediate(() => {
1148
+ _foreignDrainScheduled = false;
1149
+ void _flushForeignDrainBatch();
1150
+ });
1151
+ }
1152
+
1153
+ async function _flushForeignDrainBatch() {
1154
+ if (_foreignDrainRunning) return;
1155
+ _foreignDrainRunning = true;
1156
+ const batch = [..._foreignDrainRequests.values()];
1157
+ _foreignDrainRequests.clear();
1158
1158
  try {
1159
- // ASYNC spool lock. The sync variant parked the owning process's event
1160
- // loop in Atomics.wait for the whole timeout: in the daemon (PTYs,
1161
- // engines, IPC and SSE fan-out share one loop) every poll that lost the
1162
- // race froze the entire app, and it could never win against an async
1163
- // holder in the SAME process. Waiting off the loop also lets the
1164
- // in-process lock queue serialize sibling sessions for free.
1159
+ let mtime = 0;
1160
+ try { mtime = (await stat(pendingMessagesPath())).mtimeMs || 0; }
1161
+ catch {
1162
+ for (const request of batch) _settleForeignDrain(request, []);
1163
+ return;
1164
+ }
1165
+ const candidates = batch.filter((request) =>
1166
+ _foreignSpoolScanMtimes.get(request.sessionId) !== mtime
1167
+ && !pendingLifecycleInvalidated(request.sessionId, request.epochToken));
1168
+ for (const request of batch) {
1169
+ if (!candidates.includes(request)) _settleForeignDrain(request, []);
1170
+ }
1171
+ if (candidates.length === 0) return;
1172
+ for (const request of candidates) {
1173
+ request.localIds = _foreignLocalIds(request.sessionId);
1174
+ request.taken = [];
1175
+ request.lifecycleDecided = false;
1176
+ }
1165
1177
  await updateJsonAtomic(pendingMessagesPath(), (raw) => {
1166
- // Same authority re-read INSIDE the spool lock: a close/detach
1167
- // landing in this window must neither deliver to the old owner nor
1168
- // remove the reopened owner's rows.
1169
- runPendingTestHook('foreignDrain:beforeCommit', { sessionId });
1170
- if (pendingLifecycleInvalidated(sessionId, epochToken)) return undefined;
1171
- // Authoritative from here on: whatever this pass decides (take,
1172
- // drop-stale, or "nothing foreign here") is a real answer for THIS
1173
- // spool state, so the memo may skip re-scanning it.
1174
- lifecycleDecided = true;
1175
1178
  const next = normalizePendingStore(raw);
1176
- const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
1177
- if (q.length === 0) return undefined;
1178
- const kept = [];
1179
- for (const entry of q) {
1180
- const id = pendingMessageId(entry);
1181
- const text = pendingMessageText(entry);
1182
- const foreignUser = id && !localIds.has(id)
1183
- && !isCompletionNotificationEntry(entry)
1184
- && !isLegacyUnmarkedCompletionNotification(text)
1185
- && text && !isInternalRuntimeNotificationText(text);
1186
- // CC parity: stale foreign submits still deliver, carrying a
1187
- // late-delivery header instead of being silently removed
1188
- // (user report: remote sends silently discarded around owner
1189
- // restarts).
1190
- if (foreignUser && isStaleUserInjection(entry)) {
1191
- taken.push({ text: lateDeliveryText(text, entry), id });
1179
+ let changed = false;
1180
+ for (const request of candidates) {
1181
+ const { sessionId, epochToken, localIds, taken } = request;
1182
+ // Same authority re-read INSIDE the spool lock: a close/detach
1183
+ // landing in this window must neither deliver to the old owner
1184
+ // nor remove the reopened owner's rows.
1185
+ runPendingTestHook('foreignDrain:beforeCommit', { sessionId });
1186
+ if (pendingLifecycleInvalidated(sessionId, epochToken)) continue;
1187
+ request.lifecycleDecided = true;
1188
+ const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
1189
+ if (q.length === 0) continue;
1190
+ const kept = [];
1191
+ for (const entry of q) {
1192
+ const id = pendingMessageId(entry);
1193
+ const text = pendingMessageText(entry);
1194
+ const foreignUser = id && !localIds.has(id)
1195
+ && !isCompletionNotificationEntry(entry)
1196
+ && !isLegacyUnmarkedCompletionNotification(text)
1197
+ && text && !isInternalRuntimeNotificationText(text);
1198
+ if (foreignUser && isStaleUserInjection(entry)) {
1199
+ taken.push({ text: lateDeliveryText(text, entry), id });
1200
+ } else if (foreignUser) {
1201
+ taken.push({ text, id });
1202
+ } else {
1203
+ kept.push(entry);
1204
+ }
1205
+ }
1206
+ if (taken.length === 0) continue;
1207
+ changed = true;
1208
+ if (kept.length > 0) next.sessions[sessionId] = kept;
1209
+ else {
1210
+ delete next.sessions[sessionId];
1211
+ if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
1192
1212
  }
1193
- else if (foreignUser) taken.push({ text, id });
1194
- else kept.push(entry);
1195
- }
1196
- if (taken.length === 0) return undefined;
1197
- if (kept.length > 0) next.sessions[sessionId] = kept;
1198
- else {
1199
- delete next.sessions[sessionId];
1200
- if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
1201
1213
  }
1214
+ if (!changed) return undefined;
1202
1215
  next.updatedAt = Date.now();
1203
1216
  return next;
1204
1217
  }, {
@@ -1206,18 +1219,63 @@ export async function drainForeignUserInjections(sessionId) {
1206
1219
  lock: true,
1207
1220
  mode: PENDING_MESSAGES_MODE,
1208
1221
  fsync: false,
1209
- // Delivery polling must never wait on an async spool writer in the
1210
- // same daemon process: waiting synchronously prevents that writer
1211
- // from ever reaching its finally/release path.
1222
+ // Delivery polling must never wait behind a spool writer. A later
1223
+ // fs.watch/poll tick retries unchanged requests.
1212
1224
  timeoutMs: 0,
1213
1225
  });
1226
+ for (const request of candidates) {
1227
+ if (request.lifecycleDecided) _rememberForeignSpoolScan(request.sessionId, mtime);
1228
+ _settleForeignDrain(request, request.taken);
1229
+ }
1214
1230
  } catch (err) {
1215
- if (err?.code === 'ELOCKCONTENDED') return [];
1216
- try { process.stderr.write(`[session] foreign-injection drain failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
1217
- return [];
1231
+ if (err?.code !== 'ELOCKCONTENDED') {
1232
+ try { process.stderr.write(`[session] foreign-injection drain failed: ${err?.message || err}\n`); } catch {}
1233
+ }
1234
+ for (const request of batch) {
1235
+ if (request.waiters.length > 0) _settleForeignDrain(request, []);
1236
+ }
1237
+ } finally {
1238
+ _foreignDrainRunning = false;
1239
+ _scheduleForeignDrainBatch();
1218
1240
  }
1219
- if (lifecycleDecided) _rememberForeignSpoolScan(sessionId, mtime);
1220
- return taken;
1241
+ }
1242
+
1243
+ /**
1244
+ * Owner-side drain of FOREIGN user injections for a session this process
1245
+ * owns: atomically removes (and returns the text of) genuine user/steering
1246
+ * entries that were persisted by ANOTHER process — entries known locally
1247
+ * (own steering buffers, hydrated, in-delivery, acked) and completion/
1248
+ * internal-notification entries are left untouched for the normal
1249
+ * askSession hydrate path.
1250
+ */
1251
+ export async function drainForeignUserInjections(sessionId) {
1252
+ if (!isValidPendingSessionId(sessionId)) return [];
1253
+ // DELIVERY path, not cleanup: this takes rows out of the spool and hands
1254
+ // them to a live turn. A tombstoned (or otherwise invalidated) session may
1255
+ // neither receive nor lose input, so the tombstone-TOLERANT epoch check
1256
+ // used by the explicit cleanup mutations (ack / clear) is wrong here — the
1257
+ // token is refused outright when it was captured from a closed session.
1258
+ // Checked BEFORE the mtime memo so a later reopen still sees this spool
1259
+ // state as unscanned.
1260
+ const epochToken = currentPendingLifecycleToken(sessionId);
1261
+ if (pendingLifecycleInvalidated(sessionId)) return [];
1262
+ return new Promise((resolve) => {
1263
+ const existing = _foreignDrainRequests.get(sessionId);
1264
+ if (existing && existing.epochToken === epochToken) {
1265
+ existing.waiters.push(resolve);
1266
+ } else {
1267
+ if (existing) _settleForeignDrain(existing, []);
1268
+ _foreignDrainRequests.set(sessionId, {
1269
+ sessionId,
1270
+ epochToken,
1271
+ waiters: [resolve],
1272
+ localIds: null,
1273
+ taken: [],
1274
+ lifecycleDecided: false,
1275
+ });
1276
+ }
1277
+ _scheduleForeignDrainBatch();
1278
+ });
1221
1279
  }
1222
1280
 
1223
1281
  export function drainPendingMessages(sessionId) {
@@ -17,6 +17,10 @@ export async function _getAgentLoop() {
17
17
  if (typeof mod.agentLoop !== 'function') throw new Error('agent loop runtime is not available');
18
18
  return mod.agentLoop;
19
19
  }
20
+ export async function prewarmAgentLoop() {
21
+ await _getAgentLoop();
22
+ return true;
23
+ }
20
24
  export function _closeBashSessionLazy(sessionId, reason) {
21
25
  if (!sessionId) return;
22
26
  _bashSessionRuntimePromise ??= import('../../tools/bash-session.mjs');
@@ -6,7 +6,7 @@ import { loadSession, saveSessionAsync, setLiveSession, evictLiveSession, listSt
6
6
  import { estimateMessagesTokens, estimateTranscriptContextUsage } from '../context-utils.mjs';
7
7
  import { normalizeCompactType, DEFAULT_COMPACT_TYPE, SUMMARY_PREFIX } from '../compact.mjs';
8
8
  import { runSessionCompaction } from './compaction-runner.mjs';
9
- import { hasUserConversationMessage } from './prompt-utils.mjs';
9
+ import { hasUserConversationMessage, promptContentText } from './prompt-utils.mjs';
10
10
  import { getProvider } from '../../providers/registry.mjs';
11
11
  import { isSessionCompactionBlocked, getSessionAbortSignal, _runtimeEntries } from './runtime-liveness.mjs';
12
12
  import { mintSessionId } from './session-id.mjs';
@@ -314,3 +314,33 @@ export async function updateSessionStatus(id, status) {
314
314
  await saveSessionAsync(session, { expectedGeneration: session.generation });
315
315
  return true;
316
316
  }
317
+
318
+ // --- Rewind to a previous user message (message selector) ---
319
+ // Claude Code's "restore conversation": the selected prompt and everything
320
+ // after it leave the model history so the user can edit and resubmit it.
321
+ // Earlier turns — including any compaction rewrite that precedes them — stay
322
+ // exactly as they are. Idle-only; the caller interrupts a live turn first.
323
+ export async function rewindSessionMessagesTo(sessionId, options = {}) {
324
+ const session = loadSession(sessionId);
325
+ if (!session || session.closed === true) return null;
326
+ const target = String(options?.text ?? '').trim();
327
+ if (!target) return null;
328
+ const messages = Array.isArray(session.messages) ? session.messages : [];
329
+ let start = -1;
330
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
331
+ const message = messages[index];
332
+ if (message?.role !== 'user') continue;
333
+ if (promptContentText(message.content).trim() !== target) continue;
334
+ start = index;
335
+ break;
336
+ }
337
+ if (start < 0) return null;
338
+ const removed = messages.length - start;
339
+ session.messages = messages.slice(0, start);
340
+ // The provider cache is keyed on the exact prefix we just truncated.
341
+ session.providerState = undefined;
342
+ session.updatedAt = Date.now();
343
+ session.lastUsedAt = Date.now();
344
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
345
+ return { removed, remaining: session.messages.length };
346
+ }
@@ -41,18 +41,25 @@ function assistantToolCallIds(message) {
41
41
  return ids;
42
42
  }
43
43
 
44
- function rewindProvisionalUserTurn(messages, currentUserContent) {
44
+ function provisionalUserTurnIndex(messages, currentUserContent) {
45
45
  const currentText = promptContentText(currentUserContent);
46
46
  for (let i = messages.length - 1; i >= 0; i -= 1) {
47
47
  const message = messages[i];
48
48
  if (message?.role !== 'user') continue;
49
49
  if (message.content === currentUserContent
50
50
  || promptContentText(message.content) === currentText) {
51
+ return i;
52
+ }
53
+ }
54
+ return -1;
55
+ }
56
+
57
+ function rewindProvisionalUserTurn(messages, currentUserContent) {
58
+ const index = provisionalUserTurnIndex(messages, currentUserContent);
59
+ if (index >= 0) {
51
60
  // Remove the provisional user turn plus pre-send hooks appended
52
61
  // after it, while retaining any compaction rewrite before it.
53
- messages.splice(i);
54
- break;
55
- }
62
+ messages.splice(index);
56
63
  }
57
64
  return messages;
58
65
  }
@@ -69,13 +76,26 @@ function finalizeInterruptedTurn({
69
76
  abortReason,
70
77
  }) {
71
78
  const messages = filterModelVisibleSessionMessages(turnOutgoing).slice();
79
+ // Null/unknown reasons keep the legacy rewind (status quo for wrapped
80
+ // aborts without a closeReason enum); named non-user reasons preserve.
81
+ const userCancelled = abortReason == null
82
+ || USER_CANCEL_ABORT_REASONS.has(abortReason);
83
+ // Claude Code parity: a turn cancelled while the model was only THINKING
84
+ // produced no model-visible message, so the provisional user turn is
85
+ // rewound and the prompt goes back to the input box. Anything the model
86
+ // actually said or did — committed messages from this turn, buffered text,
87
+ // tool calls/results — keeps the turn (reasoning alone does not).
88
+ const producedOutput = String(partialAssistantContent || '').trim() !== ''
89
+ || observedToolCalls.size > 0
90
+ || observedToolResults.size > 0
91
+ || (() => {
92
+ const index = provisionalUserTurnIndex(messages, currentUserContent);
93
+ return index >= 0 && index < messages.length - 1;
94
+ })();
72
95
  const preserveResponse = responseStarted
73
- && !isInternalRuntimeNotificationText(currentUserContent);
96
+ && !isInternalRuntimeNotificationText(currentUserContent)
97
+ && (producedOutput || !userCancelled);
74
98
  if (!preserveResponse) {
75
- // Null/unknown reasons keep the legacy rewind (status quo for wrapped
76
- // aborts without a closeReason enum); named non-user reasons preserve.
77
- const userCancelled = abortReason == null
78
- || USER_CANCEL_ABORT_REASONS.has(abortReason);
79
99
  if (!userCancelled) {
80
100
  return {
81
101
  messages,
@@ -101,6 +101,7 @@ export {
101
101
  // ── Session lifecycle / ask / crud / close / cleanup ──────────────────────
102
102
  export { SessionClosedError } from './manager/session-errors.mjs';
103
103
  export { setAgentRuntime } from './manager/agent-runtime-singleton.mjs';
104
+ export { prewarmAgentLoop } from './manager/runtime-loaders.mjs';
104
105
  export {
105
106
  createSession,
106
107
  updateSessionRoute,
@@ -120,6 +121,7 @@ export {
120
121
  listSessions,
121
122
  findSessionByScopeKey,
122
123
  clearSessionMessages,
124
+ rewindSessionMessagesTo,
123
125
  compactSessionMessages,
124
126
  updateSessionStatus,
125
127
  updateSessionGeneratedTitle,
@@ -96,3 +96,68 @@ export function isInformationalShellExitOne(result) {
96
96
  if (payload.startsWith('[stderr') || payload.includes('\n[stderr')) return false;
97
97
  return true;
98
98
  }
99
+
100
+ /**
101
+ * Shell-result outcome test. The shell tool reports failure through its OWN
102
+ * status markers (bash-tool.mjs `_shellFailureStatus`): `Error:
103
+ * [shell-run-failed] …`, `Error: [shell-tool-failed] …`, or a bare
104
+ * `[exit code: N]` / `[timeout: …]` / `[signal: …]` marker — optionally behind
105
+ * ⚠️ destructive-warning lines, which are prepended AFTER the error prefix is
106
+ * composed. A generic leading `Error:` is NOT a shell failure: it is just as
107
+ * likely the command's own stdout, which is why classifyResultKind (correct
108
+ * for tools that own the `Error:` convention) over-reports on shell output.
109
+ *
110
+ * @param {unknown} result
111
+ * @returns {boolean}
112
+ */
113
+ export function isShellFailureResult(result) {
114
+ if (typeof result !== 'string') return false;
115
+ const body = result.replace(/^(?:\s*⚠️[^\n]*\n)+/, '').trimStart();
116
+ if (/^error:\s*\[shell-(?:run|tool)-failed\]/i.test(body)) return true;
117
+ return /^\[(?:exit code:|timeout:|signal:)/i.test(body);
118
+ }
119
+
120
+ // Evidence that a command's OUTPUT reports a real failure. Used to separate a
121
+ // legitimate non-zero exit (a probe or report that prints its result and ends
122
+ // with 1) from a genuine failure that happens to write only to stdout — test
123
+ // runners, compilers and package managers all announce themselves in the text.
124
+ // Deliberately literal: only well-known failure banners count, so an unknown
125
+ // tool's report is never mislabelled a failure.
126
+ const SHELL_OUTPUT_FAILURE_EVIDENCE = [
127
+ /^not ok \d/m,
128
+ /^# fail [1-9]/m,
129
+ /\bAssertionError\b/,
130
+ /\bTraceback \(most recent call last\)/,
131
+ /^npm ERR!/m,
132
+ /\berror TS\d+\b/,
133
+ // Indented banners count too: runners print " FAIL <case>" under a header.
134
+ /^\s*FAIL(?:ED|URE)?\b/mi,
135
+ /\bFAILED[:!]/,
136
+ /^\s*✗/m,
137
+ /\b\d+ (?:tests? )?failed\b/i,
138
+ /^fatal:/m,
139
+ /^error:/mi,
140
+ /\bSyntaxError\b|\bReferenceError\b|\bTypeError\b/,
141
+ /\bCannot find module\b/,
142
+ ];
143
+ export function shellOutputReportsFailure(text) {
144
+ const body = String(text ?? '');
145
+ if (!body.trim()) return false;
146
+ return SHELL_OUTPUT_FAILURE_EVIDENCE.some((pattern) => pattern.test(body));
147
+ }
148
+
149
+ /**
150
+ * Classify a non-zero shell exit as a real failure or a legitimate report.
151
+ * Legitimate: exit code 1 exactly, no signal, NOTHING on stderr, real output
152
+ * on stdout, and no failure banner in that output. Everything else — any other
153
+ * exit code, a signal, any stderr, an empty capture, or a failure banner —
154
+ * stays a failure.
155
+ */
156
+ export function isLegitimateShellExit({ exitCode, signal, stdout, stderr } = {}) {
157
+ if (signal) return false;
158
+ if (exitCode !== 1) return false;
159
+ if (String(stderr ?? '').trim()) return false;
160
+ const out = String(stdout ?? '').trim();
161
+ if (!out || out === '(no output)') return false;
162
+ return !shellOutputReportsFailure(out);
163
+ }