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
@@ -2,7 +2,7 @@
2
2
  // Session read + mutate CRUD extracted verbatim from manager.mjs: lookup
3
3
  // (getSession/listSessions/findSessionByScopeKey), message clear (with
4
4
  // clear-fork), manual compaction, status update, and metric flush.
5
- import { loadSession, saveSessionAsync, setLiveSession, listStoredSessionSummaries } from '../store.mjs';
5
+ import { loadSession, saveSessionAsync, setLiveSession, evictLiveSession, listStoredSessionSummaries } from '../store.mjs';
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';
@@ -125,8 +125,8 @@ export async function clearSessionMessages(sessionId, options = {}) {
125
125
  }
126
126
  }
127
127
  const afterMessageTokens = estimateMessagesTokens(keep);
128
- const beforeTokens = estimateTranscriptContextUsage(messages, session.tools || []);
129
- const afterTokens = estimateTranscriptContextUsage(keep, session.tools || []);
128
+ const beforeTokens = estimateTranscriptContextUsage(messages, session.tools || [], { provider: session.provider });
129
+ const afterTokens = estimateTranscriptContextUsage(keep, session.tools || [], { provider: session.provider });
130
130
  const now = Date.now();
131
131
  // --- Fork the outgoing transcript to a separate resumable session BEFORE
132
132
  // the wipe below. Runs for every clear path (plain /clear, auto-clear,
@@ -174,7 +174,14 @@ export async function clearSessionMessages(sessionId, options = {}) {
174
174
  };
175
175
  delete fork.liveTurnMessages;
176
176
  setLiveSession(fork);
177
- void saveSessionAsync(fork).catch((err) => {
177
+ void saveSessionAsync(fork).then(() => {
178
+ // The fork is a cold snapshot kept for /resume. Once durable on
179
+ // disk it must not pin a full transcript copy (image bytes
180
+ // included) in the same-process cache for the rest of the
181
+ // process lifetime — this was the largest _liveSessions leak
182
+ // (one whole conversation retained per clear).
183
+ evictLiveSession(forkId);
184
+ }).catch((err) => {
178
185
  try { process.stderr.write(`[session] clear-fork save failed (sess=${forkId}): ${err?.message || err}\n`); } catch { /* best-effort */ }
179
186
  });
180
187
  } catch (err) {
@@ -227,15 +234,19 @@ export async function compactSessionMessages(sessionId) {
227
234
  const result = await runSessionCompaction(session, {
228
235
  mode: 'manual',
229
236
  force: true,
230
- // /compact is a direct reduction of the active session transcript.
231
- // Do not re-ingest/search Memory (recall-fasttrack) before summarizing.
232
- compactType: DEFAULT_COMPACT_TYPE,
233
- // Older source history uses the same pure-conversation filter as
234
- // Memory ingest_session; protected system context and recent turns are
235
- // still preserved separately by semantic compaction.
237
+ // No compactType/model override: follow the hard-locked per-session
238
+ // type (main = recall-fasttrack, agent = semantic). The former forced
239
+ // semantic + session.model here ran every manual /compact as a
240
+ // flagship-model summary call (27-38s measured); fasttrack is local
241
+ // (ingest + digest) and semantic remains only the degraded fallback
242
+ // inside the runner, where resolveSemanticSummaryModel downshifts the
243
+ // summary model.
244
+ // If the semantic fallback does run, filter older source history
245
+ // through the same pure-conversation filter as Memory ingest_session;
246
+ // protected system context and recent turns are still preserved
247
+ // separately by semantic compaction.
236
248
  filterOldHistoryForIngest: true,
237
249
  provider: getProvider(session.provider),
238
- model: session.model,
239
250
  sessionId,
240
251
  signal: getSessionAbortSignal(sessionId),
241
252
  });
@@ -6,7 +6,8 @@
6
6
  import { getProvider } from '../../providers/registry.mjs';
7
7
  import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
8
8
  import { collectPromptSkillsCached, buildSkillManifest, composeSystemPrompt } from '../../context/collect.mjs';
9
- import { saveSession, saveSessionAsync, loadSession, setLiveSession } from '../store.mjs';
9
+ import { saveSession, saveSessionAsync, saveSessionAsyncDeferred, loadSession, setLiveSession, readSessionHeartbeatMtime, readSessionPresenceMtime, isSessionPresenceOwnerDead, deleteSessionPresence } from '../store.mjs';
10
+ import { _getRuntimeEntry } from './runtime-liveness.mjs';
10
11
  import { isAgentOwner } from '../../agent-owner.mjs';
11
12
  import { getHiddenAgent } from '../../internal-agents.mjs';
12
13
  import { loadConfig } from '../../config.mjs';
@@ -481,8 +482,109 @@ export function updateSessionRoute(id, route = {}) {
481
482
  }
482
483
 
483
484
  // --- resume (reload tools for a stored session) ---
484
- export async function resumeSession(sessionId, preset, options = {}) {
485
+ // Attach-on-resume guard: resuming a session that another live process is
486
+ // ACTIVELY driving right now must not create a second writer on the same
487
+ // file — that split-brain silently freezes one side's transcript (generation
488
+ // ownership drops the loser's saves). Such a resume ATTACHES instead: the
489
+ // caller gets the live transcript flagged remoteAttached, its submits are
490
+ // persisted into the shared pending spool (the owner's injection poller runs
491
+ // them as normal user turns), and its view refreshes from disk. ONE writer,
492
+ // ONE transcript, every surface talking into the same conversation. Idle
493
+ // sessions keep the normal single-identity handoff.
494
+ const ACTIVE_OWNER_HB_FRESH_MS = 2 * 60 * 1000; // heartbeat freshness window
495
+ const PREPARED_RESUME_LIMIT = 8;
496
+ const _preparedResumes = new Map();
497
+
498
+ function _prepareResumeTools(session, preset) {
499
+ const ownerIsAgent = isAgentOwner(session);
500
+ const skills = ownerIsAgent ? [] : collectPromptSkillsCached(session.cwd);
501
+ let toolSpec = ownerIsAgent ? 'full' : (preset || session.preset || 'full');
502
+ const agentRuntime = getAgentRuntimeSync();
503
+ if (session.profileId && agentRuntime?.getProfile) {
504
+ try {
505
+ const profile = agentRuntime.getProfile(session.profileId);
506
+ if (!ownerIsAgent && Array.isArray(profile?.tools)) toolSpec = profile.tools;
507
+ } catch { /* ignore lookup failures, keep preset fallback */ }
508
+ }
509
+ let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsAgentSession: ownerIsAgent });
510
+ if (ownerIsAgent) {
511
+ toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, session.toolPermission, session.agent || null);
512
+ }
513
+ return {
514
+ session,
515
+ preset,
516
+ toolSpec,
517
+ ownerIsAgent,
518
+ tools: finalizeSessionToolList(toolsForRouting, {
519
+ schemaAllowedTools: Array.isArray(session.schemaAllowedTools) ? session.schemaAllowedTools : null,
520
+ disallowedTools: getHiddenAgent(session.agent || null) ? ['Skill'] : null,
521
+ ownerIsAgent,
522
+ resolvedAgent: session.agent || null,
523
+ }),
524
+ };
525
+ }
526
+
527
+ function _rememberPreparedResume(sessionId, prepared) {
528
+ _preparedResumes.delete(sessionId);
529
+ _preparedResumes.set(sessionId, prepared);
530
+ while (_preparedResumes.size > PREPARED_RESUME_LIMIT) {
531
+ const oldest = _preparedResumes.keys().next().value;
532
+ if (oldest === undefined) break;
533
+ _preparedResumes.delete(oldest);
534
+ }
535
+ }
536
+
537
+ // Desktop session-row hover/idle prefetch. loadSession validates the atomic
538
+ // file signature before reusing its parsed object, so a later external write
539
+ // naturally misses this preparation and rebuilds from the new session object.
540
+ export function prefetchSession(sessionId, preset = 'full') {
541
+ const session = loadSession(sessionId);
542
+ if (!session || session.closed === true) return false;
543
+ _rememberPreparedResume(sessionId, _prepareResumeTools(session, preset));
544
+ return true;
545
+ }
546
+
547
+ function _isActivelyOwnedElsewhere(session, sessionId) {
548
+ // This process already owns the runtime for the id — switching back to
549
+ // one of our own sessions (desktop tab switch, TUI /resume) never attaches.
550
+ const entry = _getRuntimeEntry(sessionId);
551
+ if (entry && entry.closed !== true) return false;
552
+ // A FORCE-KILLED owner leaves its `.own` sidecar behind looking fresh.
553
+ // The recorded pid is authoritative: when it no longer exists, the owner
554
+ // is gone — clear the stale sidecar and resume with normal ownership
555
+ // instead of viewer-attaching into a spool nobody drains.
556
+ if (isSessionPresenceOwnerDead(sessionId)) {
557
+ deleteSessionPresence(sessionId);
558
+ return false;
559
+ }
560
+ // Heartbeats publish only while a turn is running (≤5s cadence) and the
561
+ // sidecar is deleted on detach/close, so freshness here means another
562
+ // process is mid-conversation on this session right now. Presence (`.own`)
563
+ // covers the idle gaps between turns: a live interactive surface keeps
564
+ // refreshing it (~20s) for its CURRENT session, so cross-opening an
565
+ // idle-but-open session still attaches as a viewer instead of splitting
566
+ // ownership into two writers that clobber each other's saves.
567
+ const lastHb = Math.max(
568
+ Number(readSessionHeartbeatMtime(sessionId)) || 0,
569
+ Number(session.lastHeartbeatAt) || 0,
570
+ Number(readSessionPresenceMtime(sessionId)) || 0,
571
+ );
572
+ return lastHb > 0 && Date.now() - lastHb <= ACTIVE_OWNER_HB_FRESH_MS;
573
+ }
574
+
575
+ // Viewer self-heal probe: true when a re-resume of this session would NO
576
+ // LONGER attach (owner dead or every liveness signal stale). Attached
577
+ // surfaces poll this so a dead owner promotes the viewer instead of leaving
578
+ // it spooling messages to nobody. Single source of truth: the same guard
579
+ // the resume path uses.
580
+ export function isSessionOwnerGone(sessionId) {
485
581
  const session = loadSession(sessionId);
582
+ if (!session) return false;
583
+ return !_isActivelyOwnedElsewhere(session, sessionId);
584
+ }
585
+
586
+ export async function resumeSession(sessionId, preset, options = {}) {
587
+ let session = loadSession(sessionId);
486
588
  if (!session)
487
589
  return null;
488
590
  // Resuming a closed session is a resurrection attempt — refuse. The guarded
@@ -505,34 +607,40 @@ export async function resumeSession(sessionId, preset, options = {}) {
505
607
  session.desktopSession = expectedDesktop;
506
608
  }
507
609
  if (!session.owner) session.owner = 'user';
508
- const oldTools = session.tools || [];
509
- const ownerIsAgent = isAgentOwner(session);
510
- const skills = ownerIsAgent ? [] : collectPromptSkillsCached(session.cwd);
511
- let toolSpec = ownerIsAgent ? 'full' : (preset || session.preset || 'full');
512
- const agentRuntime = getAgentRuntimeSync();
513
- if (session.profileId && agentRuntime?.getProfile) {
514
- try {
515
- const profile = agentRuntime.getProfile(session.profileId);
516
- if (!ownerIsAgent && Array.isArray(profile?.tools)) toolSpec = profile.tools;
517
- } catch { /* ignore lookup failures, keep preset fallback */ }
518
- }
519
- let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsAgentSession: ownerIsAgent });
520
- if (ownerIsAgent) {
521
- toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, session.toolPermission, session.agent || null);
610
+ if (_isActivelyOwnedElsewhere(session, sessionId)) {
611
+ // ATTACH (viewer mode, zero ownership): hand back the live transcript
612
+ // under the SAME id, flagged remoteAttached. No tool refresh, no save,
613
+ // no generation claim the session file remains exclusively the
614
+ // owner's. session-turn-api routes this surface's submits into the
615
+ // shared pending spool instead of running a local turn.
616
+ const attached = { ...session, remoteAttached: true };
617
+ delete attached.liveTurnMessages;
618
+ delete attached.toolApprovalHook;
619
+ if (process.env.MIXDOG_DEBUG_SESSION_LOG) {
620
+ try { process.stderr.write(`[session] attach-on-resume: ${sessionId} is live elsewhere → viewer attach\n`); } catch { /* best-effort */ }
621
+ }
622
+ return attached;
522
623
  }
624
+ const oldTools = session.tools || [];
625
+ const cached = _preparedResumes.get(sessionId);
626
+ _preparedResumes.delete(sessionId);
627
+ const prepared = cached?.session === session && cached?.preset === preset
628
+ ? cached
629
+ : _prepareResumeTools(session, preset);
523
630
  // Keep the persisted tool mode in sync on resume (see createSession note).
524
- session.toolSpec = toolSpec;
525
- session.tools = finalizeSessionToolList(toolsForRouting, {
526
- schemaAllowedTools: Array.isArray(session.schemaAllowedTools) ? session.schemaAllowedTools : null,
527
- disallowedTools: getHiddenAgent(session.agent || null) ? ['Skill'] : null,
528
- ownerIsAgent,
529
- resolvedAgent: session.agent || null,
530
- });
631
+ session.toolSpec = prepared.toolSpec;
632
+ session.tools = prepared.tools;
531
633
  const newTools = session.tools;
532
634
  const missing = oldTools.filter(t => !newTools.find(n => n.name === t.name));
533
635
  if (missing.length) {
534
636
  process.stderr.write(`[session] Warning: ${missing.length} tools no longer available: ${missing.map(t => t.name).join(', ')}\n`);
535
637
  }
536
- await saveSessionAsync(session, { expectedGeneration: session.generation });
638
+ // The live session already owns the refreshed tools and desktop scope.
639
+ // Defer the structured clone + worker round-trip so opening a conversation
640
+ // is not blocked on persisting the same in-memory state back to disk.
641
+ void saveSessionAsyncDeferred(session, { expectedGeneration: session.generation })
642
+ .catch((err) => {
643
+ try { process.stderr.write(`[session] resume save failed: ${err?.message || err}\n`); } catch {}
644
+ });
537
645
  return session;
538
646
  }
@@ -50,7 +50,7 @@ export function recordStandaloneStatusTelemetry(session, result, durationMs) {
50
50
  // as afterTokens. This lights up summarizeGatewayUsage's estimate-based
51
51
  // contextUsedPct branch (provider input_tokens swing wildly / unbounded on
52
52
  // e.g. OpenAI gpt-5.5), and lets a genuine >100% pass through.
53
- const _estTokens = estimateTranscriptContextUsage(session.messages, session.tools || []);
53
+ const _estTokens = estimateTranscriptContextUsage(session.messages, session.tools || [], { provider: session.provider });
54
54
  const _compactArg = { ...(result.compact && typeof result.compact === 'object' ? result.compact : {}), afterTokens: _estTokens };
55
55
  try {
56
56
  const summary = {
@@ -43,6 +43,7 @@ export {
43
43
  markSessionAskStart,
44
44
  markSessionStreamDelta,
45
45
  markSessionToolCall,
46
+ markSessionToolOutputTail,
46
47
  markSessionDone,
47
48
  markSessionEmptyFinal,
48
49
  markSessionError,
@@ -62,10 +63,17 @@ export { previewSessionTools } from './manager/tool-resolution.mjs';
62
63
  export {
63
64
  _mergePendingMessageEntries,
64
65
  enqueuePendingMessage,
66
+ enqueueRemotePendingMessage,
67
+ drainForeignUserInjections,
65
68
  drainPendingMessages,
66
69
  markCompletionEntry,
67
70
  COMPLETION_NOTIFICATION_KIND,
71
+ pendingMessagesSpoolPath,
68
72
  } from './manager/pending-messages.mjs';
73
+ // Interactive-presence sidecar (idle-open ownership signal for the
74
+ // attach-on-resume guard) — resolved through the manager facade so the
75
+ // session-runtime turn API reaches it as mgr.publishSessionPresence.
76
+ export { publishSessionPresence, deleteSessionPresence } from './store.mjs';
69
77
  export { isInternalRuntimeNotificationText as _isInternalRuntimeNotificationText } from './manager/prompt-utils.mjs';
70
78
 
71
79
  // ── Usage-metrics surface — re-exported unchanged so loop.mjs / smoke scripts
@@ -104,7 +112,13 @@ export const _preserveBufferConfigFields = preserveBufferConfigFields;
104
112
  // ── Session lifecycle / ask / crud / close / cleanup ──────────────────────
105
113
  export { SessionClosedError } from './manager/session-errors.mjs';
106
114
  export { setAgentRuntime } from './manager/agent-runtime-singleton.mjs';
107
- export { createSession, updateSessionRoute, resumeSession } from './manager/session-lifecycle.mjs';
115
+ export {
116
+ createSession,
117
+ updateSessionRoute,
118
+ prefetchSession,
119
+ resumeSession,
120
+ isSessionOwnerGone,
121
+ } from './manager/session-lifecycle.mjs';
108
122
  export { askSession, _api_call_with_interrupt } from './manager/ask-session.mjs';
109
123
  export {
110
124
  _sessionMessagesAdvancedBeyondCompactedOutgoing,
@@ -119,5 +133,6 @@ export {
119
133
  updateSessionStatus,
120
134
  flushSessionMetrics,
121
135
  } from './manager/session-crud.mjs';
136
+ export { deleteSession } from './store.mjs';
122
137
  export { closeSession, abortSessionTurn } from './manager/session-close.mjs';
123
138
  export { sweepTombstones, startIdleCleanup, stopIdleCleanup } from './manager/idle-cleanup.mjs';
@@ -13,6 +13,7 @@ import {
13
13
  rememberCompactTelemetry,
14
14
  emitCompactEvent,
15
15
  compactEventType,
16
+ resolveSemanticSummaryModel,
16
17
  } from './loop/compact-policy.mjs';
17
18
  import {
18
19
  pruneToolOutputs,
@@ -32,6 +33,25 @@ import { traceAgentCompact, messagePrefixHash } from '../agent-trace.mjs';
32
33
  import { invalidateProviderRequestToolsScope } from '../../../../session-runtime/provider-request-tools.mjs';
33
34
  import { bumpUsageMetricsEpoch } from './manager.mjs';
34
35
 
36
+ const RECOVERED_ERROR_MESSAGE_MAX_CHARS = 300;
37
+ const ANSI_ESCAPE_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))/g;
38
+
39
+ function compactRecoveredError(err) {
40
+ const rawMessage = err?.message || String(err);
41
+ const message = String(rawMessage)
42
+ .replace(ANSI_ESCAPE_RE, '')
43
+ .replace(/[\r\n]+/g, ' ')
44
+ .replace(/\s+/g, ' ')
45
+ .trim();
46
+ return {
47
+ // Preserve machine-readable codes exactly; only the display payload is bounded.
48
+ code: err?.code ?? null,
49
+ message: message.length > RECOVERED_ERROR_MESSAGE_MAX_CHARS
50
+ ? `${message.slice(0, 299)}…`
51
+ : message,
52
+ };
53
+ }
54
+
35
55
  export async function runPreSendCompactPass(state) {
36
56
  const {
37
57
  provider, messages, model, requestTools, sessionRef, sessionId, cwd, opts, signal,
@@ -47,21 +67,6 @@ export async function runPreSendCompactPass(state) {
47
67
  // a best-effort JSON.stringify length — close enough to the
48
68
  // payload we hand the provider for prefix-cache analysis.
49
69
  const beforeCount = messages.length;
50
- // beforeBytes is only ever read inside the shouldCompact telemetry
51
- // branches below. Computing it eagerly serialized the ENTIRE message
52
- // array (Buffer.byteLength(JSON.stringify(messages))) on every loop
53
- // iteration — including the common no-compact path — which grows
54
- // linearly with transcript size and was a real per-iteration drag.
55
- // Defer it to a memoized lazy getter so the no-compact path pays
56
- // nothing and the compact path still gets an exact byte count once.
57
- let _beforeBytes;
58
- let _beforeBytesComputed = false;
59
- const getBeforeBytes = () => {
60
- if (_beforeBytesComputed) return _beforeBytes;
61
- _beforeBytesComputed = true;
62
- try { _beforeBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { _beforeBytes = null; }
63
- return _beforeBytes;
64
- };
65
70
  const messageTokensEst = estimateMessagesTokensSafe(messages);
66
71
  const reactivePending = reactiveOverflowRetryPending === true;
67
72
  const pressureTokens = compactionTelemetryPressureTokens(messageTokensEst, compactPolicy, {
@@ -93,6 +98,14 @@ export async function runPreSendCompactPass(state) {
93
98
  pressureTokens,
94
99
  });
95
100
  } else {
101
+ // Snapshot BEFORE mutating the live array below. Keeping this
102
+ // inside the compact branch preserves the no-compact fast path
103
+ // while ensuring compact_meta reports real savings and the true
104
+ // input prefix rather than the replacement transcript.
105
+ let beforeBytes = null;
106
+ let beforePrefixHash = null;
107
+ try { beforeBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { /* best-effort telemetry */ }
108
+ try { beforePrefixHash = messagePrefixHash(messages); } catch { /* best-effort telemetry */ }
96
109
  try { await opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
97
110
  const compactStartedAt = Date.now();
98
111
  // Clear the one-shot reactive-overflow flag now that this
@@ -125,6 +138,7 @@ export async function runPreSendCompactPass(state) {
125
138
  let semanticCompactError = null;
126
139
  let recallFastTrackResult = null;
127
140
  let recallFastTrackError = null;
141
+ let recoveredError = null;
128
142
  try {
129
143
  let compactInputMessages = messages;
130
144
  if (compactPolicy.prune) {
@@ -164,12 +178,13 @@ export async function runPreSendCompactPass(state) {
164
178
  semanticCompactResult = await semanticCompactMessages(
165
179
  provider,
166
180
  compactInputMessages,
167
- model,
181
+ resolveSemanticSummaryModel(sessionRef, { budgetTokens: compactBudgetTokens }) || model,
168
182
  compactBudgetTokens,
169
183
  {
170
184
  reserveTokens: compactPolicy.reserveTokens,
171
185
  providerName: sessionRef.provider || provider?.name || null,
172
186
  sessionId,
187
+ cwd: sessionRef?.cwd,
173
188
  signal,
174
189
  sendOpts: opts,
175
190
  promptCacheKey: opts.promptCacheKey || null,
@@ -259,6 +274,7 @@ export async function runPreSendCompactPass(state) {
259
274
  } catch { /* fall through to overflow escalation */ }
260
275
  }
261
276
  if (compacted !== undefined) {
277
+ recoveredError = compactRecoveredError(compactErr);
262
278
  try {
263
279
  process.stderr.write(
264
280
  `[loop] compact fallback prune recovered (sess=${sessionId || 'unknown'}): ` +
@@ -317,11 +333,11 @@ export async function runPreSendCompactPass(state) {
317
333
  compact_type: compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE,
318
334
  prune_count: pruneCount,
319
335
  compact_changed: false,
320
- input_prefix_hash: messagePrefixHash(messages),
336
+ input_prefix_hash: beforePrefixHash,
321
337
  before_count: beforeCount,
322
338
  after_count: messages.length,
323
- before_bytes: getBeforeBytes(),
324
- after_bytes: getBeforeBytes(),
339
+ before_bytes: beforeBytes,
340
+ after_bytes: beforeBytes,
325
341
  context_window: compactPolicy.contextWindow,
326
342
  budget_tokens: compactPolicy.boundaryTokens,
327
343
  boundary_tokens: compactPolicy.boundaryTokens,
@@ -448,10 +464,10 @@ export async function runPreSendCompactPass(state) {
448
464
  compact_type: compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE,
449
465
  prune_count: pruneCount,
450
466
  compact_changed: compactChanged || summaryChanged,
451
- input_prefix_hash: messagePrefixHash(messages),
467
+ input_prefix_hash: beforePrefixHash,
452
468
  before_count: beforeCount,
453
469
  after_count: messages.length,
454
- before_bytes: getBeforeBytes(),
470
+ before_bytes: beforeBytes,
455
471
  after_bytes: afterBytes,
456
472
  context_window: compactPolicy.contextWindow,
457
473
  budget_tokens: compactPolicy.boundaryTokens,
@@ -464,9 +480,11 @@ export async function runPreSendCompactPass(state) {
464
480
  duration_ms: compactDurationMs,
465
481
  provider: sessionRef.provider,
466
482
  model: sessionRef.model || model,
483
+ recovered_error: recoveredError,
467
484
  details: {
468
485
  semantic: semanticCompactResult?.diagnostics || null,
469
486
  recallFastTrack: recallFastTrackResult?.diagnostics || null,
487
+ recovered_error_code: recoveredError?.code ?? null,
470
488
  },
471
489
  });
472
490
  emitCompactEvent(opts, {
@@ -6,6 +6,24 @@
6
6
  * race protection is preserved by ordering.
7
7
  */
8
8
  import { parentPort } from 'worker_threads';
9
+
10
+ // Route every stray stdout/stderr print through the parent as a `{ __log }`
11
+ // message instead of worker stdio. Piped worker stdio (stdout:true) keeps the
12
+ // parent's event loop alive for the worker's lifetime once read, and default
13
+ // (copied) stdio bypasses the TUI's process.stderr.write guard and prints
14
+ // over the terminal frame. postMessage does neither: the parent writes the
15
+ // text through its own guarded stderr.
16
+ function _forwardWrite(chunk, encoding, callback) {
17
+ try { parentPort.postMessage({ __log: typeof chunk === 'string' ? chunk : String(chunk) }); } catch { /* best-effort */ }
18
+ const cb = typeof encoding === 'function' ? encoding : callback;
19
+ if (typeof cb === 'function') cb();
20
+ return true;
21
+ }
22
+ try {
23
+ process.stdout.write = _forwardWrite;
24
+ process.stderr.write = _forwardWrite;
25
+ } catch { /* best-effort: worker still functions with default stdio */ }
26
+
9
27
  import { _saveSessionSync } from './store.mjs';
10
28
 
11
29
  parentPort.on('message', ({ session, opts, reqId }) => {
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, unlinkSync } from 'fs';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'fs';
2
2
  import * as fsp from 'fs/promises';
3
3
  import { join } from 'path';
4
4
  import { getPluginData } from '../../config.mjs';
@@ -50,3 +50,93 @@ export function deleteHeartbeat(id) {
50
50
  try { unlinkSync(_heartbeatPath(id)); } catch { /* ignore */ }
51
51
  _hbLastAt.delete(id);
52
52
  }
53
+
54
+ // Batch reader for session catalogs. Scanning only the handful of `.hb`
55
+ // sidecars avoids one failed stat per historical session while still exposing
56
+ // cross-process turn activity to desktop/TUI resume lists.
57
+ export function listSessionHeartbeatMtimes() {
58
+ const result = new Map();
59
+ try {
60
+ const dir = getStoreDir();
61
+ for (const filename of readdirSync(dir)) {
62
+ if (!filename.endsWith('.hb')) continue;
63
+ const id = filename.slice(0, -3);
64
+ if (!/^[A-Za-z0-9_-]+$/.test(id)) continue;
65
+ try {
66
+ const mtime = statSync(join(dir, filename)).mtimeMs || 0;
67
+ if (mtime > 0) result.set(id, mtime);
68
+ } catch { /* sidecar disappeared after readdir */ }
69
+ }
70
+ } catch { /* catalog activity is best-effort */ }
71
+ return result;
72
+ }
73
+
74
+ // ── Interactive presence publish ──────────────────────────
75
+ // `<id>.own` marks a live interactive surface (TUI/desktop engine) HOLDING
76
+ // the session open — including idle time between turns. Kept separate from
77
+ // `.hb` (turn-activity signal consumed by the status aggregator and deleted
78
+ // at turn end) so idle presence never fakes a "running" badge. Consumed by
79
+ // the attach-on-resume guard (_isActivelyOwnedElsewhere): fresh presence
80
+ // means a cross-open must attach as a viewer instead of claiming a second
81
+ // writer on the same session file (dual-writer last-save-wins clobbering).
82
+ // Refreshed by the engine share tick (~3s call, throttled here) and cleared
83
+ // on session switch/dispose; staleness covers crashes.
84
+ const _PRESENCE_THROTTLE_MS = 20_000;
85
+ const _presenceLastAt = new Map();
86
+
87
+ function _presencePath(id) {
88
+ if (!id || typeof id !== 'string' || !/^[A-Za-z0-9_-]+$/.test(id)) {
89
+ throw new Error(`[session-store] invalid session id: ${JSON.stringify(id)}`);
90
+ }
91
+ return join(getStoreDir(), `${id}.own`);
92
+ }
93
+
94
+ export function publishSessionPresence(id, ts) {
95
+ if (!id) return;
96
+ const now = ts || Date.now();
97
+ const last = _presenceLastAt.get(id) || 0;
98
+ if (now - last < _PRESENCE_THROTTLE_MS) return;
99
+ const target = _presencePath(id);
100
+ _presenceLastAt.set(id, now);
101
+ void fsp.writeFile(target, `${now}\n${process.pid}\n`, 'utf8').catch(() => {});
102
+ }
103
+
104
+ export function deleteSessionPresence(id) {
105
+ if (!id) return;
106
+ try { unlinkSync(_presencePath(id)); } catch { /* ignore */ }
107
+ _presenceLastAt.delete(id);
108
+ }
109
+
110
+ export function readSessionPresenceMtime(id) {
111
+ if (!id) return 0;
112
+ try {
113
+ const path = _presencePath(id);
114
+ return existsSync(path) ? (statSync(path).mtimeMs || 0) : 0;
115
+ } catch {
116
+ return 0;
117
+ }
118
+ }
119
+
120
+ // A force-killed owner never clears its presence sidecar, so bare mtime
121
+ // freshness would lock every resume inside the window into viewer mode with
122
+ // nobody draining the shared spool. The `.own` payload records the holder's
123
+ // pid; a recorded-but-dead pid is authoritative proof the owner is gone.
124
+ export function isSessionPresenceOwnerDead(id) {
125
+ if (!id) return false;
126
+ let pid = 0;
127
+ try {
128
+ const path = _presencePath(id);
129
+ if (!existsSync(path)) return false;
130
+ pid = Number(String(readFileSync(path, 'utf8')).split('\n')[1]) || 0;
131
+ } catch {
132
+ return false;
133
+ }
134
+ if (!pid) return false;
135
+ try {
136
+ process.kill(pid, 0);
137
+ return false;
138
+ } catch (error) {
139
+ // ESRCH: no such process. EPERM means the pid exists (alive).
140
+ return error?.code === 'ESRCH';
141
+ }
142
+ }
@@ -9,8 +9,13 @@ import { mkdirSync } from 'fs';
9
9
  import { join } from 'path';
10
10
  import { getPluginData } from '../config.mjs';
11
11
  import { updateJsonAtomicSync, updateJsonAtomic, writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
12
+ import {
13
+ cleanSessionPreview,
14
+ isSessionPreviewNoise,
15
+ sessionMessageText,
16
+ } from '../../../../session-runtime/session-text.mjs';
12
17
 
13
- export const SESSION_SUMMARY_INDEX_VERSION = 1;
18
+ export const SESSION_SUMMARY_INDEX_VERSION = 2;
14
19
 
15
20
  export function summaryIndexPath() {
16
21
  const dir = getPluginData();
@@ -19,52 +24,28 @@ export function summaryIndexPath() {
19
24
  }
20
25
 
21
26
  function _messageText(content) {
22
- if (typeof content === 'string') return content;
23
- if (Array.isArray(content)) {
24
- return content.map((part) => {
25
- if (typeof part === 'string') return part;
26
- if (part?.type === 'text') return part.text || '';
27
- if (typeof part?.text === 'string') return part.text;
28
- try { return JSON.stringify(part); } catch { return ''; }
29
- }).filter(Boolean).join('\n');
30
- }
31
- if (content && typeof content === 'object') {
32
- if (typeof content.text === 'string') return content.text;
33
- try { return JSON.stringify(content); } catch { return ''; }
34
- }
35
- return '';
27
+ return sessionMessageText(content);
36
28
  }
37
29
 
38
30
  export function _cleanPreview(text, max = 240) {
39
- const value = String(text || '').replace(/\s+/g, ' ').trim();
31
+ const value = cleanSessionPreview(text, max);
40
32
  return value.length > max ? value.slice(0, max).replace(/\s+\S*$/, '').trim() : value;
41
33
  }
42
34
 
43
35
  function _isPreviewNoise(text) {
44
- const value = String(text || '').trim();
45
- if (!value) return true;
46
- if (value.startsWith('<system-reminder>')) return true;
47
- if (/^Reference files:/i.test(value)) return true;
48
- return false;
36
+ return isSessionPreviewNoise(text);
49
37
  }
50
38
 
51
39
  function _sessionPreview(session) {
52
40
  const messages = Array.isArray(session?.messages) ? session.messages : [];
53
- let first = '';
54
41
  for (const m of messages) {
55
42
  if (m?.role !== 'user') continue;
56
- const text = _cleanPreview(_messageText(m.content));
57
- if (_isPreviewNoise(text)) continue;
58
- if (!first) first = text;
43
+ const raw = _messageText(m.content);
44
+ if (_isPreviewNoise(raw)) continue;
45
+ const text = _cleanPreview(raw);
46
+ if (text) return text;
59
47
  }
60
- for (let i = messages.length - 1; i >= 0; i -= 1) {
61
- const m = messages[i];
62
- if (m?.role !== 'user') continue;
63
- const text = _cleanPreview(_messageText(m.content));
64
- if (_isPreviewNoise(text)) continue;
65
- return text || first;
66
- }
67
- return first;
48
+ return '';
68
49
  }
69
50
 
70
51
  function _sessionMessageCount(session) {