mixdog 0.9.92 → 0.9.93

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 (61) hide show
  1. package/README.md +147 -51
  2. package/package.json +6 -5
  3. package/scripts/code-graph-description-contract.mjs +6 -8
  4. package/scripts/tmp-cdp-errors.mjs +41 -0
  5. package/scripts/tmp-cdp-inspect.mjs +41 -0
  6. package/scripts/tui-transcript-jitter-harness.mjs +2 -18
  7. package/src/rules/agent/00-core.md +1 -2
  8. package/src/rules/lead/01-general.md +1 -0
  9. package/src/rules/shared/01-tool.md +19 -22
  10. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +16 -3
  11. package/src/runtime/agent/orchestrator/agent-trace.mjs +17 -0
  12. package/src/runtime/agent/orchestrator/context/collect.mjs +2 -1
  13. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +9 -1
  14. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +79 -21
  15. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +40 -19
  16. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +18 -1
  17. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +44 -8
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -0
  20. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +22 -5
  21. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +35 -29
  22. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +11 -2
  23. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +5 -6
  24. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +31 -2
  25. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -0
  26. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +60 -31
  27. package/src/runtime/agent/orchestrator/session/manager.mjs +1 -1
  28. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +12 -3
  29. package/src/runtime/agent/orchestrator/session/store/listing.mjs +17 -0
  30. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +101 -0
  31. package/src/runtime/agent/orchestrator/session/store.mjs +30 -0
  32. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -23
  33. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -21
  34. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
  35. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +12 -6
  36. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -3
  37. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +47 -14
  38. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +100 -0
  39. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +6 -5
  40. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  41. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +32 -2
  42. package/src/runtime/channels/backends/discord-gateway.mjs +6 -32
  43. package/src/runtime/channels/lib/inbound-handler.mjs +19 -3
  44. package/src/runtime/channels/lib/scheduler.mjs +51 -3
  45. package/src/runtime/channels/lib/worker-main.mjs +4 -0
  46. package/src/runtime/channels/tool-defs.mjs +4 -2
  47. package/src/runtime/memory/lib/query-handlers.mjs +11 -3
  48. package/src/runtime/memory/tool-defs.mjs +5 -5
  49. package/src/runtime/shared/channel-notification-routing.mjs +8 -2
  50. package/src/runtime/shared/llm/http-agent.mjs +11 -0
  51. package/src/session-runtime/lifecycle-api.mjs +26 -1
  52. package/src/session-runtime/tool-catalog-data.mjs +5 -2
  53. package/src/session-runtime/workflow.mjs +7 -5
  54. package/src/standalone/agent-tool/tag-registry.mjs +5 -1
  55. package/src/standalone/explore-tool.mjs +1 -1
  56. package/src/tui/dist/index.mjs +68 -45
  57. package/src/tui/engine/agent-envelope.mjs +52 -3
  58. package/src/tui/engine/turn.mjs +8 -9
  59. package/src/tui/engine.mjs +18 -41
  60. package/src/workflows/solo/WORKFLOW.md +0 -6
  61. package/src/workflows/solo-bench/WORKFLOW.md +17 -0
@@ -59,6 +59,9 @@ function compactStoredToolArgString(value, key = '', opts = {}) {
59
59
  const isLong = isBody || STORED_TOOL_ARG_LONG_KEY_RE.test(key);
60
60
  const limit = isLong ? STORED_TOOL_ARG_LIMIT : Infinity;
61
61
  if (value.length <= limit) return value;
62
+ // A marker is about to replace verbatim text — report the mutation so
63
+ // sweep callers can tag the resulting prefix-cache break.
64
+ try { opts.onCompacted?.(); } catch { /* observability only */ }
62
65
  const hash = createHash('sha256').update(value).digest('hex').slice(0, 16);
63
66
  // Body markers carry the recovery instruction inline: the compaction
64
67
  // detectors only require the `[mixdog compacted ...]` shape (no ']' or
@@ -115,14 +118,19 @@ export function compactToolCallsForHistory(calls, opts = {}) {
115
118
  // contract as restoreToolCallBodyForId);
116
119
  // - a call with no result row yet (current batch / interrupted turn) is
117
120
  // left untouched.
121
+ // Returns true when at least one body was actually collapsed to a marker
122
+ // (i.e. the transcript prefix changed), so callers can tag the intentional
123
+ // cache break instead of logging an unexplained input_prefix_mismatch.
118
124
  export function compactSettledToolCallBodies(messages) {
119
- if (!Array.isArray(messages)) return;
125
+ if (!Array.isArray(messages)) return false;
120
126
  const resultKinds = new Map();
121
127
  for (const message of messages) {
122
128
  if (message?.role === 'tool' && message.toolCallId) {
123
129
  resultKinds.set(message.toolCallId, message.toolKind || 'normal');
124
130
  }
125
131
  }
132
+ let changed = false;
133
+ const sweepOpts = { onCompacted: () => { changed = true; } };
126
134
  for (const message of messages) {
127
135
  if (message?.role !== 'assistant' || !Array.isArray(message.toolCalls)) continue;
128
136
  for (const call of message.toolCalls) {
@@ -130,9 +138,10 @@ export function compactSettledToolCallBodies(messages) {
130
138
  if (!call.arguments || typeof call.arguments !== 'object') continue;
131
139
  const kind = call.id ? resultKinds.get(call.id) : undefined;
132
140
  if (kind === undefined || kind === 'error') continue;
133
- call.arguments = compactStoredToolArgValue(call.arguments);
141
+ call.arguments = compactStoredToolArgValue(call.arguments, '', 0, sweepOpts);
134
142
  }
135
143
  }
144
+ return changed;
136
145
  }
137
146
 
138
147
  // Restore retry-safe long command/script text for ONE failed tool call inside a
@@ -15,12 +15,11 @@ export function _isMutationTool(name) {
15
15
  const n = _stripMcpPrefix(name);
16
16
  return n === 'apply_patch';
17
17
  }
18
- // Side-effect-free read-only tools that stay parallel even after an earlier
19
- // ordered mutation failed in the same batch. Everything NOT in this set is
20
- // treated as ordered-gate-skippable (see _isOrderedGateSkippable): apply_patch,
21
- // shell/bash_session, write/edit-style tools, and any (non-mixdog) MCP tool
22
- // whose effects are unknown. Kept separate from _isMutationTool, which stays
23
- // apply_patch-only for epoch-mutation counting and eager-dispatch gating.
18
+ // Side-effect-free read-only tools that may eager-start across an upcoming
19
+ // apply_patch barrier and may keep running after an earlier patch fails.
20
+ // Everything NOT in this set waits for its side-effect segment and is skipped
21
+ // after a failed patch. Kept separate from _isMutationTool, which stays
22
+ // apply_patch-only for epoch counting.
24
23
  const ORDERED_GATE_SAFE_READONLY_TOOLS = new Set([
25
24
  'read',
26
25
  'find',
@@ -226,8 +226,37 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
226
226
  return result;
227
227
  }
228
228
  if (name === 'apply_patch') {
229
- const patchArgs = typeof args === 'string' ? { patch: args } : args;
230
- return executePatchTool(name, patchArgs, cwd, { sessionId: callerSessionId, toolCallId: executeOpts.toolCallId || null });
229
+ const patchArgs = typeof args === 'string' ? { patch: args } : { ...(args || {}) };
230
+ // post_shell: optional verification command executed through the normal
231
+ // one-shot shell path ONLY after the patch applies cleanly; a failed
232
+ // patch skips it. Patch text and shell output return as ONE tool
233
+ // result, so patch+verify costs a single call. Runtime-only knob:
234
+ // stripped before executePatchTool sees the args.
235
+ const postShell = typeof patchArgs.post_shell === 'string' && patchArgs.post_shell.trim()
236
+ ? patchArgs.post_shell.trim()
237
+ : null;
238
+ delete patchArgs.post_shell;
239
+ const patchResult = await executePatchTool(name, patchArgs, cwd, {
240
+ sessionId: callerSessionId,
241
+ toolCallId: executeOpts.toolCallId || null,
242
+ });
243
+ if (!postShell) return patchResult;
244
+ const patchNorm = normalizeToolEnvelope(patchResult);
245
+ const patchText = typeof patchNorm.result === 'string' ? patchNorm.result : String(patchNorm.result ?? '');
246
+ // Text-based failure detection only: legacy string returns normalize
247
+ // to explicitSuccess:false even on success, so that flag is unusable here.
248
+ const patchFailed = /^Error[\s:[]/.test(patchText.trimStart());
249
+ if (patchFailed) {
250
+ return `${patchText}\n--- post_shell skipped: patch failed ---`;
251
+ }
252
+ const shellRes = await executeBuiltinTool('shell', { command: postShell }, cwd, completionToolOpts);
253
+ const shellNorm = normalizeToolEnvelope(shellRes);
254
+ const shellText = typeof shellNorm.result === 'string' ? shellNorm.result : String(shellNorm.result ?? '');
255
+ const shellFailed = /^Error[\s:[]/.test(shellText.trimStart());
256
+ const header = shellFailed
257
+ ? '--- post_shell FAILED (patch is applied; fix and re-verify) ---'
258
+ : '--- post_shell ---';
259
+ return `${patchText}\n\n${header}\n${shellText}`;
231
260
  }
232
261
  if (isBuiltinTool(name)) {
233
262
  // clientHostPid threaded for the same per-terminal job-scope reason as
@@ -25,6 +25,7 @@ import {
25
25
  compactTypeForSession,
26
26
  } from './context-meta.mjs';
27
27
  import { resolveSemanticSummaryModel } from '../loop/compact-policy.mjs';
28
+ import { traceAgentCompact, messagePrefixHash } from '../../agent-trace.mjs';
28
29
  import { uncachedInputTokensForProvider } from './usage-metrics.mjs';
29
30
  import { pruneOffloadSession } from '../tool-result-offload.mjs';
30
31
  import { _getPendingMessagesForSession } from './pending-messages.mjs';
@@ -302,6 +303,7 @@ export async function runSessionCompaction(session, opts = {}) {
302
303
  semanticCompact: false,
303
304
  };
304
305
  const budget = targetBudgetTokens;
306
+ const compactStartedAt = Date.now();
305
307
  try { await opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
306
308
  const provider = opts.provider || getProvider(session.provider) || null;
307
309
  let compacted;
@@ -486,6 +488,31 @@ export async function runSessionCompaction(session, opts = {}) {
486
488
  lastRecallFastTrackError: recallFastTrackError?.message || null,
487
489
  lastError: compactError?.message || semanticCompactError?.message || recallFastTrackError?.message || String(compactError || semanticCompactError || recallFastTrackError || 'compact failed'),
488
490
  };
491
+ // compact_meta parity with the loop's pre-send pass: the out-of-loop
492
+ // (post-turn/manual) compaction failure was previously invisible to
493
+ // trace analytics.
494
+ traceAgentCompact({
495
+ sessionId: opts.sessionId || session.id || null,
496
+ stage: mode === 'auto' ? 'post_turn' : 'manual',
497
+ trigger: mode,
498
+ compact_type: compactType,
499
+ compact_changed: false,
500
+ before_count: messages.length,
501
+ after_count: messages.length,
502
+ context_window: positiveContextWindow(session.contextWindow) || null,
503
+ budget_tokens: boundary,
504
+ boundary_tokens: boundary,
505
+ target_budget_tokens: budget,
506
+ reserve_tokens: reserveTokens,
507
+ pressure_tokens: pressureTokens,
508
+ trigger_tokens: triggerTokens,
509
+ message_tokens_est: beforeMessageTokens,
510
+ duration_ms: Date.now() - compactStartedAt,
511
+ provider: session.provider || null,
512
+ model: session.model || null,
513
+ error: session.compaction.lastError,
514
+ error_code: 'compact_failed',
515
+ });
489
516
  return {
490
517
  changed: false,
491
518
  error: session.compaction.lastError,
@@ -574,6 +601,39 @@ export async function runSessionCompaction(session, opts = {}) {
574
601
  compactCount: (session.compaction?.compactCount || 0) + (changed ? 1 : 0),
575
602
  };
576
603
  if (changed) invalidateProviderContextBaseline(session);
604
+ // Observability parity with the loop's pre-send pass: record the
605
+ // out-of-loop mutation as compact_meta and park a one-shot intent so the
606
+ // next turn's first send tags its cache break instead of logging an
607
+ // unexplained input_prefix_mismatch (observed live: a 403k→10k post-turn
608
+ // compact traced as intentional_transition: null with no compact_meta).
609
+ let beforePrefixHash = null;
610
+ try { beforePrefixHash = messagePrefixHash(messages); } catch { /* best-effort */ }
611
+ traceAgentCompact({
612
+ sessionId: pruneSessionId || null,
613
+ stage: mode === 'auto' ? 'post_turn' : 'manual',
614
+ trigger: mode,
615
+ compact_type: compactType,
616
+ compact_changed: changed,
617
+ input_prefix_hash: beforePrefixHash,
618
+ before_count: messages.length,
619
+ after_count: compacted.length,
620
+ before_bytes: beforeEncoded ? Buffer.byteLength(beforeEncoded, 'utf8') : null,
621
+ after_bytes: afterEncoded ? Buffer.byteLength(afterEncoded, 'utf8') : null,
622
+ context_window: positiveContextWindow(session.contextWindow) || null,
623
+ budget_tokens: boundary,
624
+ boundary_tokens: boundary,
625
+ target_budget_tokens: budget,
626
+ reserve_tokens: reserveTokens,
627
+ pressure_tokens: pressureTokens,
628
+ trigger_tokens: triggerTokens,
629
+ message_tokens_est: beforeMessageTokens,
630
+ duration_ms: Date.now() - compactStartedAt,
631
+ provider: session.provider || null,
632
+ model: session.model || null,
633
+ });
634
+ if (changed) {
635
+ session.pendingCacheBreakIntent = mode === 'auto' ? 'post_turn_compaction' : 'manual_compaction';
636
+ }
577
637
  return {
578
638
  changed,
579
639
  reason: unchangedReason,
@@ -19,16 +19,41 @@ const PENDING_MESSAGES_MODE = 0o600;
19
19
  const PENDING_ORPHAN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
20
  const PENDING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
21
21
  // Replay window for genuine user/steering entries. A cross-surface submit is
22
- // meant for a LIVE owner; if nothing picked it up within this window, firing
23
- // it into a session resumed hours or days later reads as a surprise
24
- // self-injection (user report: stale spool messages replayed on re-entry).
25
- // Crash+restart recovery within the window still replays normally.
22
+ // 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
25
+ // explicit late-delivery header so a session resumed hours later reads them
26
+ // as clearly-late input instead of a surprise self-injection.
26
27
  const STALE_USER_INJECTION_TTL_MS = 30 * 60 * 1000;
27
28
 
29
+ // Ownership epoch for the staleness gate below: entries enqueued while THIS
30
+ // process was already alive were aimed at a live owner that still exists.
31
+ const _PENDING_PROCESS_START_MS = Date.now();
32
+
28
33
  function isStaleUserInjection(entry, now = Date.now()) {
29
34
  if (isCompletionNotificationEntry(entry)) return false;
30
35
  const enqueuedAt = Number(entry?.enqueuedAt) || 0;
31
- return enqueuedAt > 0 && (now - enqueuedAt) > STALE_USER_INJECTION_TTL_MS;
36
+ if (enqueuedAt <= 0) return false;
37
+ // A submit that arrived AFTER this owner process booted is CURRENT input
38
+ // the owner was merely too busy to take yet (observed: remote sends
39
+ // silently discarded after 30m while the owner ground through long
40
+ // turns). It must deliver regardless of age. The stale window only
41
+ // guards entries that PREDATE this process — the resumed-session replay
42
+ // case the TTL was built for (surprise self-injection on re-entry).
43
+ if (enqueuedAt >= _PENDING_PROCESS_START_MS) return false;
44
+ return (now - enqueuedAt) > STALE_USER_INJECTION_TTL_MS;
45
+ }
46
+
47
+ // CC parity: never silently discard queued user input. A stale entry is
48
+ // delivered with this explicit age-annotated header so neither the user nor
49
+ // the model mistakes it for fresh input.
50
+ function lateDeliveryText(text, entry, now = Date.now()) {
51
+ const value = String(text ?? '');
52
+ if (!value.trim()) return value;
53
+ const enqueuedAt = Number(entry?.enqueuedAt) || 0;
54
+ const ageMinutes = Math.max(1, Math.round((now - enqueuedAt) / 60000));
55
+ const age = ageMinutes >= 120 ? `~${Math.round(ageMinutes / 60)}h` : `~${ageMinutes}m`;
56
+ return `[late delivery: queued ${age} ago, before the current session owner started]\n${value}`;
32
57
  }
33
58
  // Marker for deferred agent/tool *completion* notifications. Such entries must
34
59
  // never be replayed into a later turn on session resume (out-of-order delivery
@@ -765,7 +790,6 @@ export function hydratePendingMessages(sessionId, options = {}) {
765
790
  let hydrated = [];
766
791
  let alreadyDelivered = [];
767
792
  let staleLedgerEntries = [];
768
- const staleUserEntries = [];
769
793
  const ledgerSession = loadSession(sessionId);
770
794
  // Durable lifecycle epoch this hydration started under. Publishing claims the
771
795
  // durable entries into THIS process's memory, so it must be revalidated
@@ -797,16 +821,19 @@ export function hydratePendingMessages(sessionId, options = {}) {
797
821
  return false;
798
822
  }
799
823
  if (!id || inDelivery.has(id) || acked.has(id)) return false;
800
- // Stale genuine user/steering entries must not surprise-inject
801
- // into a session resumed long after they were queued (user:
802
- // "re-entering the session suddenly injected old messages").
803
- // Completion entries keep their own resume-drop policy.
804
- if (isStaleUserInjection(entry)) {
805
- staleUserEntries.push(entry);
806
- return false;
807
- }
808
824
  return true;
809
825
  });
826
+ // CC parity: stale genuine user/steering entries DELIVER with a
827
+ // late-delivery header instead of being silently dropped (the old
828
+ // behavior discarded them; user report: remote/steering sends
829
+ // silently ignored around owner restarts). Completion entries
830
+ // keep their own resume-drop policy (drain discards them).
831
+ for (const entry of hydrated) {
832
+ if (!isStaleUserInjection(entry)) continue;
833
+ if (typeof entry.message === 'string') {
834
+ entry.message = lateDeliveryText(entry.message, entry);
835
+ }
836
+ }
810
837
  // Read-only claim: durable data remains until successful delivery
811
838
  // acknowledges these exact ids. A crash here therefore redelivers.
812
839
  return undefined;
@@ -828,13 +855,6 @@ export function hydratePendingMessages(sessionId, options = {}) {
828
855
  runPendingTestHook('hydrate:betweenCleanups', { sessionId });
829
856
  if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
830
857
  }
831
- if (staleUserEntries.length > 0) {
832
- try { process.stderr.write(`[session] dropped ${staleUserEntries.length} stale queued message(s) (older than ${Math.round(STALE_USER_INJECTION_TTL_MS / 60000)}m) sessionId=${sessionId}\n`); } catch {}
833
- const cleaned = await acknowledgePendingMessages(sessionId, staleUserEntries, { expectedToken: startToken });
834
- if (cleaned) cleanupConfirmed.push(...staleUserEntries);
835
- runPendingTestHook('hydrate:betweenCleanups', { sessionId });
836
- if (pendingLifecycleInvalidated(sessionId, startToken)) return 0;
837
- }
838
858
  if (cleanupConfirmed.length > 0) {
839
859
  try {
840
860
  // One session save prunes both IDs whose replay spool was removed
@@ -968,7 +988,19 @@ function modelVisiblePendingMessages(messages) {
968
988
  }
969
989
 
970
990
  export function _mergePendingMessageEntries(entries) {
971
- const normalized = (Array.isArray(entries) ? entries : [])
991
+ // CC-parity delivery priority (reference messageQueueManager.ts: user
992
+ // input enqueues at 'next', task notifications at 'later', and dequeue
993
+ // always serves 'next' first). Our single merged turn message is the
994
+ // analogue of that dequeue order: genuine user/steering entries are
995
+ // merged BEFORE deferred completion notifications so queued user input
996
+ // is never buried under system notification text. FIFO is preserved
997
+ // within each group (stable partition).
998
+ const source = Array.isArray(entries) ? entries : [];
999
+ const ordered = [
1000
+ ...source.filter((entry) => !isCompletionNotificationEntry(entry)),
1001
+ ...source.filter(isCompletionNotificationEntry),
1002
+ ];
1003
+ const normalized = ordered
972
1004
  .map(normalizePendingMessageEntry)
973
1005
  .filter(Boolean);
974
1006
  if (normalized.length === 0) return null;
@@ -1106,7 +1138,6 @@ export function drainForeignUserInjections(sessionId) {
1106
1138
  }
1107
1139
  } catch { /* ledger unavailable — in-memory sets still guard */ }
1108
1140
  const taken = [];
1109
- let droppedStale = 0;
1110
1141
  // The mtime memo may ONLY be armed by a scan that actually reached a
1111
1142
  // lifecycle-valid decision under the spool lock. Arming it upfront meant a
1112
1143
  // drain refused in-lock (a close/detach/reopen landing in the window) still
@@ -1137,14 +1168,15 @@ export function drainForeignUserInjections(sessionId) {
1137
1168
  && !isCompletionNotificationEntry(entry)
1138
1169
  && !isLegacyUnmarkedCompletionNotification(text)
1139
1170
  && text && !isInternalRuntimeNotificationText(text);
1140
- // Stale foreign submits are removed WITHOUT injecting: they
1141
- // were aimed at a live owner that no longer exists (user:
1142
- // stale spool messages fired on session re-entry days later).
1143
- if (foreignUser && isStaleUserInjection(entry)) droppedStale += 1;
1171
+ // CC parity: stale foreign submits still deliver, carrying a
1172
+ // late-delivery header instead of being silently removed
1173
+ // (user report: remote sends silently discarded around owner
1174
+ // restarts).
1175
+ if (foreignUser && isStaleUserInjection(entry)) taken.push(lateDeliveryText(text, entry));
1144
1176
  else if (foreignUser) taken.push(text);
1145
1177
  else kept.push(entry);
1146
1178
  }
1147
- if (taken.length === 0 && droppedStale === 0) return undefined;
1179
+ if (taken.length === 0) return undefined;
1148
1180
  if (kept.length > 0) next.sessions[sessionId] = kept;
1149
1181
  else {
1150
1182
  delete next.sessions[sessionId];
@@ -1158,9 +1190,6 @@ export function drainForeignUserInjections(sessionId) {
1158
1190
  return [];
1159
1191
  }
1160
1192
  if (lifecycleDecided) _rememberForeignSpoolScan(sessionId, mtime);
1161
- if (droppedStale > 0) {
1162
- try { process.stderr.write(`[session] dropped ${droppedStale} stale foreign submit(s) (older than ${Math.round(STALE_USER_INJECTION_TTL_MS / 60000)}m) sessionId=${sessionId}\n`); } catch {}
1163
- }
1164
1193
  return taken;
1165
1194
  }
1166
1195
 
@@ -126,7 +126,7 @@ export {
126
126
  updateSessionManualTitle,
127
127
  flushSessionMetrics,
128
128
  } from './manager/session-crud.mjs';
129
- export { deleteSession } from './store.mjs';
129
+ export { deleteSession, listOwnedAgentSessionIds } from './store.mjs';
130
130
  // Read-only parsed-session access (desktop pane peek): no resume, no
131
131
  // ownership, no liveness side effects.
132
132
  export { loadSession } from './store.mjs';
@@ -211,9 +211,13 @@ export async function sendWithRecovery(ctx) {
211
211
  // acknowledgement replays on a fresh request instead of
212
212
  // failing the turn. Non-acked (or tool-bearing) shapes keep
213
213
  // the explicit-failure contract below unchanged.
214
+ // NOTE: no classifyError gate here — an exposed stall is
215
+ // stamped unsafeToRetry, which the general classifier reads as
216
+ // terminal, but that unsafety is exactly what the retraction
217
+ // removes (observed live: make-mips-interpreter failed twice
218
+ // because this guard demanded 'transient' and never fired).
214
219
  if (
215
220
  transportRetriesUsed < TRANSPORT_RETRY_MAX
216
- && classifyError(sendErr) === 'transient'
217
221
  && await retractExposedTextForReplay()
218
222
  ) {
219
223
  const waitMs = TRANSPORT_RETRY_BACKOFF_MS[transportRetriesUsed];
@@ -325,8 +329,13 @@ export async function sendWithRecovery(ctx) {
325
329
  // send after a bounded wait instead of failing the turn.
326
330
  if (
327
331
  transportRetriesUsed < TRANSPORT_RETRY_MAX
328
- && classifyError(sendErr) === 'transient'
329
- && (outcome.replaySafe === true || await retractExposedTextForReplay())
332
+ && (
333
+ (outcome.replaySafe === true && classifyError(sendErr) === 'transient')
334
+ || (
335
+ (classifyError(sendErr) === 'transient' || outcome.stallObserved === true)
336
+ && await retractExposedTextForReplay()
337
+ )
338
+ )
330
339
  ) {
331
340
  const waitMs = TRANSPORT_RETRY_BACKOFF_MS[transportRetriesUsed];
332
341
  try {
@@ -64,6 +64,16 @@ const RESUMABLE_OPEN_MAX_COUNT = 300;
64
64
  // idle this long — see the blank-scratch branch in sweepStaleSessions.
65
65
  const BLANK_SCRATCH_MAX_AGE_MS = 60 * 60 * 1000; // 1h
66
66
 
67
+ /** Child-agent transcripts share their visible parent's retention boundary.
68
+ * Presence (including a tombstone or unreadable file) preserves the child;
69
+ * only proven parent absence releases it to ordinary cleanup. */
70
+ function retainedLinkedAgent(session) {
71
+ if (!session || !isAgentOwner(session)) return false;
72
+ const parentId = String(session.ownerSessionId || session.parentSessionId || '').trim();
73
+ if (!/^[A-Za-z0-9_-]+$/.test(parentId) || parentId === session.id) return false;
74
+ return probePath(sessionPath(parentId)).state !== PROBE_ABSENT;
75
+ }
76
+
67
77
 
68
78
  export function listStoredSessions(options = {}) {
69
79
  const dir = getStoreDir();
@@ -423,6 +433,13 @@ function* sweepStaleSessionSteps(ttlMs, options = {}) {
423
433
  // messages), so there is no second, divergent parse of this file.
424
434
  const actual = record.doc;
425
435
  const diskClosed = record.closed === true || actual.status === 'closed';
436
+ if (retainedLinkedAgent(actual)) {
437
+ // Parent-owned agent transcripts are task history, not
438
+ // ephemeral worker cache. This covers both new open sessions
439
+ // and tombstones created by older Mixdog versions.
440
+ remaining++;
441
+ continue;
442
+ }
426
443
  if (diskClosed) {
427
444
  // A shared store can be tombstoned by another process while
428
445
  // this process still owns an in-flight controller for the same
@@ -59,6 +59,106 @@ function cleanValue(value) {
59
59
  return String(value || '').trim();
60
60
  }
61
61
 
62
+ function archivedAgentNotification(content, sessionId) {
63
+ const text = typeof content === 'string' ? content : '';
64
+ const marker = '\n\nResult:\n';
65
+ const markerAt = text.indexOf(marker);
66
+ if (markerAt < 0 || !/The async agent task .* has finished \(/.test(text.slice(0, markerAt))) return null;
67
+ const lines = text.slice(markerAt + marker.length)
68
+ .split(/\r?\n/)
69
+ .map((line) => line.replace(/^>\s?/, ''));
70
+ const divider = lines.findIndex((line) => line.trim() === '');
71
+ if (divider < 0) return null;
72
+ const headers = new Map();
73
+ for (const line of lines.slice(0, divider)) {
74
+ const match = /^([A-Za-z][A-Za-z0-9_]*):\s*(.*?)\s*$/.exec(line);
75
+ if (match) headers.set(match[1], match[2]);
76
+ }
77
+ if (headers.get('surface') !== 'agent' || headers.get('sessionId') !== sessionId) return null;
78
+ const status = cleanValue(headers.get('status')).toLowerCase();
79
+ if (!/^(?:completed|failed|cancelled)$/.test(status)) return null;
80
+ const body = lines.slice(divider + 1).join('\n').trim();
81
+ if (!body) return null;
82
+ return {
83
+ body,
84
+ status,
85
+ tag: cleanValue(headers.get('tag') || headers.get('label')),
86
+ agent: cleanValue(headers.get('agent')),
87
+ provider: cleanValue(headers.get('provider')),
88
+ model: cleanValue(headers.get('model')),
89
+ effort: cleanValue(headers.get('effort')),
90
+ fast: cleanValue(headers.get('fast')).toLowerCase() === 'true',
91
+ finishedAt: Date.parse(cleanValue(headers.get('finished'))) || 0,
92
+ };
93
+ }
94
+
95
+ /** Legacy recovery for child transcripts already unlinked by terminal reaping.
96
+ * The parent owns one canonical body-carrying completion notification, so scan
97
+ * only files containing the exact child id and project the newest valid body. */
98
+ function readArchivedAgentResult(sessionId) {
99
+ const dir = join(dataDir(), 'sessions');
100
+ if (probePath(dir).state !== PROBE_PRESENT) return null;
101
+ let files;
102
+ try { files = readdirSync(dir).filter((file) => file.endsWith('.json')); }
103
+ catch { return null; }
104
+ let best = null;
105
+ for (const file of files) {
106
+ let raw;
107
+ try { raw = readFileSync(join(dir, file), 'utf8'); } catch { continue; }
108
+ if (!raw.includes(sessionId)) continue;
109
+ const record = readTopLevelLifecycleRecord(raw);
110
+ if (isLifecycleUnreadable(record) || record.id === sessionId) continue;
111
+ const parent = record.doc;
112
+ const messages = Array.isArray(parent.messages) ? parent.messages : [];
113
+ for (let index = messages.length - 1; index >= 0; index--) {
114
+ const message = messages[index];
115
+ if (message?.role !== 'user') continue;
116
+ const archived = archivedAgentNotification(message.content, sessionId);
117
+ if (!archived) continue;
118
+ const at = positiveNumber(
119
+ message?.meta?.transcript?.at,
120
+ archived.finishedAt || positiveNumber(parent.updatedAt),
121
+ );
122
+ if (best && best.at > at) continue;
123
+ best = { ...archived, at, parent };
124
+ break;
125
+ }
126
+ }
127
+ if (!best) return null;
128
+ const text = `# Archived agent result\n\n${best.body}`;
129
+ return {
130
+ sessionId,
131
+ items: [{
132
+ id: `archived-agent-result:${sessionId}`,
133
+ kind: 'assistant',
134
+ text,
135
+ status: best.status,
136
+ ...(best.at ? { at: best.at } : {}),
137
+ ...(best.model ? { model: best.model } : {}),
138
+ ...(best.provider ? { provider: best.provider } : {}),
139
+ ...(best.agent ? { agent: best.agent } : {}),
140
+ }],
141
+ provider: best.provider,
142
+ model: best.model,
143
+ effort: best.effort,
144
+ fast: best.fast,
145
+ cwd: cleanValue(best.parent.cwd),
146
+ desktopSession: desktopSession(best.parent.desktopSession, best.parent.cwd),
147
+ workflow: null,
148
+ stats: {
149
+ currentContextTokens: 0,
150
+ currentEstimatedContextTokens: 0,
151
+ currentContextSource: null,
152
+ },
153
+ contextWindow: null,
154
+ rawContextWindow: null,
155
+ displayContextWindow: null,
156
+ autoCompactTokenLimit: null,
157
+ archivedAgentResult: true,
158
+ readOnlyDetachedAgent: false,
159
+ };
160
+ }
161
+
62
162
  function activeAgentWorker(row) {
63
163
  const statuses = [row?.stage, row?.status]
64
164
  .map(cleanValue)
@@ -421,6 +521,7 @@ export async function readStoredSessionTranscript(id, options = {}) {
421
521
  // Same strict authority as the store, and the same fail-closed rule: an
422
522
  // absent, unreadable, ambiguous or foreign record yields no transcript.
423
523
  const read = readTextFile(join(dataDir(), 'sessions', `${sessionId}.json`));
524
+ if (read.state === PROBE_ABSENT) return readArchivedAgentResult(sessionId);
424
525
  if (read.state !== PROBE_PRESENT) return null;
425
526
  const record = readTopLevelLifecycleRecord(read.text);
426
527
  if (isLifecycleUnreadable(record) || record.id !== sessionId) return null;
@@ -1188,6 +1188,36 @@ export function loadSession(id) {
1188
1188
  return stored ? _ensureLifecycleFields(stored) : null;
1189
1189
  }
1190
1190
 
1191
+ /** Strictly enumerate child-agent session files linked to one visible parent.
1192
+ * Used only by explicit parent deletion; ordinary close/context switches keep
1193
+ * the relationship intact. */
1194
+ export function listOwnedAgentSessionIds(ownerSessionId) {
1195
+ const ownerId = String(ownerSessionId || '').trim();
1196
+ if (!/^[A-Za-z0-9_-]+$/.test(ownerId)) return [];
1197
+ const dir = getStoreDir();
1198
+ if (probePath(dir).state !== PROBE_PRESENT) return [];
1199
+ let files;
1200
+ try {
1201
+ files = readdirSync(dir).filter((file) => file.endsWith('.json'));
1202
+ } catch {
1203
+ return [];
1204
+ }
1205
+ const ids = [];
1206
+ for (const file of files) {
1207
+ const candidateId = file.slice(0, -5);
1208
+ if (!candidateId || candidateId === ownerId || !/^[A-Za-z0-9_-]+$/.test(candidateId)) continue;
1209
+ try {
1210
+ const record = readTopLevelLifecycleRecord(readFileSync(join(dir, file), 'utf8'));
1211
+ if (isLifecycleUnreadable(record) || record.id !== candidateId) continue;
1212
+ const session = record.doc;
1213
+ if (!isAgentOwner(session)) continue;
1214
+ const linkedOwner = String(session.ownerSessionId || session.parentSessionId || '').trim();
1215
+ if (linkedOwner === ownerId) ids.push(candidateId);
1216
+ } catch { /* unreadable/vanished records are never deletion targets */ }
1217
+ }
1218
+ return ids;
1219
+ }
1220
+
1191
1221
 
1192
1222
  export function deleteSession(id, options = {}) {
1193
1223
  // Keep caller probes and all vetoes ahead of the non-reentrant lock and