mixdog 0.9.87 → 0.9.89

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 (167) hide show
  1. package/package.json +13 -7
  2. package/scripts/tool-failures.mjs +60 -24
  3. package/src/agents/heavy-worker/AGENT.md +3 -4
  4. package/src/agents/worker/AGENT.md +1 -2
  5. package/src/cli.mjs +8 -0
  6. package/src/defaults/cycle3-review-prompt.md +4 -4
  7. package/src/rules/agent/00-core.md +3 -0
  8. package/src/rules/agent/42-cycle3-agent.md +5 -6
  9. package/src/rules/lead/01-general.md +2 -0
  10. package/src/rules/shared/01-tool.md +29 -35
  11. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +3 -51
  12. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +59 -0
  13. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +67 -0
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +57 -4
  16. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +2 -3
  17. package/src/runtime/agent/orchestrator/config.mjs +29 -11
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/internal-agents.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +0 -24
  21. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +25 -17
  23. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +321 -22
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -22
  25. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +28 -23
  26. package/src/runtime/agent/orchestrator/providers/gemini.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +0 -1
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +0 -1
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +2 -5
  30. package/src/runtime/agent/orchestrator/providers/lib/anthropic-native-blocks.mjs +27 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +329 -0
  33. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +23 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +107 -20
  35. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +4 -3
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +124 -12
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +1 -1
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +110 -75
  41. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +96 -113
  42. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +160 -11
  43. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +18 -1
  44. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +147 -117
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +88 -0
  46. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +50 -8
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +25 -0
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +47 -6
  50. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +485 -24
  51. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +100 -2
  52. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -0
  53. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +80 -14
  54. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +142 -2
  55. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +128 -32
  56. package/src/runtime/agent/orchestrator/session/manager.mjs +5 -1
  57. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +77 -4
  58. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +70 -39
  59. package/src/runtime/agent/orchestrator/session/store/fs-probe.mjs +91 -0
  60. package/src/runtime/agent/orchestrator/session/store/listing.mjs +141 -64
  61. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +253 -0
  62. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +211 -28
  63. package/src/runtime/agent/orchestrator/session/store/save-fault.mjs +313 -0
  64. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +622 -53
  65. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +43 -10
  66. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +53 -8
  67. package/src/runtime/agent/orchestrator/session/store/summary-rebuild-worker.mjs +20 -0
  68. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +113 -9
  69. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +2 -0
  70. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +476 -52
  71. package/src/runtime/agent/orchestrator/session/store.mjs +721 -196
  72. package/src/runtime/agent/orchestrator/session/token-native.mjs +2 -4
  73. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +19 -0
  74. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +4 -6
  75. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  76. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +146 -90
  77. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +6 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +93 -17
  79. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +7 -7
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +6 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +17 -1
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +85 -5
  84. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +52 -0
  85. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +54 -51
  86. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -15
  87. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +132 -8
  88. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +86 -3
  89. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +6 -1
  90. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +210 -38
  91. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +23 -1
  92. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +87 -19
  93. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  94. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -8
  95. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +12 -2
  96. package/src/runtime/media/lanes.mjs +2 -2
  97. package/src/runtime/media/renditions.mjs +101 -17
  98. package/src/runtime/media/renditions.test.mjs +54 -0
  99. package/src/runtime/media/store.mjs +111 -43
  100. package/src/runtime/media/store.test.mjs +53 -11
  101. package/src/runtime/memory/index.mjs +26 -50
  102. package/src/runtime/memory/lib/core-memory-candidates.mjs +2 -7
  103. package/src/runtime/memory/lib/core-memory-store.mjs +2 -9
  104. package/src/runtime/memory/lib/cycle-scheduler.mjs +10 -0
  105. package/src/runtime/memory/lib/embedding-provider.mjs +3 -3
  106. package/src/runtime/memory/lib/embedding-worker.mjs +60 -15
  107. package/src/runtime/memory/lib/http-router.mjs +4 -0
  108. package/src/runtime/memory/lib/ko-morph.mjs +49 -6
  109. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -18
  110. package/src/runtime/memory/lib/memory-cycle2.mjs +3 -3
  111. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -3
  112. package/src/runtime/memory/lib/memory-recall-store.mjs +24 -0
  113. package/src/runtime/memory/lib/query-handlers.mjs +26 -28
  114. package/src/runtime/memory/tool-defs.mjs +3 -3
  115. package/src/runtime/shared/atomic-file.mjs +5 -1
  116. package/src/runtime/shared/child-guardian.mjs +73 -2
  117. package/src/runtime/shared/provider-api-key.mjs +22 -5
  118. package/src/runtime/shared/tool-execution-contract.mjs +53 -0
  119. package/src/session-runtime/config-helpers.mjs +6 -5
  120. package/src/session-runtime/lifecycle-api.mjs +16 -4
  121. package/src/session-runtime/media-api.mjs +31 -16
  122. package/src/session-runtime/prewarm.mjs +2 -24
  123. package/src/session-runtime/runtime-core.mjs +33 -15
  124. package/src/session-runtime/runtime-tunables.mjs +0 -3
  125. package/src/session-runtime/session-lifecycle.mjs +0 -6
  126. package/src/session-runtime/session-title.mjs +228 -0
  127. package/src/session-runtime/session-turn-api.mjs +11 -7
  128. package/src/session-runtime/workflow-agents-api.mjs +16 -0
  129. package/src/session-runtime/workflow.mjs +7 -5
  130. package/src/standalone/agent-tool/job-views.mjs +90 -36
  131. package/src/standalone/agent-tool/spawn-flow.mjs +25 -216
  132. package/src/standalone/agent-tool/worker-index.mjs +8 -0
  133. package/src/standalone/agent-tool/worker-rows.mjs +2 -1
  134. package/src/standalone/agent-tool.mjs +17 -17
  135. package/src/standalone/agent-watchdog-registry.mjs +19 -2
  136. package/src/standalone/channel-daemon.mjs +8 -0
  137. package/src/standalone/explore-tool.mjs +1 -1
  138. package/src/standalone/memory-runtime-proxy.mjs +4 -7
  139. package/src/standalone/projects.mjs +4 -1
  140. package/src/standalone/usage-dashboard.mjs +25 -3
  141. package/src/tui/App.jsx +5 -3
  142. package/src/tui/app/resume-picker.mjs +7 -2
  143. package/src/tui/app/route-pickers.mjs +11 -117
  144. package/src/tui/app/slash-dispatch.mjs +13 -3
  145. package/src/tui/app/transcript-row-estimate.mjs +1 -1
  146. package/src/tui/app/transcript-window.mjs +17 -93
  147. package/src/tui/app/use-transcript-scroll.mjs +32 -2
  148. package/src/tui/app/use-transcript-window.mjs +64 -114
  149. package/src/tui/components/Markdown.jsx +19 -2
  150. package/src/tui/components/Message.jsx +8 -3
  151. package/src/tui/components/ToolExecution.jsx +7 -3
  152. package/src/tui/dist/index.mjs +243 -223
  153. package/src/tui/engine/live-share.mjs +15 -5
  154. package/src/tui/engine/render-timing.mjs +2 -2
  155. package/src/tui/engine/session-api-ext.mjs +74 -4
  156. package/src/tui/engine/session-flow.mjs +3 -1
  157. package/src/tui/engine.mjs +2 -2
  158. package/src/tui/hooks/useSharedTick.mjs +0 -2
  159. package/src/tui/index.jsx +5 -5
  160. package/src/ui/statusline-agents.mjs +44 -11
  161. package/src/vendor/statusline/bin/statusline-route.mjs +26 -3
  162. package/src/workflows/default/WORKFLOW.md +3 -3
  163. package/src/workflows/solo/WORKFLOW.md +5 -3
  164. package/src/runtime/media/index.mjs +0 -18
  165. package/src/runtime/memory/lib/embedding-warmup.mjs +0 -68
  166. package/src/standalone/agent-shard/shard-child.mjs +0 -300
  167. package/src/standalone/agent-shard/shard-pool.mjs +0 -443
@@ -32,7 +32,6 @@ import {
32
32
  listCoreCandidates,
33
33
  promoteCoreCandidate,
34
34
  dismissCoreCandidate,
35
- CORE_SUMMARY_MAX,
36
35
  normalizeCoreInput,
37
36
  } from './core-memory-store.mjs'
38
37
  import { resolveProjectId, resolveProjectScope } from './project-id-resolver.mjs'
@@ -54,15 +53,26 @@ export function createMemoryActionHandlers({
54
53
  startCycle1Run,
55
54
  finalizeCycle2Run,
56
55
  finalizeCycle3Run,
56
+ requestCycle3Review,
57
57
  getSchedulerCycle1InFlight,
58
58
  getCycle2CallLlm,
59
59
  getCycle3CallLlm,
60
60
  ingestTranscriptFile,
61
61
  cwdFromTranscriptPath,
62
+ addCoreImpl = addCore,
62
63
  editCoreImpl = editCore,
63
64
  }) {
64
65
  const DATA_DIR = dataDir
65
66
 
67
+ async function queueCoreReview(reason) {
68
+ if (typeof requestCycle3Review !== 'function') return
69
+ try {
70
+ await requestCycle3Review(reason)
71
+ } catch (err) {
72
+ log?.(`[core-memory] follow-up review queue failed: ${err?.message || err}\n`)
73
+ }
74
+ }
75
+
66
76
  // Whole-action backfill mutex. memory-cycle1's _cycle1InFlight only protects
67
77
  // cycle1; ingest workers (memory-ops-policy.mjs) and cycle2 can still overlap
68
78
  // if a second backfill kicks in (e.g. setup-server timeout + retry). Track the
@@ -634,6 +644,7 @@ export function createMemoryActionHandlers({
634
644
  }
635
645
  if (op === 'promote') {
636
646
  const entry = await promoteCoreCandidate(coreDataDir, args.id, { ...args, scope })
647
+ await queueCoreReview('core-promote')
637
648
  const mergeNote = entry.merged_with ? ` (merged into core id=${entry.merged_with}, sim=${entry.sim})` : ''
638
649
  return { text: `core promoted candidate id=${args.id} → core id=${entry.id}${mergeNote}: ${entry.element}` }
639
650
  }
@@ -646,14 +657,14 @@ export function createMemoryActionHandlers({
646
657
  }
647
658
  // Local trim helper — the manage-block trimOrNull at :1807 is scoped to
648
659
  // that branch and unreachable from here.
649
- // Only the explicit string "common" selects the COMMON pool. Core add
650
- // validates missing, null, and blank project_id below; edits use the
651
- // target entry's stored pool.
660
+ // An explicit project_id wins. Otherwise infer from the active cwd/session
661
+ // and fall back to COMMON. Cycle3 can later reclassify a mis-scoped row.
652
662
  const hasProjectIdKey = Object.prototype.hasOwnProperty.call(args, 'project_id')
653
663
  const projectIdText = typeof args.project_id === 'string' ? args.project_id.trim() : ''
654
664
  const projectId = (() => {
665
+ if (!hasProjectIdKey || !projectIdText) return resolveProjectScope(args.cwd)
655
666
  if (projectIdText.toLowerCase() === 'common') return null
656
- return projectIdText || null
667
+ return projectIdText
657
668
  })()
658
669
  try {
659
670
  if (op === 'add' || op === 'edit') {
@@ -669,17 +680,7 @@ export function createMemoryActionHandlers({
669
680
  })
670
681
  const errors = [...normalized.errors]
671
682
  if (op === 'add') {
672
- if (!hasProjectIdKey || !projectIdText) {
673
- const inferredProjectId = typeof args.cwd === 'string' && args.cwd
674
- ? resolveProjectId(args.cwd)
675
- : null
676
- errors.unshift(
677
- `project_id required — pass "common" for COMMON pool, or project slug like "owner/repo" for scoped pool` +
678
- (typeof inferredProjectId === 'string' && /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)?$/.test(inferredProjectId)
679
- ? ` (cwd suggests "${inferredProjectId}")`
680
- : ''),
681
- )
682
- } else if (projectId === '*') {
683
+ if (projectId === '*') {
683
684
  errors.unshift('project_id "*" only valid for op="list"')
684
685
  }
685
686
  }
@@ -721,11 +722,13 @@ export function createMemoryActionHandlers({
721
722
  return { text: lines.join('\n') }
722
723
  }
723
724
  if (op === 'add') {
724
- const entry = await addCore(coreDataDir, args, projectId)
725
+ const entry = await addCoreImpl(coreDataDir, args, projectId)
726
+ await queueCoreReview('core-add')
725
727
  return { text: `core added (id=${entry.id}): ${entry.element} — ${entry.summary.slice(0, 200)}` }
726
728
  }
727
729
  if (op === 'edit') {
728
730
  const entry = await editCoreImpl(coreDataDir, args.id, args)
731
+ await queueCoreReview('core-edit')
729
732
  return { text: `core edited (id=${entry.id}): ${entry.element} — ${entry.summary.slice(0, 200)}` }
730
733
  }
731
734
  if (op === 'delete') {
@@ -796,7 +799,7 @@ export function createMemoryActionHandlers({
796
799
  for (const a of actions) {
797
800
  if (a?.action === 'core') {
798
801
  const cid = Number(a.entry_id)
799
- const core = String(a.core_summary ?? '').replace(/\s+/g, ' ').trim().slice(0, CORE_SUMMARY_MAX)
802
+ const core = String(a.core_summary ?? '').replace(/\s+/g, ' ').trim()
800
803
  if (Number.isFinite(cid) && core) coreSummaryById.set(cid, core)
801
804
  } else {
802
805
  primaryActions.push(a)
@@ -7,7 +7,7 @@
7
7
  // memory-cycle2-gate.mjs — prompt/parse/validate + runUnifiedGate + cascade
8
8
  import { flushEmbeddingDirty } from './memory-embed.mjs'
9
9
  import { refreshHotActive } from './memory.mjs'
10
- import { backfillCoreEmbeddings, nominateCoreCandidates, CORE_SUMMARY_MAX } from './core-memory-store.mjs'
10
+ import { backfillCoreEmbeddings, nominateCoreCandidates } from './core-memory-store.mjs'
11
11
  import { markCycleRequest, consumeCycleRequests, resolveCoalesceMaxDrains, scheduleCoalescedCycleRetry, makeCycleRequestSignature, resolveCoalesceMaxRetries } from './memory-cycle-requests.mjs'
12
12
  import { __mixdogMemoryLog, throwIfAborted } from './memory-cycle2-shared.mjs'
13
13
  import {
@@ -394,7 +394,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
394
394
  throwIfAborted(signal)
395
395
  if (a.action === 'core') {
396
396
  const id = Number(a.entry_id)
397
- const core = String(a.core_summary ?? '').replace(/\s+/g, ' ').trim().slice(0, CORE_SUMMARY_MAX)
397
+ const core = String(a.core_summary ?? '').replace(/\s+/g, ' ').trim()
398
398
  if (Number.isFinite(id) && core) coreSummaryById.set(id, core)
399
399
  } else {
400
400
  primaryActions.push(a)
@@ -404,7 +404,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
404
404
  const setCoreSummary = async (entryId, explicitSummary) => {
405
405
  const id = Number(entryId)
406
406
  if (!Number.isFinite(id)) return false
407
- let core = String(explicitSummary ?? '').replace(/\s+/g, ' ').trim().slice(0, CORE_SUMMARY_MAX)
407
+ const core = String(explicitSummary ?? '').replace(/\s+/g, ' ').trim()
408
408
  if (!core) return false
409
409
  await db.query(`UPDATE entries SET core_summary = $1 WHERE id = $2 AND is_root = 1`, [core, id])
410
410
  return true
@@ -25,7 +25,7 @@ import { join } from 'path'
25
25
  import { fileURLToPath } from 'url'
26
26
  import { resolveMaintenancePreset } from '../../shared/llm/index.mjs'
27
27
  import { callAgentDispatch } from './agent-ipc.mjs'
28
- import { listCore, editCore, deleteCore, archiveCore, CORE_SUMMARY_MAX } from './core-memory-store.mjs'
28
+ import { listCore, editCore, deleteCore, archiveCore } from './core-memory-store.mjs'
29
29
  import { reclassifyCore } from './core-memory-store.mjs'
30
30
  import { loadCurrentRulesDigest } from './memory-cycle2.mjs'
31
31
  import { embedText } from './embedding-provider.mjs'
@@ -136,8 +136,6 @@ async function isSafeConservativeUpdate(current, action) {
136
136
  const newElement = normalizeComparable(action.element)
137
137
  const newSummary = normalizeComparable(action.summary)
138
138
  if (!newElement || !newSummary) return { ok: false, reason: 'empty rewrite' }
139
- if (newSummary.length > CORE_SUMMARY_MAX) return { ok: false, reason: 'summary too long' }
140
-
141
139
  const oldText = coreText(current)
142
140
  const newText = `${action.element}\n${action.summary}`
143
141
  const oldLen = normalizeComparable(oldText).length
@@ -10,6 +10,28 @@ import { recallReadQuery } from './memory-recall-read-query.mjs'
10
10
  // uses entries directly; this guard remains for explicit useHotActive callers.
11
11
  import { SEMANTIC_ONLY_MIN_SIM, memberTsInWindow, SEMANTIC_TOP_RANK_MAX, SEMANTIC_TOP_RANK_MIN_SIM, SEMANTIC_TOP_RANK_STRICT_SIM, SHORT_QUERY_TOKEN_MAX, SIM_FLOOR, W_DENSE, W_WINDOW_RECENCY, W_RARE, RARE_DF_MAX, queryTokensLower, rowDisplayText, windowRecencyFactor, buildExactTerms, countQueryTokens, hasFullQueryTextMatch, hasQueryTokenCoverage, exactTextBoost, _checkMvHotActivePopulated } from './recall-scoring.mjs';
12
12
 
13
+ // Bounded lexical scan window. The trgm/exact CTE legs run `ILIKE '%…%'`
14
+ // which no index accelerates, so their worst case grows linearly with the
15
+ // entries table — on a years-old memory DB every recall pays a full-table
16
+ // substring scan twice. Bound both legs to the newest N rows by id (bigserial
17
+ // ⇒ insertion order; MAX(id) resolves via the pk index, no sort). Dense
18
+ // (HNSW) and sparse (GIN FTS) legs still cover the WHOLE table through real
19
+ // indexes, so old memories remain reachable semantically/topically — only the
20
+ // exact-substring rescue narrows to recent rows. Default 200k: measured on a
21
+ // live DB, a 20k bound regressed recall quality (bench MRR 0.75 → 0.33 — the
22
+ // rescued rows sat beyond the newest 20k inserts), so the default only guards
23
+ // pathological table growth, not today's scale. MIXDOG_RECALL_LEXSCAN_ROWS
24
+ // overrides; 0 restores the unbounded scan.
25
+ const _envLexScan = Number(process.env.MIXDOG_RECALL_LEXSCAN_ROWS)
26
+ const RECALL_LEXSCAN_ROWS = Number.isFinite(_envLexScan) && _envLexScan >= 0
27
+ ? Math.floor(_envLexScan)
28
+ : 200_000
29
+ function lexScanBound(alias = '') {
30
+ if (RECALL_LEXSCAN_ROWS <= 0) return ''
31
+ const col = alias ? `${alias}.id` : 'id'
32
+ return `AND ${col} >= (SELECT GREATEST(COALESCE(MAX(id), 0) - ${RECALL_LEXSCAN_ROWS}, 0) FROM entries)`
33
+ }
34
+
13
35
  export async function searchRelevantHybrid(db, query, options = {}) {
14
36
  const clean = String(query ?? '').trim()
15
37
  if (!clean) return []
@@ -280,6 +302,7 @@ trgm AS (
280
302
  OR coalesce(summary, '') ILIKE '%' || $3 || '%'
281
303
  )
282
304
  ${trgmFilterClause}
305
+ ${lexScanBound()}
283
306
  ORDER BY ts DESC
284
307
  LIMIT $4
285
308
  ),`
@@ -297,6 +320,7 @@ exact AS (
297
320
  )
298
321
  WHERE true
299
322
  ${exactFilterClause}
323
+ ${lexScanBound('ee')}
300
324
  GROUP BY ee.id, ee.ts
301
325
  HAVING COUNT(*) >= ${minExactHits}
302
326
  ORDER BY exact_hits DESC, ee.ts DESC
@@ -39,6 +39,7 @@ export function createQueryHandlers({
39
39
  log,
40
40
  resolveProjectScope,
41
41
  embeddingWarmupCanStart,
42
+ ensureKoMorph,
42
43
  getBootTimestamp,
43
44
  getTraceDb,
44
45
  }) {
@@ -123,9 +124,13 @@ export function createQueryHandlers({
123
124
  // is fresh (within FRESHNESS_MS of now) — an older newest-unchunked-turn
124
125
  // is completed history (cycle1 just hasn't gotten to it, or drain timed
125
126
  // out) and must stay visible, not be silently hidden every browse.
127
+ // Never applies to a compaction digest: that browse runs over a freshly
128
+ // and fully ingested transcript, so every row (including the previous
129
+ // turn's FINAL assistant message) carries an ingest-time ts and would be
130
+ // misread as in-flight, silently dropping the newest turn from the digest.
126
131
  const IN_FLIGHT_TURN_FRESHNESS_MS = 5 * 60 * 1000
127
132
  let excludeSourceTurnId = null
128
- if (terms.length === 0) {
133
+ if (!compactDigest && terms.length === 0) {
129
134
  try {
130
135
  const r = await db.query(
131
136
  `SELECT source_turn t, MAX(ts) last_ts FROM entries
@@ -140,7 +145,7 @@ export function createQueryHandlers({
140
145
  }
141
146
  } catch {}
142
147
  }
143
- if (Number.isFinite(excludeSourceTurnId)) {
148
+ if (!compactDigest && Number.isFinite(excludeSourceTurnId)) {
144
149
  params.push(excludeSourceTurnId)
145
150
  where.push(`NOT (chunk_root IS NULL AND source_turn = $${params.length})`)
146
151
  }
@@ -166,7 +171,7 @@ export function createQueryHandlers({
166
171
  const fillLimit = Math.max(0, fetchLimit - rows.length)
167
172
  const fillWhere = ['session_id = $1', 'id <> ALL($2::bigint[])', '(is_root = 1 OR chunk_root IS NULL OR chunk_root = id)']
168
173
  const fillParams = [sessionId, [...seen]]
169
- if (Number.isFinite(excludeSourceTurnId)) {
174
+ if (!compactDigest && Number.isFinite(excludeSourceTurnId)) {
170
175
  fillParams.push(excludeSourceTurnId)
171
176
  fillWhere.push(`NOT (chunk_root IS NULL AND source_turn = $${fillParams.length})`)
172
177
  }
@@ -410,6 +415,14 @@ export function createQueryHandlers({
410
415
  return { text: header + parts.join('\n\n') }
411
416
  }
412
417
  const query = String(args.query ?? '').trim()
418
+ // Kiwi is a large optional analyzer. A Korean query starts it lazily, but
419
+ // this request keeps the lightweight lexical path instead of waiting for
420
+ // WASM/model construction. Later queries use it once ready.
421
+ if (query && /[\uAC00-\uD7AF]/u.test(query)) {
422
+ void Promise.resolve(ensureKoMorph?.()).catch((err) => {
423
+ log(`[memory-service] lazy kiwi init failed; recall stays lexical: ${err?.message || err}\n`)
424
+ })
425
+ }
413
426
  let period = String(args.period ?? '').trim() || undefined
414
427
  // Period and sort are caller-supplied only. Lead is responsible for
415
428
  // mapping vague time phrases / chronological intent into the period
@@ -500,31 +513,16 @@ export function createQueryHandlers({
500
513
  if (isEmbeddingModelReady()) {
501
514
  queryVector = await embedText(query, { priority: true })
502
515
  } else if (embeddingWarmupCanStart()) {
503
- // Cold model: WAIT for warmup (bounded) instead of silently degrading
504
- // to lexical-only. Raw rows are already embedded at ingest time, so
505
- // the query vector is the only missing piece of the dense leg — a few
506
- // seconds of warmup buys full hybrid quality on the first recall.
507
- // On timeout the warmup keeps running in the background and this
508
- // recall falls back to lexical (same as before).
509
- const COLD_RECALL_WARMUP_WAIT_MS = Math.max(0, Number(process.env.MIXDOG_RECALL_WARMUP_WAIT_MS) || 8_000)
510
- let timer
511
- try {
512
- queryVector = await Promise.race([
513
- warmupEmbeddingProvider().then(() => embedText(query, { priority: true })),
514
- new Promise((resolve) => { timer = setTimeout(() => resolve(null), COLD_RECALL_WARMUP_WAIT_MS) }),
515
- ])
516
- } catch (err) {
517
- log(`[memory-service] embedding warmup during cold recall failed: ${err?.message || err}\n`)
518
- queryVector = null
519
- } finally {
520
- clearTimeout(timer)
521
- }
522
- if (!Array.isArray(queryVector)) {
523
- const now = Date.now()
524
- if (now - _embeddingColdRecallLogAt > 10_000) {
525
- _embeddingColdRecallLogAt = now
526
- log(`[recall] embedding still cold after ${COLD_RECALL_WARMUP_WAIT_MS}ms wait; returning lexical results while background warmup continues\n`)
527
- }
516
+ // Never turn a cold model into an 8-second foreground lock. Start the
517
+ // isolated worker and return lexical results now; the next recall gets
518
+ // the dense leg when the model is ready.
519
+ void warmupEmbeddingProvider().catch((err) => {
520
+ log(`[memory-service] embedding warmup after cold recall failed: ${err?.message || err}\n`)
521
+ })
522
+ const now = Date.now()
523
+ if (now - _embeddingColdRecallLogAt > 10_000) {
524
+ _embeddingColdRecallLogAt = now
525
+ log('[recall] embedding model warming in background; returning lexical results\n')
528
526
  }
529
527
  } else {
530
528
  const now = Date.now()
@@ -27,12 +27,12 @@ export const TOOL_DEFS = [
27
27
  description: 'Required for action=core.',
28
28
  },
29
29
  id: { type: 'number', description: 'Exact memory id.' },
30
- element: { type: 'string', maxLength: 40, description: 'Memory key/title. Defaults to the first 40 chars of summary. Max 40 chars.' },
31
- summary: { type: 'string', maxLength: 100, description: 'Memory content: one short English clause, max 100 chars.' },
30
+ element: { type: 'string', description: 'Memory key/title. Defaults to the first 40 chars of summary.' },
31
+ summary: { type: 'string', description: 'Durable memory content; the memory worker compacts it after storage.' },
32
32
  status: { type: 'string', enum: ['pending','active','archived'], description: 'Lifecycle status.' },
33
33
  limit: { type: 'number', description: 'Max rows/items.' },
34
34
  confirm: { type: 'string', description: 'Exact confirmation phrase for destructive actions.' },
35
- project_id: { type: 'string', description: 'Core pool: explicit common or slug. Required for core add only (edit uses the id\'s stored pool); there is no default pool.' },
35
+ project_id: { type: 'string', description: 'Optional core pool override: common or slug. Omitted values are inferred from cwd/session, then fall back to common.' },
36
36
  },
37
37
  anyOf: [
38
38
  { properties: { action: { const: 'status' } }, required: ['action'] },
@@ -30,7 +30,11 @@ const _execFileAsync = promisify(execFile);
30
30
  const RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EEXIST']);
31
31
  const LOCK_WAIT_CODES = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
32
32
  const DEFAULT_BACKOFFS_MS = Object.freeze([25, 50, 100, 200, 400, 800, 1200, 1600]);
33
- const DEFAULT_LOCK_TIMEOUT_MS = 8000;
33
+ const _configuredLockTimeoutMs = Number(process.env.MIXDOG_LOCK_TIMEOUT_MS);
34
+ const DEFAULT_LOCK_TIMEOUT_MS = Number.isFinite(_configuredLockTimeoutMs)
35
+ && _configuredLockTimeoutMs >= 0
36
+ ? _configuredLockTimeoutMs
37
+ : 2000;
34
38
 
35
39
  // Per-process owner identity. A bare pid is not a durable holder identity:
36
40
  // after a holder crashes the OS can recycle its pid onto an unrelated live
@@ -8,6 +8,13 @@ function positiveInt(value) {
8
8
  return Number.isInteger(n) && n > 0 ? n : null;
9
9
  }
10
10
 
11
+ const DEFAULT_MIN_FREE_MEMORY_MB = 1024;
12
+
13
+ export function childGuardianMemoryFloorMb(env = process.env) {
14
+ const n = Math.floor(Number(env.MIXDOG_MIN_FREE_MEMORY_MB));
15
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_MIN_FREE_MEMORY_MB;
16
+ }
17
+
11
18
  export function childGuardianSpawnEnv(env = process.env) {
12
19
  return {
13
20
  PATH: env.PATH || '',
@@ -22,9 +29,20 @@ export function childGuardianSpawnEnv(env = process.env) {
22
29
  };
23
30
  }
24
31
 
25
- function guardianScript({ parentPid, childPid, childGroupPid, platform, pollMs, orphanGraceMs, forceGraceMs }) {
32
+ function guardianScript({
33
+ parentPid,
34
+ childPid,
35
+ childGroupPid,
36
+ platform,
37
+ pollMs,
38
+ orphanGraceMs,
39
+ forceGraceMs,
40
+ minFreeMemoryMb,
41
+ receiptPath,
42
+ }) {
26
43
  return `
27
44
  const { spawnSync } = require('node:child_process');
45
+ const { freemem } = require('node:os');
28
46
  const parentPid = ${JSON.stringify(parentPid)};
29
47
  const childPid = ${JSON.stringify(childPid)};
30
48
  const childGroupPid = ${JSON.stringify(childGroupPid || childPid)};
@@ -32,6 +50,23 @@ const platform = ${JSON.stringify(platform)};
32
50
  const pollMs = ${JSON.stringify(pollMs)};
33
51
  const orphanGraceMs = ${JSON.stringify(orphanGraceMs)};
34
52
  const forceGraceMs = ${JSON.stringify(forceGraceMs)};
53
+ const minFreeMemoryBytes = ${JSON.stringify(minFreeMemoryMb * 1024 * 1024)};
54
+ const receiptPath = ${JSON.stringify(receiptPath || null)};
55
+ // Kill receipt: guardian kills are otherwise indistinguishable from a crashed
56
+ // wrapper ("process exited without reporting an exit code"). Written BEFORE
57
+ // the kill so the owner's next status refresh can attribute the death.
58
+ function writeReceipt(reason, extra) {
59
+ if (!receiptPath) return;
60
+ try {
61
+ require('node:fs').writeFileSync(receiptPath, JSON.stringify({
62
+ reason,
63
+ at: new Date().toISOString(),
64
+ guardianPid: process.pid,
65
+ childPid,
66
+ ...extra,
67
+ }));
68
+ } catch {}
69
+ }
35
70
  function alive(pid) {
36
71
  if (!Number.isInteger(pid) || pid <= 0) return false;
37
72
  try { process.kill(pid, 0); return true; }
@@ -57,6 +92,20 @@ let killing = false;
57
92
  let orphanedAt = 0;
58
93
  const timer = setInterval(() => {
59
94
  if (!alive(childPid)) process.exit(0);
95
+ // Admission protects only NEW work. A running shell can still consume the
96
+ // host after it starts, so reclaim its entire tree before it starves the
97
+ // owning Mixdog process. Force is intentional: at the memory floor there is
98
+ // no safe grace window for a runaway allocator.
99
+ if (!killing && minFreeMemoryBytes > 0) {
100
+ let freeBytes = Number.POSITIVE_INFINITY;
101
+ try { freeBytes = Number(freemem()); } catch {}
102
+ if (Number.isFinite(freeBytes) && freeBytes < minFreeMemoryBytes) {
103
+ killing = true;
104
+ writeReceipt('host-memory-floor', { freeBytes, minFreeMemoryBytes });
105
+ killTarget(true);
106
+ process.exit(0);
107
+ }
108
+ }
60
109
  // Orphaned when the parent PID dies. Matches mainstream CLIs: no console
61
110
  // probe — a hidden shell spawn (windowsHide) plus tree-kill on cleanup, so
62
111
  // orphan detection is pure parent-liveness.
@@ -67,6 +116,7 @@ const timer = setInterval(() => {
67
116
  if (!orphanedAt) orphanedAt = Date.now();
68
117
  if (killing || Date.now() - orphanedAt < orphanGraceMs) return;
69
118
  killing = true;
119
+ writeReceipt('parent-exit', {});
70
120
  killTarget(false);
71
121
  setTimeout(() => { if (alive(childPid)) killTarget(true); process.exit(0); }, forceGraceMs).unref?.();
72
122
  }, pollMs);
@@ -82,10 +132,14 @@ export function startChildGuardian({
82
132
  graceMs = 3000,
83
133
  orphanGraceMs = graceMs,
84
134
  forceGraceMs = graceMs,
135
+ protectHostMemory = false,
136
+ minFreeMemoryMb = protectHostMemory ? childGuardianMemoryFloorMb() : 0,
137
+ receiptPath = null,
85
138
  } = {}) {
86
139
  const parent = positiveInt(parentPid);
87
140
  const child = positiveInt(childPid);
88
141
  if (!parent || !child || parent === child) return null;
142
+ const memoryFloorMb = Math.max(0, Math.floor(Number(minFreeMemoryMb) || 0));
89
143
 
90
144
  try {
91
145
  const guardian = spawn(process.execPath, [
@@ -99,6 +153,8 @@ export function startChildGuardian({
99
153
  pollMs: Math.max(100, Math.floor(Number(pollMs) || 750)),
100
154
  orphanGraceMs: Math.max(100, Math.floor(Number(orphanGraceMs) || Number(graceMs) || 3000)),
101
155
  forceGraceMs: Math.max(100, Math.floor(Number(forceGraceMs) || Number(graceMs) || 3000)),
156
+ minFreeMemoryMb: memoryFloorMb,
157
+ receiptPath: typeof receiptPath === 'string' && receiptPath ? receiptPath : null,
102
158
  }),
103
159
  ], {
104
160
  stdio: 'ignore',
@@ -106,7 +162,22 @@ export function startChildGuardian({
106
162
  ...detachedSpawnOpts,
107
163
  });
108
164
  guardian.unref?.();
109
- return { pid: guardian.pid || null, label, childPid: child };
165
+ let stopped = false;
166
+ return {
167
+ pid: guardian.pid || null,
168
+ label,
169
+ childPid: child,
170
+ stop() {
171
+ if (stopped) return false;
172
+ stopped = true;
173
+ try {
174
+ guardian.kill();
175
+ return true;
176
+ } catch {
177
+ return false;
178
+ }
179
+ },
180
+ };
110
181
  } catch {
111
182
  return null;
112
183
  }
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from 'node:module';
2
2
 
3
3
  const require = createRequire(import.meta.url);
4
- const { getSecret } = require('../../lib/keychain-cjs.cjs');
4
+ const { getSecret, hasSecret } = require('../../lib/keychain-cjs.cjs');
5
5
 
6
6
  // Lightweight API-key lookup shared by pristine startup and the general config
7
7
  // loader. This module deliberately has no mixdog-config.json dependency: asking
@@ -23,12 +23,32 @@ function providerSecretEnv(provider) {
23
23
  return `MIXDOG_AGENT_${String(provider || '').replace(/[.\s]+/g, '_').toUpperCase()}_APIKEY`;
24
24
  }
25
25
 
26
- export function getAgentApiKey(provider) {
26
+ function environmentApiKey(provider) {
27
27
  const id = String(provider || '').trim();
28
28
  const standardEnv = AGENT_PROVIDER_ENV[id];
29
29
  if (standardEnv && process.env[standardEnv]) return process.env[standardEnv];
30
30
  const mixdogEnv = providerSecretEnv(id);
31
31
  if (process.env[mixdogEnv]) return process.env[mixdogEnv];
32
+ for (const alias of AGENT_PROVIDER_ENV_ALIASES[id] || []) {
33
+ if (process.env[alias]) return process.env[alias];
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /** Presence-only credential probe. On Windows this is a cheap DPAPI-file
39
+ * check; decrypting through PowerShell is reserved for an actual API call. */
40
+ export function hasAgentApiKey(provider) {
41
+ const id = String(provider || '').trim();
42
+ if (!id) return false;
43
+ if (environmentApiKey(id)) return true;
44
+ try { return hasSecret(`agent.${id}.apiKey`); }
45
+ catch { return false; }
46
+ }
47
+
48
+ export function getAgentApiKey(provider) {
49
+ const id = String(provider || '').trim();
50
+ const environment = environmentApiKey(id);
51
+ if (environment) return environment;
32
52
  try {
33
53
  const stored = getSecret(`agent.${id}.apiKey`);
34
54
  if (stored) return stored;
@@ -36,8 +56,5 @@ export function getAgentApiKey(provider) {
36
56
  // A missing/locked keychain is equivalent to no stored key. Explicit
37
57
  // environment aliases remain available as the final provider-local source.
38
58
  }
39
- for (const alias of AGENT_PROVIDER_ENV_ALIASES[id] || []) {
40
- if (process.env[alias]) return process.env[alias];
41
- }
42
59
  return null;
43
60
  }
@@ -204,6 +204,59 @@ export function isLikelyToolCompletionWrapper(text) {
204
204
  return quotedCount / quotedLines.length >= 0.8;
205
205
  }
206
206
 
207
+ const INTERNAL_TRANSCRIPT_CONTEXT_RE =
208
+ /^<(?:system-reminder|task-notification|skill|memory-context|mcp-instructions|available-deferred-tools|event)\b/i;
209
+ const INTERNAL_TRANSCRIPT_SYNTHETIC_RE =
210
+ /^(?:\[mixdog-runtime\]|A previous model worked on this task and produced the compacted handoff summary below\b|Re-attached after compaction\b|Reference files:\s)/i;
211
+ const INTERNAL_TRANSCRIPT_ASYNC_HEAD_RE =
212
+ /^The async (?:agent task|shell task|\w+ execution) \S+ has (?:finished|completed)\b/i;
213
+ // Persisted USER cancellation control rows ("[Request interrupted by user]"
214
+ // and its tool-use variant) exist for the next model step, not for humans:
215
+ // the human already saw the cancel they typed. The live engine path already
216
+ // withholds them; history/lane snapshots deliver them as plain user rows,
217
+ // which flashed in unfocused panes until focus swapped the source (user
218
+ // report). Full-row match only — never hide real prose around them.
219
+ // "[Request interrupted by process restart]" is deliberately NOT matched: it
220
+ // is a user-visible crash-recovery marker showing where a force-killed turn
221
+ // stopped, and the restored transcript must keep that row
222
+ // (scripts/turn-checkpoint-crash-test.mjs).
223
+ const INTERNAL_TRANSCRIPT_INTERRUPT_RE =
224
+ /^\[request interrupted by user(?: for tool use)?\]$/i;
225
+
226
+ // One display-only policy for every transcript surface. Runtime control rows
227
+ // may be persisted as ordinary role:user messages so the next model step can
228
+ // consume them; they are never human-authored chat and must not be rendered.
229
+ // The flexible Result separator covers legacy rows and command bodies whose
230
+ // embedded newlines lost their quote prefix during persistence.
231
+ export function isInternalTranscriptDisplayText(text) {
232
+ const value = String(text ?? '').trim();
233
+ if (!value) return false;
234
+ if (INTERNAL_TRANSCRIPT_CONTEXT_RE.test(value)
235
+ || INTERNAL_TRANSCRIPT_SYNTHETIC_RE.test(value)
236
+ || INTERNAL_TRANSCRIPT_INTERRUPT_RE.test(value)
237
+ || isInternalRuntimeNotificationText(value)
238
+ || isModelVisibleToolCompletionWrapper(value)
239
+ || isLikelyToolCompletionWrapper(value)) {
240
+ return true;
241
+ }
242
+ if (INTERNAL_TRANSCRIPT_ASYNC_HEAD_RE.test(value) && !/\bResult:\s*(?:\r?\n|$)/i.test(value)) {
243
+ return true;
244
+ }
245
+ const resultSplit = /\r?\n(?:[ \t]*\r?\n)?Result:[ \t]*\r?\n/i.exec(value);
246
+ if (!resultSplit) return false;
247
+ const preamble = value.slice(0, resultSplit.index).trim();
248
+ const instructionLike = MODEL_VISIBLE_COMPLETION_INSTRUCTION_RE.test(preamble)
249
+ || MODEL_VISIBLE_COMPLETION_REVIEW_RE.test(preamble)
250
+ || MODEL_VISIBLE_COMPLETION_ASYNC_HEADER_RE.test(preamble);
251
+ if (!instructionLike) return false;
252
+ const normalizedBody = value.slice(resultSplit.index + resultSplit[0].length)
253
+ .split(/\r?\n/)
254
+ .map((line) => line.replace(/^>\s?/, ''))
255
+ .join('\n')
256
+ .trim();
257
+ return isInternalRuntimeNotificationText(normalizedBody);
258
+ }
259
+
207
260
  export function modelVisibleToolCompletionMessage(text, meta = {}) {
208
261
  const message = String(text || '').trim();
209
262
  if (!message) return '';
@@ -122,10 +122,11 @@ const AUTO_CLEAR_DEFAULT_IDLE_MS = 60 * 60 * 1000;
122
122
  // Lead 11.75M vs 20.11M and agents 14.46M vs 45.04M input-token equivalents,
123
123
  // because cold misses rewrite the accumulated tail. Gemini, xAI and Mistral
124
124
  // caches run ~1h, Groq's implicit cache persists ~2h, OpenAI/DeepSeek
125
- // key-prefix/implicit caches persist far longer (~24h), and OpenAI OAuth's
126
- // in-memory server cache is short-lived (~5-10min), so we round up slightly
127
- // to 10m to avoid sweeping a still-warm cache. Unknown/unrecognized
128
- // providers fall back to the 'default' 1h entry.
125
+ // key-prefix/implicit caches persist far longer (~24h), and OpenAI OAuth
126
+ // (Codex backend) guarantees a 30-minute minimum prompt-cache TTL on
127
+ // GPT-5.6+ models (prompt_cache_options.ttl=30m, refreshed on use), so the
128
+ // sweep matches that window to avoid clearing a still-warm cache.
129
+ // Unknown/unrecognized providers fall back to the 'default' 1h entry.
129
130
  export const AUTO_CLEAR_PROVIDER_IDLE_MS = Object.freeze({
130
131
  'anthropic': 60 * 60 * 1000,
131
132
  'anthropic-oauth': 60 * 60 * 1000,
@@ -134,7 +135,7 @@ export const AUTO_CLEAR_PROVIDER_IDLE_MS = Object.freeze({
134
135
  'openai': 24 * 60 * 60 * 1000,
135
136
  'deepseek': 24 * 60 * 60 * 1000,
136
137
  'xai': 60 * 60 * 1000,
137
- 'openai-oauth': 10 * 60 * 1000,
138
+ 'openai-oauth': 30 * 60 * 1000,
138
139
  'mistral': 60 * 60 * 1000,
139
140
  'default': AUTO_CLEAR_DEFAULT_IDLE_MS,
140
141
  });
@@ -52,6 +52,7 @@ export function createLifecycleApi(deps) {
52
52
  beginRoutePreparation, clearRoutePreparation,
53
53
  pushTranscriptRebind,
54
54
  notificationListeners, remoteStateListeners, desktopSession,
55
+ disposeSessionTitles,
55
56
  } = deps;
56
57
  const closeSurfaceSession = (session, reason, options) => {
57
58
  if (!session?.id) return false;
@@ -111,6 +112,7 @@ export function createLifecycleApi(deps) {
111
112
  model: s.model,
112
113
  provider: s.provider,
113
114
  messageCount,
115
+ title: cleanSessionPreview(s.title || '', 100),
114
116
  preview,
115
117
  // Working indicator: the .hb sidecar ALONE is the liveness signal. Its
116
118
  // deletion at turn end IS the completion signal, so the persisted
@@ -166,6 +168,7 @@ export function createLifecycleApi(deps) {
166
168
  // of reaping every session's jobs. CLI exit paths never set this.
167
169
  const keepBackgroundWork = options?.keepBackgroundWork === true;
168
170
  setCloseRequested(true);
171
+ try { disposeSessionTitles?.(); } catch {}
169
172
  // Self-update now stages in the background and swaps on the next clean
170
173
  // launch (see staged-update.mjs) — nothing installs at shutdown. On a
171
174
  // real process exit we just drop this session's live-refcount pid file so
@@ -415,12 +418,21 @@ export function createLifecycleApi(deps) {
415
418
  if (!sessionId || !/^[A-Za-z0-9_-]+$/.test(sessionId)) return null;
416
419
  const current = getSession();
417
420
  if (current?.id === sessionId) return current;
418
- const session = mgr.loadSession?.(sessionId);
421
+ // Visible desktop panes reconcile dead-owner checkpoints during their
422
+ // startup peek instead of waiting for a later click/resume. Older test
423
+ // or embedded managers retain the plain read-only fallback.
424
+ const session = typeof mgr.recoverSessionAfterProcessRestart === 'function'
425
+ ? mgr.recoverSessionAfterProcessRestart(sessionId)
426
+ : mgr.loadSession?.(sessionId);
419
427
  // `closed` means the previous live owner released this durable session;
420
428
  // it is still a valid historical transcript. Desktop pane peeks are
421
- // strictly read-only, so they may inspect it without resuming or
422
- // changing ownership. Deleted/tombstoned sessions have no load result.
423
- return session || null;
429
+ // ownership-neutral, so they may inspect it without resuming. Crash
430
+ // checkpoint reconciliation is the sole durable mutation here.
431
+ if (!session) return null;
432
+ return mgr.prepareSessionProjection?.(
433
+ session,
434
+ toolSpecForMode(getMode()),
435
+ ) || session;
424
436
  },
425
437
  async resume(id) {
426
438
  clearRoutePreparation?.();