mixdog 0.9.55 → 0.9.59

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 (120) hide show
  1. package/package.json +3 -1
  2. package/scripts/_smoke_wd.mjs +7 -0
  3. package/scripts/agent-terminal-reap-test.mjs +127 -2
  4. package/scripts/compact-file-reattach-test.mjs +88 -0
  5. package/scripts/compact-pressure-test.mjs +45 -21
  6. package/scripts/compact-recall-digest-test.mjs +57 -0
  7. package/scripts/compact-smoke.mjs +35 -39
  8. package/scripts/desktop-session-bridge-test.mjs +271 -9
  9. package/scripts/generate-oc-icons.mjs +11 -0
  10. package/scripts/live-share-test.mjs +148 -0
  11. package/scripts/live-worker-smoke.mjs +1 -1
  12. package/scripts/max-output-recovery-test.mjs +12 -1
  13. package/scripts/mouse-tracking-restore-smoke.mjs +107 -0
  14. package/scripts/provider-admission-scheduler-test.mjs +100 -0
  15. package/scripts/provider-contract-test.mjs +49 -0
  16. package/scripts/resource-admission-test.mjs +5 -2
  17. package/scripts/session-ingest-compaction-smoke.mjs +38 -0
  18. package/scripts/steering-drain-buckets-test.mjs +15 -0
  19. package/scripts/submit-commandbusy-race-test.mjs +54 -3
  20. package/scripts/tool-smoke.mjs +2 -3
  21. package/scripts/tui-ambiguous-width-test.mjs +113 -0
  22. package/scripts/tui-runtime-load-bench.mjs +5 -0
  23. package/scripts/tui-transcript-perf-test.mjs +167 -0
  24. package/src/app.mjs +23 -0
  25. package/src/cli.mjs +6 -0
  26. package/src/lib/keychain-cjs.cjs +70 -20
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +4 -2
  28. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +18 -17
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +18 -0
  30. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +111 -0
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  32. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +61 -2
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -4
  34. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +43 -2
  35. package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +112 -0
  36. package/src/runtime/agent/orchestrator/session/context-utils.mjs +203 -49
  37. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +6 -2
  38. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -15
  39. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -0
  40. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  41. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +109 -2
  42. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -7
  43. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +39 -39
  44. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +108 -4
  45. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +19 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +22 -11
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +132 -24
  48. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager.mjs +16 -1
  50. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +39 -21
  51. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +18 -0
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +91 -1
  53. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +14 -33
  54. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +169 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +474 -42
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +17 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +36 -1
  58. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +20 -0
  59. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -1
  60. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +249 -11
  61. package/src/runtime/channels/data/voice-runtime-manifest.json +21 -5
  62. package/src/runtime/channels/lib/scheduler.mjs +8 -6
  63. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +71 -8
  64. package/src/runtime/channels/lib/voice-transcription.mjs +2 -2
  65. package/src/runtime/channels/lib/whisper-language.mjs +4 -0
  66. package/src/runtime/memory/lib/embedding-provider.mjs +7 -0
  67. package/src/runtime/memory/lib/embedding-worker.mjs +20 -0
  68. package/src/runtime/memory/lib/query-handlers.mjs +10 -4
  69. package/src/runtime/memory/lib/recall-format.mjs +43 -2
  70. package/src/runtime/memory/lib/session-ingest-runtime.mjs +90 -64
  71. package/src/runtime/shared/err-text.mjs +4 -1
  72. package/src/runtime/shared/resource-admission.mjs +11 -0
  73. package/src/runtime/shared/schedule-model-ref.mjs +28 -0
  74. package/src/runtime/shared/schedule-session-run.mjs +63 -0
  75. package/src/runtime/shared/schedules-db.mjs +8 -3
  76. package/src/runtime/shared/tool-result-summary.mjs +4 -1
  77. package/src/runtime/shared/tool-surface.mjs +7 -7
  78. package/src/runtime/shared/transcript-writer.mjs +17 -0
  79. package/src/session-runtime/channel-config-api.mjs +11 -0
  80. package/src/session-runtime/config-helpers.mjs +9 -6
  81. package/src/session-runtime/context-status.mjs +80 -3
  82. package/src/session-runtime/lifecycle-api.mjs +154 -40
  83. package/src/session-runtime/provider-auth-api.mjs +21 -2
  84. package/src/session-runtime/provider-usage.mjs +4 -1
  85. package/src/session-runtime/resource-api.mjs +12 -11
  86. package/src/session-runtime/runtime-core.mjs +45 -11
  87. package/src/session-runtime/session-text.mjs +56 -6
  88. package/src/session-runtime/session-turn-api.mjs +70 -2
  89. package/src/session-runtime/tool-catalog.mjs +2 -1
  90. package/src/session-runtime/workflow-agents-api.mjs +2 -2
  91. package/src/standalone/agent-tool/render.mjs +5 -1
  92. package/src/standalone/agent-tool.mjs +63 -3
  93. package/src/standalone/channel-admin.mjs +19 -3
  94. package/src/standalone/channel-daemon.mjs +40 -0
  95. package/src/tui/app/resume-picker.mjs +5 -1
  96. package/src/tui/app/settings-picker.mjs +19 -0
  97. package/src/tui/app/transcript-window.mjs +45 -8
  98. package/src/tui/app/use-mouse-input.mjs +101 -31
  99. package/src/tui/app/use-transcript-window.mjs +53 -9
  100. package/src/tui/components/ToolExecution.jsx +3 -4
  101. package/src/tui/components/TranscriptItem.jsx +3 -0
  102. package/src/tui/dist/index.mjs +17391 -14920
  103. package/src/tui/engine/context-state.mjs +10 -1
  104. package/src/tui/engine/live-share.mjs +390 -0
  105. package/src/tui/engine/queue-helpers.mjs +23 -0
  106. package/src/tui/engine/session-api-ext.mjs +439 -40
  107. package/src/tui/engine/session-api.mjs +7 -1
  108. package/src/tui/engine/session-flow.mjs +21 -7
  109. package/src/tui/engine/tool-card-results.mjs +3 -1
  110. package/src/tui/engine/turn.mjs +57 -4
  111. package/src/tui/engine.mjs +211 -7
  112. package/src/tui/index.jsx +2 -0
  113. package/src/tui/lib/voice-setup.mjs +10 -4
  114. package/src/tui/paste-attachments.mjs +4 -1
  115. package/src/vendor/statusline/src/gateway/session-routes.mjs +24 -1
  116. package/src/workflows/default/WORKFLOW.md +3 -0
  117. package/vendor/ink/build/display-width.js +14 -0
  118. package/vendor/ink/build/output.js +24 -3
  119. package/vendor/ink/build/wrap-text.d.ts +2 -0
  120. package/vendor/ink/build/wrap-text.js +45 -4
@@ -6,7 +6,7 @@
6
6
  import { createHash } from 'crypto';
7
7
  import { getProvider } from '../../providers/registry.mjs';
8
8
  import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
9
- import { loadSession, saveSessionAsync, saveSessionAsyncDeferred } from '../store.mjs';
9
+ import { loadSession, saveSessionAsync, saveSessionAsyncDeferred, readSessionLifecycleFromDisk } from '../store.mjs';
10
10
  import { createAbortController } from '../../../../shared/abort-controller.mjs';
11
11
  import { logLlmCall } from '../../../../shared/llm/usage-log.mjs';
12
12
  import { appendAgentTrace } from '../../agent-trace.mjs';
@@ -59,6 +59,70 @@ import { _getAgentLoop } from './runtime-loaders.mjs';
59
59
  import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
60
60
  import { recordProviderContextBaseline } from '../loop/compact-policy.mjs';
61
61
 
62
+ export function persistedAssistantTranscriptMetadata(value, fallbackAt = Date.now()) {
63
+ if (!value || typeof value !== 'object') return null;
64
+ const candidateAt = Number(value.assistantAt);
65
+ const assistantAt = Number.isFinite(candidateAt) && candidateAt > 0 ? candidateAt : fallbackAt;
66
+ value.assistantAt = assistantAt;
67
+ return {
68
+ at: assistantAt,
69
+ ...(typeof value.model === 'string' && value.model ? { model: value.model } : {}),
70
+ ...(typeof value.provider === 'string' && value.provider ? { provider: value.provider } : {}),
71
+ ...(typeof value.agent === 'string' && value.agent ? { agent: value.agent } : {}),
72
+ };
73
+ }
74
+
75
+ export function attachAssistantTranscriptCompletion(messages, completion, turnStartedAt = 0) {
76
+ if (!Array.isArray(messages) || !completion || typeof completion !== 'object') return false;
77
+ const elapsedMs = Math.max(0, Number(completion.elapsedMs || 0));
78
+ const status = typeof completion.status === 'string' && completion.status
79
+ ? completion.status
80
+ : 'done';
81
+ const verb = typeof completion.verb === 'string' && completion.verb
82
+ ? completion.verb
83
+ : 'Thought';
84
+ let turnStart = -1;
85
+ const expectedAt = Number(turnStartedAt || 0);
86
+ if (expectedAt > 0) {
87
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
88
+ const message = messages[index];
89
+ if (message?.role !== 'user') continue;
90
+ if (Number(message?.meta?.transcript?.at || 0) !== expectedAt) continue;
91
+ turnStart = index;
92
+ break;
93
+ }
94
+ }
95
+ for (let index = messages.length - 1; index > turnStart; index -= 1) {
96
+ const message = messages[index];
97
+ if (message?.role !== 'assistant') continue;
98
+ const content = message.content;
99
+ const hasVisibleText = typeof content === 'string'
100
+ ? Boolean(content.trim())
101
+ : Array.isArray(content) && content.some((part) => {
102
+ if (typeof part === 'string') return Boolean(part.trim());
103
+ if (!part || typeof part !== 'object') return false;
104
+ return Boolean(String(part.text || part.content || '').trim());
105
+ });
106
+ if (!hasVisibleText) continue;
107
+ const meta = message.meta && typeof message.meta === 'object' ? message.meta : {};
108
+ const transcript = meta.transcript && typeof meta.transcript === 'object'
109
+ ? meta.transcript
110
+ : {};
111
+ messages[index] = {
112
+ ...message,
113
+ meta: {
114
+ ...meta,
115
+ transcript: {
116
+ ...transcript,
117
+ completion: { status, verb, elapsedMs },
118
+ },
119
+ },
120
+ };
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+
62
126
  /**
63
127
  * Wrap an async call so that if the session's controller aborts mid-flight,
64
128
  * the wrapper settles with a SessionClosedError even if the underlying promise
@@ -102,6 +166,20 @@ export async function _api_call_with_interrupt(sessionId, fn) {
102
166
 
103
167
  export async function askSession(sessionId, prompt, context, onToolCall, cwdOverride, explicitPrefetch, askOpts = {}) {
104
168
  const _askStartedAt = Date.now();
169
+ const _rawTranscriptMeta = askOpts?.transcriptMeta;
170
+ const _transcriptMeta = _rawTranscriptMeta && typeof _rawTranscriptMeta === 'object'
171
+ ? {
172
+ ...(Number.isFinite(Number(_rawTranscriptMeta.at)) ? { at: Number(_rawTranscriptMeta.at) } : {}),
173
+ ...(typeof _rawTranscriptMeta.model === 'string' && _rawTranscriptMeta.model ? { model: _rawTranscriptMeta.model } : {}),
174
+ ...(typeof _rawTranscriptMeta.provider === 'string' && _rawTranscriptMeta.provider ? { provider: _rawTranscriptMeta.provider } : {}),
175
+ ...(typeof _rawTranscriptMeta.agent === 'string' && _rawTranscriptMeta.agent ? { agent: _rawTranscriptMeta.agent } : {}),
176
+ }
177
+ : null;
178
+ const _takeAssistantTranscriptMetadata = () => {
179
+ const metadata = persistedAssistantTranscriptMetadata(_rawTranscriptMeta);
180
+ if (_rawTranscriptMeta && typeof _rawTranscriptMeta === 'object') delete _rawTranscriptMeta.assistantAt;
181
+ return metadata;
182
+ };
105
183
  const _promptSrc = 'prompt';
106
184
  const _prefetchFiles = (explicitPrefetch?.files?.length) || 0;
107
185
  const _prefetchCallers = (explicitPrefetch?.callers?.length) || 0;
@@ -188,6 +266,19 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
188
266
  // recover the stale transient stage before the loop's pre-send compact
189
267
  // path runs (it will overwrite lastStage with real telemetry).
190
268
  normalizeStaleCompactingStage(preSession);
269
+ // Split-brain re-adoption: another surface (e.g. desktop click-through
270
+ // of a session still actively owned by this process) may have
271
+ // resumed-and-detached this session, bumping the ON-DISK generation
272
+ // while the conversation kept going here. Every save from this process
273
+ // would then be silently dropped by _shouldDrop's ownership rule and
274
+ // the on-disk transcript would freeze at the last landed save. A new
275
+ // ask on a NON-closed session is an explicit ownership claim: adopt
276
+ // the disk generation so this turn's commits land and heal the file.
277
+ const diskLifecycle = readSessionLifecycleFromDisk(sessionId);
278
+ if (diskLifecycle && diskLifecycle.closed !== true
279
+ && diskLifecycle.generation > (typeof preSession.generation === 'number' ? preSession.generation : 0)) {
280
+ preSession.generation = diskLifecycle.generation;
281
+ }
191
282
  askGeneration = typeof preSession.generation === 'number' ? preSession.generation : 0;
192
283
  const runtime = _touchRuntime(sessionId);
193
284
  // Preserve any parent-abort link agent-dispatch established BEFORE we
@@ -299,7 +390,11 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
299
390
  session.sessionStartMetaInjected = true;
300
391
  }
301
392
  cancelledUserTurnContent = _userTurnContent;
302
- const outgoing = [...historyMessages, { role: 'user', content: _userTurnContent }];
393
+ const outgoing = [...historyMessages, {
394
+ role: 'user',
395
+ content: _userTurnContent,
396
+ ...(_transcriptMeta ? { meta: { transcript: _transcriptMeta } } : {}),
397
+ }];
303
398
  _turnOutgoing = outgoing;
304
399
  // Expose the in-flight working transcript so contextStatus() can
305
400
  // estimate the LIVE context footprint mid-turn. agentLoop mutates
@@ -403,6 +498,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
403
498
  onTextReset: _trackTextReset,
404
499
  onReasoningDelta: _trackReasoningDelta,
405
500
  onAssistantText: _trackAssistantText,
501
+ takeAssistantTranscriptMetadata: _takeAssistantTranscriptMetadata,
406
502
  onAssistantMessageCommitted: () => _turnInterruption.markAssistantMessageCommitted(),
407
503
  onAssistantToolCallObserved: (call, detail) => _turnInterruption.recordToolCalls([call], detail),
408
504
  onProviderSendStarted: () => _turnInterruption.markProviderSendStarted(),
@@ -503,6 +599,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
503
599
  // Turn committed into session.messages; drop the live-turn alias so
504
600
  // contextStatus() reverts to the authoritative committed transcript.
505
601
  session.liveTurnMessages = null;
602
+ const _assistantTranscriptMeta = persistedAssistantTranscriptMetadata(_rawTranscriptMeta);
506
603
  if (result.content || result.reasoningContent) {
507
604
  // Max-output recovery returns the complete concatenated text to
508
605
  // callers/TUI, while outgoing already contains prior partial
@@ -518,6 +615,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
518
615
  // if any, are swapped for a placeholder only at disk write
519
616
  // time inside the session store (store.mjs _sessionForDisk).
520
617
  content: persistedAssistantContent,
618
+ ...(_assistantTranscriptMeta ? { meta: { transcript: _assistantTranscriptMeta } } : {}),
521
619
  ...(typeof result.reasoningContent === 'string' && result.reasoningContent
522
620
  ? { reasoningContent: result.reasoningContent }
523
621
  : {}),
@@ -553,6 +651,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
553
651
  role: 'assistant',
554
652
  content: '',
555
653
  emptyFinal: true,
654
+ ...(_assistantTranscriptMeta ? { meta: { transcript: _assistantTranscriptMeta } } : {}),
556
655
  stopReason: _emptyStop,
557
656
  iterations: result?.iterations ?? null,
558
657
  toolCallsTotal: result?.toolCallsTotal ?? null,
@@ -668,6 +767,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
668
767
  // replay; duplicate memory/spool copies share the same id.
669
768
  recordPendingMessageDelivery(session, _turnPendingEntries);
670
769
  _pwstTurnDrained = drainPendingMessages(sessionId);
770
+ if (_pwstTurnDrained.length === 0) {
771
+ const turnStartedAt = Number(_rawTranscriptMeta?.at || _askStartedAt);
772
+ attachAssistantTranscriptCompletion(session.messages, {
773
+ status: 'done',
774
+ verb: _rawTranscriptMeta?.completionVerb,
775
+ elapsedMs: Date.now() - turnStartedAt,
776
+ }, turnStartedAt);
777
+ }
671
778
  let terminalRelayed = false;
672
779
  if (_pwstTurnDrained.length === 0 && typeof askOpts?.onTerminalResult === 'function') {
673
780
  terminalRelayed = true;
@@ -200,6 +200,7 @@ async function runRecallFastTrackForSession(session, messages, opts = {}) {
200
200
  limit: positiveContextWindow(session?.compaction?.recallDigestLimit) || 30,
201
201
  includeMembers: true,
202
202
  includeRaw: true,
203
+ compactDigest: true,
203
204
  }, callerCtx, memoryTimeoutMs);
204
205
  recallText = typeof browsed === 'string' ? browsed : String(browsed?.text ?? browsed ?? '');
205
206
  } catch (err) {
@@ -270,7 +271,7 @@ export async function runSessionCompaction(session, opts = {}) {
270
271
  const targetBudgetTokens = alignedPolicy
271
272
  ? (compactTargetBudgetForPolicy({ ...alignedPolicy, force }) || boundary)
272
273
  : boundary;
273
- const pressureTokens = estimateTranscriptContextUsage(messages, session.tools || []);
274
+ const pressureTokens = estimateTranscriptContextUsage(messages, session.tools || [], { provider: session.provider });
274
275
  const beforeTokens = pressureTokens;
275
276
  // Manual /compact may explicitly request the original semantic path:
276
277
  // summarize THIS session's transcript directly without hydrating/searching
@@ -322,6 +323,7 @@ export async function runSessionCompaction(session, opts = {}) {
322
323
  recallText: recallPayload.recallText,
323
324
  query: recallPayload.query,
324
325
  querySha: recallPayload.querySha,
326
+ cwd: session.cwd,
325
327
  // Ingest just ran on the live transcript, so an empty recall dump
326
328
  // means the memory pipeline is broken — do NOT erase history
327
329
  // behind an empty summary shell. Empty recall now throws and is
@@ -352,12 +354,13 @@ export async function runSessionCompaction(session, opts = {}) {
352
354
  semanticCompactResult = await semanticCompactMessages(
353
355
  provider,
354
356
  messages,
355
- opts.model || session.model,
357
+ opts.model || resolveSemanticSummaryModel(session, { budgetTokens: budget }) || session.model,
356
358
  budget,
357
359
  {
358
360
  reserveTokens,
359
361
  providerName: session.provider || provider?.name || null,
360
362
  sessionId: opts.sessionId || session.id || null,
363
+ cwd: session.cwd,
361
364
  signal: opts.signal || null,
362
365
  promptCacheKey: session.promptCacheKey || null,
363
366
  providerCacheKey: session.promptCacheKey || null,
@@ -396,12 +399,13 @@ export async function runSessionCompaction(session, opts = {}) {
396
399
  semanticCompactResult = await semanticCompactMessages(
397
400
  provider,
398
401
  messages,
399
- opts.model || session.model,
402
+ opts.model || resolveSemanticSummaryModel(session, { budgetTokens: budget }) || session.model,
400
403
  budget,
401
404
  {
402
405
  reserveTokens,
403
406
  providerName: session.provider || provider?.name || null,
404
407
  sessionId: opts.sessionId || session.id || null,
408
+ cwd: session.cwd,
405
409
  signal: opts.signal || null,
406
410
  promptCacheKey: session.promptCacheKey || null,
407
411
  providerCacheKey: session.promptCacheKey || null,
@@ -552,9 +556,13 @@ export async function runSessionCompaction(session, opts = {}) {
552
556
  lastChangedAt: changed ? now : session.compaction?.lastChangedAt || null,
553
557
  lastCompactAt: changed ? now : session.compaction?.lastCompactAt || null,
554
558
  lastSemantic: semanticCompactResult?.semantic === true,
555
- lastSemanticError: semanticCompactError?.message || null,
559
+ // This is a terminal success record. A failed component may have been
560
+ // recovered by semantic compaction or pruning, but must not remain as a
561
+ // status/UI failure after the final compacted transcript is accepted.
562
+ lastSemanticError: null,
556
563
  lastRecallFastTrack: recallFastTrackResult?.recallFastTrack === true,
557
- lastRecallFastTrackError: recallFastTrackError?.message || null,
564
+ lastRecallFastTrackError: null,
565
+ lastError: null,
558
566
  lastRecallFastTrackQuerySha: recallFastTrackResult?.query ? createHash('sha256').update(recallFastTrackResult.query).digest('hex').slice(0, 16) : null,
559
567
  lastSemanticUsage: semanticCompactResult?.usage ? {
560
568
  inputTokens: semanticCompactResult.usage.inputTokens || 0,
@@ -584,9 +592,9 @@ export async function runSessionCompaction(session, opts = {}) {
584
592
  targetBudgetTokens: budget,
585
593
  reserveTokens,
586
594
  semanticCompact: semanticCompactResult?.semantic === true,
587
- semanticError: semanticCompactError?.message || null,
595
+ semanticError: null,
588
596
  recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
589
- recallFastTrackError: recallFastTrackError?.message || null,
597
+ recallFastTrackError: null,
590
598
  usage: semanticCompactResult?.usage || null,
591
599
  };
592
600
  }
@@ -2,16 +2,14 @@
2
2
  // Periodic idle-session + tombstone sweep extracted verbatim from manager.mjs.
3
3
  // Drives sweepStaleSessions on an unref'd interval; closeSession is imported
4
4
  // from session-close.mjs (one-way dependency, no cycle).
5
- import { sweepStaleSessions } from '../store.mjs';
5
+ import { sweepStaleSessions, evictIdleLiveSessions } from '../store.mjs';
6
6
  import { sweepOrphanedPendingMessages } from './pending-messages.mjs';
7
7
  import {
8
8
  _getRuntimeEntry,
9
9
  _clearSessionRuntime,
10
- _runtimeEntries,
11
10
  _sweepTerminalSessionRuntimes,
12
11
  } from './runtime-liveness.mjs';
13
12
  import { _closeBashSessionLazy } from './runtime-loaders.mjs';
14
- import { closeSession } from './session-close.mjs';
15
13
  import { nonNegativeIntEnv } from './env-utils.mjs';
16
14
 
17
15
  // --- Periodic idle session cleanup ---
@@ -41,6 +39,32 @@ function _previewIds(items, limit = 5) {
41
39
  return ` (${ids.join(', ')}${more})`;
42
40
  }
43
41
 
42
+ const IN_FLIGHT_STAGES = new Set(['connecting', 'requesting', 'streaming', 'tool_running', 'cancelling']);
43
+
44
+ export function _finalizeSweptSessionRuntime(detail) {
45
+ if (!detail?.id) return false;
46
+ const rtEntry = _getRuntimeEntry(detail.id);
47
+ // The store scan and this runtime cleanup are not atomic. If the session
48
+ // became active after the scan, leave its controller and runtime untouched;
49
+ // a later idle cycle can reconsider it after the work settles.
50
+ if (rtEntry && (
51
+ (rtEntry.controller && !rtEntry.controller.signal?.aborted)
52
+ || IN_FLIGHT_STAGES.has(rtEntry.stage)
53
+ )) return false;
54
+ _clearSessionRuntime(detail.id);
55
+ if (detail.bashSessionId) {
56
+ try { _closeBashSessionLazy(detail.bashSessionId, `idle-sweep:${detail.id}`); } catch { /* ignore */ }
57
+ }
58
+ return true;
59
+ }
60
+
61
+ // Informational sweep telemetry is debug-gated: unconditional stderr writes
62
+ // land inside the interactive TUI and corrupt the composer/status line.
63
+ // Genuine sweep errors (catch paths) stay unconditional.
64
+ const _sweepLog = (line) => {
65
+ if (process.env.MIXDOG_DEBUG_SESSION_LOG) process.stderr.write(line);
66
+ };
67
+
44
68
  function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {}) {
45
69
  const startedAt = Date.now();
46
70
  try {
@@ -59,35 +83,24 @@ function sweepIdleSessions({ includeTombstones = true, sweepIdle = true } = {})
59
83
  } = result;
60
84
  if (cleaned > 0) {
61
85
  for (const d of details) {
62
- // Skip entries with an active in-flight controller — aborting
63
- // them via closeSession() is the safe path; clearing the runtime
64
- // without signalling the controller leaves orphan provider work.
65
- const rtEntry = _getRuntimeEntry(d.id);
66
- if (rtEntry && rtEntry.controller && !rtEntry.controller.signal?.aborted) {
67
- try { closeSession(d.id, 'idle-sweep'); } catch { /* ignore */ }
68
- } else {
69
- _clearSessionRuntime(d.id);
70
- if (d.bashSessionId) {
71
- try { _closeBashSessionLazy(d.bashSessionId, `idle-sweep:${d.id}`); } catch { /* ignore */ }
72
- }
73
- }
74
- process.stderr.write(`[agent-session] idle cleanup: closed ${d.id} (idle ${d.idleMinutes}m, owner=${d.owner})\n`);
86
+ if (!_finalizeSweptSessionRuntime(d)) continue;
87
+ _sweepLog(`[agent-session] idle cleanup: closed ${d.id} (idle ${d.idleMinutes}m, owner=${d.owner})\n`);
75
88
  }
76
- process.stderr.write(`[agent-session] idle sweep: cleaned ${cleaned} session(s), ${remaining} remaining\n`);
89
+ _sweepLog(`[agent-session] idle sweep: cleaned ${cleaned} session(s), ${remaining} remaining\n`);
77
90
  }
78
91
  if (tombstonesCleaned > 0) {
79
92
  for (const d of tombstoneDetails) {
80
93
  if (d?.id && !_isSessionLive(d.id)) _clearSessionRuntime(d.id);
81
94
  }
82
- process.stderr.write(`[session-sweep] unlinked ${tombstonesCleaned} tombstone(s)${_previewIds(tombstoneDetails)}\n`);
95
+ _sweepLog(`[session-sweep] unlinked ${tombstonesCleaned} tombstone(s)${_previewIds(tombstoneDetails)}\n`);
83
96
  }
84
97
  if (tombstoneErrors.length > 0) {
85
98
  const first = tombstoneErrors[0];
86
- process.stderr.write(`[session-sweep] tombstone unlink failed for ${tombstoneErrors.length} session(s): ${first?.id || 'unknown'} ${first?.message || ''}\n`);
99
+ _sweepLog(`[session-sweep] tombstone unlink failed for ${tombstoneErrors.length} session(s): ${first?.id || 'unknown'} ${first?.message || ''}\n`);
87
100
  }
88
101
  const elapsed = Date.now() - startedAt;
89
102
  if (elapsed >= CLEANUP_SLOW_LOG_MS) {
90
- process.stderr.write(`[session-sweep] cleanup took ${elapsed}ms (idle=${cleaned}, tombstones=${tombstonesCleaned}, remaining=${remaining})\n`);
103
+ _sweepLog(`[session-sweep] cleanup took ${elapsed}ms (idle=${cleaned}, tombstones=${tombstonesCleaned}, remaining=${remaining})\n`);
91
104
  }
92
105
  } catch (e) {
93
106
  process.stderr.write(`[agent-session] idle sweep error: ${e && e.message || e}\n`);
@@ -117,11 +130,11 @@ export function sweepTombstones() {
117
130
  if (d?.id && !_isSessionLive(d.id)) _clearSessionRuntime(d.id);
118
131
  }
119
132
  if (tombstonesCleaned > 0) {
120
- process.stderr.write(`[session-sweep] unlinked ${tombstonesCleaned} tombstone(s)${_previewIds(tombstoneDetails)}\n`);
133
+ _sweepLog(`[session-sweep] unlinked ${tombstonesCleaned} tombstone(s)${_previewIds(tombstoneDetails)}\n`);
121
134
  }
122
135
  if (tombstoneErrors.length > 0) {
123
136
  const first = tombstoneErrors[0];
124
- process.stderr.write(`[session-sweep] tombstone unlink failed for ${tombstoneErrors.length} session(s): ${first?.id || 'unknown'} ${first?.message || ''}\n`);
137
+ _sweepLog(`[session-sweep] tombstone unlink failed for ${tombstoneErrors.length} session(s): ${first?.id || 'unknown'} ${first?.message || ''}\n`);
125
138
  }
126
139
  return tombstonesCleaned;
127
140
  } catch (e) {
@@ -130,28 +143,15 @@ export function sweepTombstones() {
130
143
  }
131
144
  }
132
145
 
133
- function hasActiveRuntimeWork() {
134
- for (const [, entry] of _runtimeEntries()) {
135
- if (!entry || entry.closed === true) continue;
136
- if (entry.controller && !entry.controller.signal?.aborted) return true;
137
- if (['connecting', 'requesting', 'streaming', 'tool_running', 'cancelling'].includes(entry.stage)) return true;
138
- }
139
- return false;
140
- }
141
-
142
- function _runCleanupCycle() {
146
+ export function _runCleanupCycle() {
143
147
  // Drain every settled runtime entry on each pass, not just the one or two
144
148
  // sessions whose on-disk idle TTL happened to expire in this interval.
145
149
  _sweepTerminalSessionRuntimes();
146
- // A busy host may always have some unrelated agent in flight. Tombstones
147
- // are already closed and can still be batch-reclaimed safely; only the
148
- // open-session idle sweep remains gated while active work exists.
149
- if (hasActiveRuntimeWork()) {
150
- sweepIdleSessions({ includeTombstones: true, sweepIdle: false });
151
- return;
152
- }
153
150
  sweepOrphanedPendingMessages();
154
151
  sweepIdleSessions({ includeTombstones: true });
152
+ // Reclaim same-process session snapshots whose state is durable on disk
153
+ // (memory-leak guard: _liveSessions used to grow for process lifetime).
154
+ try { evictIdleLiveSessions({ isSessionLive: _isSessionLive }); } catch { /* best-effort */ }
155
155
  }
156
156
 
157
157
  function _startCleanupInterval() {
@@ -1,7 +1,7 @@
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 } from 'fs';
4
+ import { readFileSync, statSync } from 'fs';
5
5
  import { createHash, randomBytes } from 'crypto';
6
6
  import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
7
7
  import { updateJsonAtomicSync, updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
@@ -47,8 +47,13 @@ function pendingMessageId(entry) {
47
47
  return typeof entry?.id === 'string' && entry.id ? entry.id : null;
48
48
  }
49
49
 
50
+ // Content-addressed (NO index): an index-keyed id changed whenever the queue
51
+ // shifted, so ack/ledger suppression missed the renamed entry and the foreign
52
+ // drain re-injected it as a duplicate user message. Identical legacy strings
53
+ // now share one id — acceptable, since ack-by-id then clears such duplicate
54
+ // stale rows together.
50
55
  function legacyPendingMessageId(sessionId, index, value) {
51
- return `legacy_${createHash('sha256').update(`${sessionId}:${index}:${value}`).digest('hex').slice(0, 24)}`;
56
+ return `legacy_${createHash('sha256').update(`${sessionId}:${value}`).digest('hex').slice(0, 24)}`;
52
57
  }
53
58
 
54
59
  function isCompletionNotificationEntry(entry) {
@@ -97,6 +102,12 @@ function pendingMessagesPath() {
97
102
  return join(resolvePluginData(), PENDING_MESSAGES_FILE);
98
103
  }
99
104
 
105
+ // Exposed for live-share owners: they fs.watch this file for instant pickup
106
+ // of cross-surface submits (the 3s drain tick remains the safety net).
107
+ export function pendingMessagesSpoolPath() {
108
+ return pendingMessagesPath();
109
+ }
110
+
100
111
  function isValidPendingSessionId(sessionId) {
101
112
  return typeof sessionId === 'string' && /^[A-Za-z0-9_-]+$/.test(sessionId);
102
113
  }
@@ -371,11 +382,19 @@ export function acknowledgePendingMessages(sessionId, deliveredEntries) {
371
382
  const reported = operation.then(() => true).catch((err) => {
372
383
  try { process.stderr.write(`[session] pending-message ack failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
373
384
  return false;
374
- }).finally(() => {
385
+ }).then((ok) => {
386
+ if (_pendingPersistTails.get(sessionId) === operation) _pendingPersistTails.delete(sessionId);
387
+ // Keep the acked ids when the spool cleanup FAILED: the durable entry
388
+ // still exists, and forgetting that it was already delivered let the
389
+ // foreign-injection drain re-take it as a "new" cross-surface submit
390
+ // (user saw the same message duplicated in the transcript). Retained
391
+ // ids also keep suppressing the hydrate path; they are dropped only
392
+ // once a later ack round actually lands.
393
+ if (!ok) return false;
375
394
  const currentAcked = pendingIdSet(_ackedPendingIds, sessionId);
376
395
  for (const id of ids) currentAcked.delete(id);
377
396
  if (currentAcked.size === 0) _ackedPendingIds.delete(sessionId);
378
- if (_pendingPersistTails.get(sessionId) === operation) _pendingPersistTails.delete(sessionId);
397
+ return true;
379
398
  });
380
399
  return reported;
381
400
  }
@@ -638,6 +657,91 @@ export function enqueuePendingMessage(sessionId, message) {
638
657
  return Math.max(q.length, bufferedDepth || 0);
639
658
  }
640
659
 
660
+ /**
661
+ * Remote-attach injection enqueue: persist a user message into the shared
662
+ * cross-process spool WITHOUT touching this process's in-memory queues. Used
663
+ * by a viewer surface attached to a session another live process owns — the
664
+ * owner's injection poller (drainForeignUserInjections) picks it up there.
665
+ * Skipping the local queues is deliberate: if this process later takes real
666
+ * ownership of the session, a lingering local copy would double-inject.
667
+ */
668
+ export function enqueueRemotePendingMessage(sessionId, message) {
669
+ if (!isValidPendingSessionId(sessionId)) return 0;
670
+ const normalized = pendingMessageQueueEntry(message);
671
+ if (!normalized) return 0;
672
+ return persistPendingMessages(sessionId, [{ ...normalized, id: newPendingMessageId() }]);
673
+ }
674
+
675
+ // Spool-file mtime gate so the owner's idle poller costs one stat per tick,
676
+ // not a locked read-modify-write.
677
+ let _foreignSpoolScanMtime = 0;
678
+
679
+ /**
680
+ * Owner-side drain of FOREIGN user injections for a session this process
681
+ * owns: atomically removes (and returns the text of) genuine user/steering
682
+ * entries that were persisted by ANOTHER process — entries known locally
683
+ * (own steering buffers, hydrated, in-delivery, acked) and completion/
684
+ * internal-notification entries are left untouched for the normal
685
+ * askSession hydrate path.
686
+ */
687
+ export function drainForeignUserInjections(sessionId) {
688
+ if (!isValidPendingSessionId(sessionId)) return [];
689
+ let mtime = 0;
690
+ try { mtime = statSync(pendingMessagesPath()).mtimeMs || 0; } catch { return []; }
691
+ if (mtime === _foreignSpoolScanMtime) return [];
692
+ _foreignSpoolScanMtime = mtime;
693
+ const localIds = new Set();
694
+ for (const map of [_sessionPendingMessages, _pendingPersistBuffers, _hydratedPendingMessages]) {
695
+ for (const entry of map.get(sessionId) || []) {
696
+ const id = pendingMessageId(entry);
697
+ if (id) localIds.add(id);
698
+ }
699
+ }
700
+ for (const set of [_inDeliveryPendingIds.get(sessionId), _ackedPendingIds.get(sessionId)]) {
701
+ for (const id of set || []) localIds.add(id);
702
+ }
703
+ // The durable delivered-ledger mirrors the hydrate path's suppression: an
704
+ // entry whose id was recorded as delivered (but whose spool cleanup has
705
+ // not landed yet — crash, lock contention, another process) is NOT a
706
+ // foreign submit and must never re-inject.
707
+ try {
708
+ for (const id of loadSession(sessionId)?.deliveredPendingMessageIds || []) {
709
+ if (typeof id === 'string' && id) localIds.add(id);
710
+ }
711
+ } catch { /* ledger unavailable — in-memory sets still guard */ }
712
+ const taken = [];
713
+ try {
714
+ updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
715
+ const next = normalizePendingStore(raw);
716
+ const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
717
+ if (q.length === 0) return undefined;
718
+ const kept = [];
719
+ for (const entry of q) {
720
+ const id = pendingMessageId(entry);
721
+ const text = pendingMessageText(entry);
722
+ const foreignUser = id && !localIds.has(id)
723
+ && !isCompletionNotificationEntry(entry)
724
+ && !isLegacyUnmarkedCompletionNotification(text)
725
+ && text && !isInternalRuntimeNotificationText(text);
726
+ if (foreignUser) taken.push(text);
727
+ else kept.push(entry);
728
+ }
729
+ if (taken.length === 0) return undefined;
730
+ if (kept.length > 0) next.sessions[sessionId] = kept;
731
+ else {
732
+ delete next.sessions[sessionId];
733
+ if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
734
+ }
735
+ next.updatedAt = Date.now();
736
+ return next;
737
+ }, { compact: true, lock: true, mode: PENDING_MESSAGES_MODE, fsync: false });
738
+ } catch (err) {
739
+ try { process.stderr.write(`[session] foreign-injection drain failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
740
+ return [];
741
+ }
742
+ return taken;
743
+ }
744
+
641
745
  export function drainPendingMessages(sessionId) {
642
746
  const q = _sessionPendingMessages.get(sessionId);
643
747
  const memory = q && q.length > 0 ? q.slice() : [];
@@ -133,6 +133,8 @@ export function markSessionAskStart(id) {
133
133
  entry.lastToolCall = null;
134
134
  entry.toolStartedAt = null;
135
135
  entry.toolSelfDeadlineMs = null;
136
+ entry.toolOutputTail = null;
137
+ entry.toolOutputTailAt = null;
136
138
  entry.lastError = null;
137
139
  // A new ask starts a fresh turn lifecycle — clear any stale empty-final
138
140
  // classification from the prior turn so inspectBridgeEntry doesn't keep
@@ -238,6 +240,9 @@ export function markSessionToolCall(id, toolName, selfDeadlineMs) {
238
240
  const entry = _touchRuntime(id);
239
241
  entry.stage = 'tool_running';
240
242
  entry.lastToolCall = toolName || null;
243
+ // A new tool call invalidates the previous call's live-output tail.
244
+ entry.toolOutputTail = null;
245
+ entry.toolOutputTailAt = null;
241
246
  // Self-enforced deadline (ms) for tools that kill themselves at a known
242
247
  // budget (shell timeout / task wait). The watchdog raises the tool-running
243
248
  // ceiling to this + grace instead of aborting at toolRunningMs. Null/<=0
@@ -256,6 +261,18 @@ export function markSessionToolCall(id, toolName, selfDeadlineMs) {
256
261
  publishHeartbeat(id, entry.toolStartedAt);
257
262
  _startToolActivityHeartbeat(id);
258
263
  }
264
+ // Live shell-output tail for the CURRENT running tool. Written by the shell
265
+ // tool's onOutputTail timer (~1 s cadence), read by engine transcript
266
+ // consumers via getSessionProgressSnapshot. Never creates a runtime entry —
267
+ // a settled/unknown session ignores late writes.
268
+ export function markSessionToolOutputTail(id, tail) {
269
+ if (!id) return;
270
+ const entry = _runtimeState.get(id);
271
+ if (!entry) return;
272
+ const text = typeof tail === 'string' ? tail : String(tail ?? '');
273
+ entry.toolOutputTail = text ? text.slice(-4000) : null;
274
+ entry.toolOutputTailAt = Date.now();
275
+ }
259
276
  // Parent AbortSignal listeners are dropped on askSession unwind (finally /
260
277
  // terminal return) and on error/cancel/close — not in markSessionDone, which
261
278
  // also runs between queued follow-up turns within one ask.
@@ -383,6 +400,8 @@ export function getSessionProgressSnapshot(sessionId) {
383
400
  toolStartedAt: entry.toolStartedAt || 0,
384
401
  currentTool: entry.lastToolCall || null,
385
402
  toolSelfDeadlineMs: entry.toolSelfDeadlineMs || 0,
403
+ toolOutputTail: entry.toolOutputTail || null,
404
+ toolOutputTailAt: entry.toolOutputTailAt || 0,
386
405
  lastProgressAt: entry.lastProgressAt || 0,
387
406
  updatedAt: entry.updatedAt || 0,
388
407
  hasFirstActivity: Boolean(firstSemanticAt && (!askStartedAt || firstSemanticAt >= askStartedAt)),