mixdog 0.9.36 → 0.9.38

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 (115) hide show
  1. package/package.json +3 -2
  2. package/scripts/abort-recovery-test.mjs +118 -0
  3. package/scripts/compact-smoke.mjs +7 -7
  4. package/scripts/dispatch-persist-recovery-test.mjs +141 -0
  5. package/scripts/explore-bench-tmp.mjs +19 -0
  6. package/scripts/explore-bench.mjs +101 -14
  7. package/scripts/explore-prompt-policy-test.mjs +31 -0
  8. package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
  9. package/scripts/notify-completion-mirror-test.mjs +73 -0
  10. package/scripts/openai-ws-early-settle-test.mjs +176 -0
  11. package/scripts/output-style-smoke.mjs +17 -17
  12. package/scripts/provider-toolcall-test.mjs +333 -14
  13. package/scripts/recall-bench-cases.json +3 -3
  14. package/scripts/recall-bench.mjs +1 -1
  15. package/scripts/recall-quality-cases.json +4 -4
  16. package/scripts/recall-usecase-cases.json +2 -2
  17. package/scripts/session-bench.mjs +13 -13
  18. package/scripts/session-sweep.mjs +266 -0
  19. package/scripts/steering-drain-buckets-test.mjs +72 -11
  20. package/scripts/submit-commandbusy-race-test.mjs +114 -0
  21. package/scripts/tool-smoke.mjs +83 -10
  22. package/src/app.mjs +2 -1
  23. package/src/defaults/cycle3-review-prompt.md +9 -0
  24. package/src/defaults/skills/setup/SKILL.md +93 -293
  25. package/src/lib/rules-builder.cjs +3 -2
  26. package/src/output-styles/default.md +2 -2
  27. package/src/output-styles/extreme-minimal.md +1 -1
  28. package/src/output-styles/minimal.md +1 -1
  29. package/src/output-styles/simple.md +2 -3
  30. package/src/rules/agent/30-explorer.md +40 -14
  31. package/src/rules/lead/01-general.md +4 -0
  32. package/src/rules/shared/01-tool.md +7 -3
  33. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
  34. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
  38. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
  39. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
  40. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
  41. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
  42. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
  43. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
  44. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
  45. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
  46. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
  47. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -2
  48. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
  49. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
  50. package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
  51. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
  52. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
  53. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
  54. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
  55. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
  56. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
  57. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +30 -55
  58. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
  59. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
  61. package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
  62. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
  63. package/src/runtime/agent/orchestrator/stall-policy.mjs +55 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
  65. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
  66. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
  68. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
  70. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
  73. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
  75. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  76. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  77. package/src/runtime/channels/lib/worker-main.mjs +5 -0
  78. package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
  79. package/src/runtime/memory/lib/ko-morph.mjs +1 -1
  80. package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
  81. package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
  82. package/src/runtime/memory/lib/recall-format.mjs +3 -3
  83. package/src/runtime/shared/child-spawn-gate.mjs +15 -0
  84. package/src/runtime/shared/config.mjs +98 -12
  85. package/src/runtime/shared/process-listener-headroom.mjs +12 -0
  86. package/src/session-runtime/cwd-plugins.mjs +6 -3
  87. package/src/session-runtime/model-route-api.mjs +50 -2
  88. package/src/session-runtime/provider-auth-api.mjs +8 -1
  89. package/src/session-runtime/provider-models.mjs +3 -3
  90. package/src/session-runtime/resource-api.mjs +22 -0
  91. package/src/session-runtime/runtime-core.mjs +56 -10
  92. package/src/session-runtime/settings-api.mjs +11 -2
  93. package/src/session-runtime/warmup-schedulers.mjs +7 -1
  94. package/src/standalone/agent-tool.mjs +15 -9
  95. package/src/standalone/explore-tool.mjs +4 -1
  96. package/src/tui/App.jsx +6 -5
  97. package/src/tui/app/transcript-window.mjs +60 -10
  98. package/src/tui/app/use-transcript-window.mjs +2 -1
  99. package/src/tui/components/ContextPanel.jsx +4 -1
  100. package/src/tui/components/ToolExecution.jsx +15 -12
  101. package/src/tui/components/TranscriptItem.jsx +1 -1
  102. package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
  103. package/src/tui/dist/index.mjs +482 -151
  104. package/src/tui/engine/agent-job-feed.mjs +36 -6
  105. package/src/tui/engine/notification-plan.mjs +3 -3
  106. package/src/tui/engine/queue-helpers.mjs +8 -0
  107. package/src/tui/engine/render-timing.mjs +104 -8
  108. package/src/tui/engine/session-api.mjs +58 -3
  109. package/src/tui/engine/session-flow.mjs +93 -29
  110. package/src/tui/engine/tool-card-results.mjs +28 -13
  111. package/src/tui/engine/turn.mjs +238 -157
  112. package/src/tui/engine.mjs +17 -8
  113. package/src/tui/index.jsx +10 -1
  114. package/src/workflows/default/WORKFLOW.md +8 -4
  115. package/src/workflows/solo/WORKFLOW.md +8 -4
@@ -175,7 +175,7 @@ export function analyze(text) {
175
175
  }
176
176
 
177
177
  // Content-morpheme stem forms for a Korean phrase. null when not ready.
178
- // "개선을" ['개선']; "개선하고" ['개선','하'] filtered to content tags.
178
+ // Example: an inflected Korean noun/verb phrase is reduced to content stems.
179
179
  export function stems(text) {
180
180
  const tokens = analyze(text)
181
181
  if (!tokens) return null
@@ -22,6 +22,7 @@ function __mixdogMemoryLog(...args) {
22
22
  // <id>|update|<element>|<summary>
23
23
  // <id>|merge|<target_id>|<source_ids_csv>
24
24
  // <id>|delete|<reason>
25
+ // <id>|reclassify|<project_slug|common>
25
26
 
26
27
  import { existsSync, readFileSync } from 'fs'
27
28
  import { join } from 'path'
@@ -29,6 +30,7 @@ import { fileURLToPath } from 'url'
29
30
  import { resolveMaintenancePreset } from '../../shared/llm/index.mjs'
30
31
  import { callAgentDispatch } from './agent-ipc.mjs'
31
32
  import { listCore, editCore, deleteCore, archiveCore, CORE_SUMMARY_MAX } from './core-memory-store.mjs'
33
+ import { reclassifyCore } from './core-memory-store.mjs'
32
34
  import { loadCurrentRulesDigest } from './memory-cycle2.mjs'
33
35
  import { embedText } from './embedding-provider.mjs'
34
36
  import { searchRelevantHybrid } from './memory-recall-store.mjs'
@@ -264,6 +266,17 @@ function parseVerdicts(raw, idSet) {
264
266
  } else if (verb === 'delete') {
265
267
  const reason = (parts[2] ?? '').trim()
266
268
  actions.push({ id, verb: 'delete', reason: reason || null })
269
+ } else if (verb === 'reclassify' || verb === 'reproject' || verb === 'rescope') {
270
+ // Project classification correction: move a mis-scoped entry to the right
271
+ // pool. `<id>|reclassify|<project_slug|common>`. A missing target scope
272
+ // has no destination → drop. 'common' (case-insensitive) → COMMON pool.
273
+ const rawTarget = (parts[2] ?? '').trim()
274
+ if (!rawTarget) {
275
+ __mixdogMemoryLog(`[cycle3] reclassify rejected: id=${id} missing target scope\n`)
276
+ continue
277
+ }
278
+ const projectId = /^common$/i.test(rawTarget) ? null : rawTarget
279
+ actions.push({ id, verb: 'reclassify', projectId })
267
280
  } else if (verb === 'superseded' || verb === 'supersede') {
268
281
  // Require newer-id proof: `id|superseded|<newer_id>`. Without a valid
269
282
  // newer active core id the supersession has no evidence → drop to keep.
@@ -288,6 +301,7 @@ function mergeCycle3Counts(a = {}, b = {}) {
288
301
  updated: Number(a.updated || 0) + Number(b.updated || 0),
289
302
  merged: Number(a.merged || 0) + Number(b.merged || 0),
290
303
  deleted: Number(a.deleted || 0) + Number(b.deleted || 0),
304
+ reclassified: Number(a.reclassified || 0) + Number(b.reclassified || 0),
291
305
  }
292
306
  }
293
307
 
@@ -302,11 +316,13 @@ function mergeCycle3Results(a, b) {
302
316
  updated: Number(a.updated || 0) + Number(b.updated || 0),
303
317
  merged: Number(a.merged || 0) + Number(b.merged || 0),
304
318
  deleted: Number(a.deleted || 0) + Number(b.deleted || 0),
319
+ reclassified: Number(a.reclassified || 0) + Number(b.reclassified || 0),
305
320
  proposed: mergeCycle3Counts(a.proposed, b.proposed),
306
321
  held: {
307
322
  updated: Number(a?.held?.updated || 0) + Number(b?.held?.updated || 0),
308
323
  merged: Number(a?.held?.merged || 0) + Number(b?.held?.merged || 0),
309
324
  deleted: Number(a?.held?.deleted || 0) + Number(b?.held?.deleted || 0),
325
+ reclassified: Number(a?.held?.reclassified || 0) + Number(b?.held?.reclassified || 0),
310
326
  },
311
327
  details: [...(a.details || []), ...(b.details || [])],
312
328
  skippedInFlight: false,
@@ -517,6 +533,10 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
517
533
 
518
534
  const idSet = new Set(cores.map(c => Number(c.id)))
519
535
  const coreById = new Map(cores.map(c => [Number(c.id), c]))
536
+ // Real project pools present in this review — reclassify may only move an
537
+ // entry INTO a pool we already know is real (COMMON is always valid), never
538
+ // invent a typo pool from an LLM-supplied slug.
539
+ const knownPools = new Set(cores.map(c => c.project_id).filter(p => p != null).map(String))
520
540
  const parsed = parseVerdicts(raw, idSet)
521
541
  if (!parsed) {
522
542
  __mixdogMemoryLog(
@@ -605,9 +625,9 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
605
625
  }
606
626
  }
607
627
 
608
- let kept = 0, updated = 0, merged = 0, deleted = 0, superseded = 0
609
- const proposed = { kept: 0, updated: 0, merged: 0, deleted: 0, superseded: 0 }
610
- const held = { updated: 0, merged: 0, deleted: 0, superseded: 0 }
628
+ let kept = 0, updated = 0, merged = 0, deleted = 0, superseded = 0, reclassified = 0
629
+ const proposed = { kept: 0, updated: 0, merged: 0, deleted: 0, superseded: 0, reclassified: 0 }
630
+ const held = { updated: 0, merged: 0, deleted: 0, superseded: 0, reclassified: 0 }
611
631
  const details = []
612
632
  const touched = new Set() // ids already acted on this cycle — avoid double action
613
633
  // Safety margin: even when every junk verdict is legitimate, a single
@@ -687,6 +707,44 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
687
707
  }
688
708
  continue
689
709
  }
710
+ if (a.verb === 'reclassify') {
711
+ proposed.reclassified++
712
+ // Project classification correction: move a mis-scoped entry to the right
713
+ // pool. Non-destructive (project_id only, no delete, no re-embed) and
714
+ // reversible, so it applies in DEFAULT (conservative) mode. Guards: the
715
+ // move must actually change pools and the target must be a real pool from
716
+ // this review (or COMMON) — otherwise hold. Unique-collision / content
717
+ // drift are resolved inside reclassifyCore (returns skipped).
718
+ const core = coreById.get(a.id)
719
+ const curPid = core?.project_id ?? null
720
+ const targetPid = a.projectId ?? null
721
+ let holdReason = null
722
+ if ((curPid ?? null) === (targetPid ?? null)) holdReason = 'already in target pool'
723
+ else if (targetPid != null && !knownPools.has(String(targetPid))) holdReason = `unknown target pool "${targetPid}"`
724
+ if (!mutate) holdReason = holdReason || 'proposal mode'
725
+ if (holdReason) {
726
+ held.reclassified++
727
+ details.push({ id: a.id, verb: 'reclassify', from: curPid, to: targetPid, applied: false, held: true, reason: holdReason })
728
+ touched.add(a.id)
729
+ continue
730
+ }
731
+ try {
732
+ const res = await reclassifyCore(dataDir, a.id, targetPid, core ? { element: core.element, summary: core.summary, sourceProjectId: curPid } : null)
733
+ if (res?.skipped) {
734
+ held.reclassified++
735
+ details.push({ id: a.id, verb: 'reclassify', from: curPid, to: targetPid, applied: false, held: true, reason: res.reason })
736
+ } else {
737
+ reclassified++
738
+ details.push({ id: a.id, verb: 'reclassify', from: curPid, to: targetPid, applied: true })
739
+ }
740
+ touched.add(a.id)
741
+ } catch (err) {
742
+ if (signal?.aborted) throw signal.reason ?? err
743
+ __mixdogMemoryLog(`[cycle3] reclassify failed id=${a.id}: ${err.message}\n`)
744
+ details.push({ id: a.id, verb: 'reclassify', error: err.message })
745
+ }
746
+ continue
747
+ }
690
748
  if (a.verb === 'superseded') {
691
749
  proposed.superseded++
692
750
  // Supersession archives (status flip), never physical DELETE, and applies
@@ -808,9 +866,11 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
808
866
  ` proposed_update=${proposed.updated} proposed_merge=${proposed.merged} proposed_delete=${proposed.deleted}` +
809
867
  ` applied_update=${updated} applied_merge=${merged} applied_delete=${deleted}` +
810
868
  ` applied_superseded=${superseded}` +
869
+ ` applied_reclassify=${reclassified}` +
811
870
  ` held_update=${held.updated} held_merge=${held.merged} held_delete=${held.deleted} held_superseded=${held.superseded}` +
871
+ ` held_reclassify=${held.reclassified}` +
812
872
  ` mode=${applyMode}\n`,
813
873
  )
814
874
 
815
- return { reviewed: cores.length, kept, updated, merged, deleted, superseded, proposed, held, applied: mutate, applyMode, details }
875
+ return { reviewed: cores.length, kept, updated, merged, deleted, superseded, reclassified, proposed, held, applied: mutate, applyMode, details }
816
876
  }
@@ -10,7 +10,7 @@ const MEMORY_TOKEN_STOPWORDS = new Set([
10
10
  'your', 'unless', 'with',
11
11
  'user', 'assistant', 'requested', 'request', 'asked', 'ask', 'stated', 'state', 'reported', 'report',
12
12
  'mentioned', 'mention', 'clarified', 'clarify', 'explicitly', 'currently',
13
- '사용자', '유저', '요청', '질문', '답변', '언급', '말씀', '설명', '보고', '무슨', '뭐야', '했지', 'user', 'asks', 'asked', 'request', 'requested', 'question', 'answer', 'reply', 'said', 'mentioned', 'explained', 'reported', 'what', 'huh',
13
+ '\uC0AC\uC6A9\uC790', '\uC720\uC800', '\uC694\uCCAD', '\uC9C8\uBB38', '\uB2F5\uBCC0', '\uC5B8\uAE09', '\uB9D0\uC500', '\uC124\uBA85', '\uBCF4\uACE0', '\uBB34\uC2A8', '\uBB50\uC57C', '\uD588\uC9C0', 'user', 'asks', 'asked', 'request', 'requested', 'question', 'answer', 'reply', 'said', 'mentioned', 'explained', 'reported', 'what', 'huh',
14
14
  ])
15
15
 
16
16
  export function normalizeMemoryToken(token) {
@@ -20,9 +20,9 @@ export function normalizeMemoryToken(token) {
20
20
  // Korean suffix stripping: basic particles + compound endings
21
21
  if (/[\uAC00-\uD7AF]/.test(normalized) && normalized.length > 2) {
22
22
  const stripped = normalized
23
- .replace(/(했었지|했더라|됐었나|됐던가|했는지|였는지|인건가|하려면|에서는|이라서|였더라|에서도|이었지|으로도|거였지|한건지|이었나)$/u, '')
24
- .replace(/(했던|했지|됐던|됐지|하게|되던|이라|에서|으로|하는|없는|있는|었던|하자|않게|할때|인지|인데|인건|이고|보다|처럼|까지|부터|마다|밖에|없이)$/u, '')
25
- .replace(/(은|는|이|가|을|를|랑|과|와|도|에|의|로|만|며|나|고|서|자|요)$/u, '')
23
+ .replace(/(\uD588\uC5C8\uC9C0|\uD588\uB354\uB77C|\uB410\uC5C8\uB098|\uB410\uB358\uAC00|\uD588\uB294\uC9C0|\uC600\uB294\uC9C0|\uC778\uAC74\uAC00|\uD558\uB824\uBA74|\uC5D0\uC11C\uB294|\uC774\uB77C\uC11C|\uC600\uB354\uB77C|\uC5D0\uC11C\uB3C4|\uC774\uC5C8\uC9C0|\uC73C\uB85C\uB3C4|\uAC70\uC600\uC9C0|\uD55C\uAC74\uC9C0|\uC774\uC5C8\uB098)$/u, '')
24
+ .replace(/(\uD588\uB358|\uD588\uC9C0|\uB410\uB358|\uB410\uC9C0|\uD558\uAC8C|\uB418\uB358|\uC774\uB77C|\uC5D0\uC11C|\uC73C\uB85C|\uD558\uB294|\uC5C6\uB294|\uC788\uB294|\uC5C8\uB358|\uD558\uC790|\uC54A\uAC8C|\uD560\uB54C|\uC778\uC9C0|\uC778\uB370|\uC778\uAC74|\uC774\uACE0|\uBCF4\uB2E4|\uCC98\uB7FC|\uAE4C\uC9C0|\uBD80\uD130|\uB9C8\uB2E4|\uBC16\uC5D0|\uC5C6\uC774)$/u, '')
25
+ .replace(/(\uC740|\uB294|\uC774|\uAC00|\uC744|\uB97C|\uB791|\uACFC|\uC640|\uB3C4|\uC5D0|\uC758|\uB85C|\uB9CC|\uBA70|\uB098|\uACE0|\uC11C|\uC790|\uC694)$/u, '')
26
26
  if (stripped.length >= 2) normalized = stripped
27
27
  }
28
28
 
@@ -314,14 +314,14 @@ function collectGroupTs(groupRows) {
314
314
  return all
315
315
  }
316
316
 
317
- // Activity-span header suffix: `(MM-DD HH:mm ~ HH:mm, n)`; the end keeps the
317
+ // Activity-span header suffix: `(MM-DD HH:mm ~ HH:mm, n entries)`; the end keeps the
318
318
  // MM-DD prefix only when it falls on a different calendar day than the start.
319
319
  function spanHeaderSuffix(minTs, maxTs, n) {
320
320
  const min = formatTs(minTs) // "YYYY-MM-DD HH:mm"
321
321
  const max = formatTs(maxTs)
322
322
  const startPart = min.slice(5) // "MM-DD HH:mm"
323
323
  const endPart = min.slice(0, 10) === max.slice(0, 10) ? max.slice(11) : max.slice(5)
324
- return `(${startPart} ~ ${endPart}, ${n})`
324
+ return `(${startPart} ~ ${endPart}, ${n} entries)`
325
325
  }
326
326
 
327
327
  // Session-grouped rendering for the GLOBAL query-less browse ("what did we
@@ -359,7 +359,7 @@ export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder
359
359
  const tsAll = collectGroupTs(groupRows)
360
360
  const suffix = tsAll.length
361
361
  ? ` ${spanHeaderSuffix(Math.min(...tsAll), Math.max(...tsAll), groupRows.length)}`
362
- : ` (${groupRows.length})`
362
+ : ` (${groupRows.length} entries)`
363
363
  parts.push(`## ${label}${mark}${suffix}`)
364
364
  const bodyStr = renderEntryLines(groupRows, { recencyOrder })
365
365
  const bodyLines = bodyStr === '(no results)'
@@ -120,6 +120,21 @@ function _abortError() {
120
120
  return e;
121
121
  }
122
122
 
123
+ /**
124
+ * Best-effort capacity probe for NON-COMPETING prewarm/warmup work. Returns
125
+ * true only when a slot could be taken right now without queuing — i.e. below
126
+ * the cap AND with no waiter already queued. Speculative warmers (explore's
127
+ * code_graph / find prewarm) consult this to skip/defer when the daemon is
128
+ * busy, so a fire-and-forget warm never pushes a real tool query into the
129
+ * queue (the "non-competing under fanout" guarantee). This is a probe, NOT a
130
+ * reservation: the answer can go stale under a race, which is acceptable for
131
+ * best-effort work. Real queries must use acquire()/withGate() and never gate
132
+ * themselves on this.
133
+ */
134
+ export function hasSpareCapacity() {
135
+ return _inflight < MAX_INFLIGHT && _queue.length === 0;
136
+ }
137
+
123
138
  /**
124
139
  * Run `fn` while holding one child-spawn slot. Release is guaranteed in a
125
140
  * finally so a throw/return from `fn` cannot leak a slot or deadlock the gate.
@@ -2,7 +2,7 @@
2
2
  * Unified config reader/writer.
3
3
  * Single file: mixdog-config.json with sections such as channels, agent, and memory.
4
4
  */
5
- import { readFileSync, mkdirSync, existsSync } from 'fs'
5
+ import { readFileSync, statSync, mkdirSync, existsSync } from 'fs'
6
6
  import { join, dirname } from 'path'
7
7
  import { createRequire } from 'module'
8
8
  import { resolvePluginData } from './plugin-paths.mjs'
@@ -24,6 +24,78 @@ const CONFIG_PATH = join(DATA_DIR, 'mixdog-config.json')
24
24
 
25
25
  const GENERATED_KEY = '_generated'
26
26
 
27
+ // Process-wide short-TTL cache of the RAW utf8 string of CONFIG_PATH (never
28
+ // the parsed object). Parallel agent spawns each hit the non-RMW read path
29
+ // (readAll → readSection/readConfig/readCapabilities); without this every
30
+ // spawn pays a synchronous readFileSync(CONFIG_PATH) on the event loop,
31
+ // serializing the whole fanout behind redundant disk I/O. Caching the raw
32
+ // string — not the parsed object — lets each caller re-JSON.parse for an
33
+ // isolated, freely-mutable object (no shared-reference poisoning). Only the
34
+ // non-RMW readAll() consults this; the RMW path (readAllForRmW) always reads
35
+ // disk under the lock. Read errors and malformed JSON are never cached.
36
+ let _configReadCache = null // { raw, mtimeMs, size, atMs } | null
37
+ // Optional coarse fast-path window (ms). Default 0 => the cached raw string is
38
+ // validated against the file's mtime+size on EVERY read via a cheap statSync
39
+ // (no content read, no whole-file JSON.parse), so a cross-process edit is
40
+ // observed immediately with ZERO time-based staleness while the expensive
41
+ // full read + parse are still skipped on an unchanged file. Set >0 to also
42
+ // skip the statSync within the window, trading a bounded staleness for fewer
43
+ // syscalls under extreme spawn fanout.
44
+ const CONFIG_READ_TTL_MS = (() => {
45
+ const n = Number(process.env.MIXDOG_CONFIG_READ_TTL_MS)
46
+ return Number.isFinite(n) && n >= 0 ? n : 0
47
+ })()
48
+
49
+ export function invalidateConfigReadCache() {
50
+ _configReadCache = null
51
+ }
52
+
53
+ function readConfigRawCached() {
54
+ const now = Date.now()
55
+ if (_configReadCache) {
56
+ // Fast path (only when a TTL window is configured): serve without any
57
+ // syscall until the window lapses.
58
+ if (CONFIG_READ_TTL_MS > 0 && now - _configReadCache.atMs < CONFIG_READ_TTL_MS) {
59
+ return _configReadCache.raw
60
+ }
61
+ // Freshness gate: metadata-only stat. Unchanged mtime+size => the cached
62
+ // raw is still current, cross-process writes included — atomic writers land
63
+ // a new inode with a fresh mtime, so this reliably detects them.
64
+ try {
65
+ const st = statSync(CONFIG_PATH)
66
+ if (st.mtimeMs === _configReadCache.mtimeMs && st.size === _configReadCache.size) {
67
+ _configReadCache.atMs = now
68
+ return _configReadCache.raw
69
+ }
70
+ } catch {
71
+ // stat failed (e.g. transient ENOENT mid-rename); fall through to a fresh
72
+ // read which re-applies the ENOENT/backup-restore semantics below.
73
+ }
74
+ }
75
+ // Stat BEFORE read: if a write lands between the two we cache newer content
76
+ // against the older mtime, so the very next call re-reads instead of getting
77
+ // stuck on stale bytes — the cache always converges toward fresh.
78
+ let st = null
79
+ try {
80
+ st = statSync(CONFIG_PATH)
81
+ } catch (err) {
82
+ if (err.code === 'ENOENT') { _configReadCache = null; return null }
83
+ process.stderr.write(`[config] readConfigRawCached: unexpected stat error for ${CONFIG_PATH}: ${err.message}\n`)
84
+ throw err
85
+ }
86
+ let raw
87
+ try {
88
+ raw = readFileSync(CONFIG_PATH, 'utf8')
89
+ } catch (err) {
90
+ if (err.code === 'ENOENT') { _configReadCache = null; return null }
91
+ // Fail closed on unknown read errors (EACCES, EIO, …); do NOT cache.
92
+ process.stderr.write(`[config] readJsonFile: unexpected read error for ${CONFIG_PATH}: ${err.message}\n`)
93
+ throw err
94
+ }
95
+ _configReadCache = { raw, mtimeMs: st.mtimeMs, size: st.size, atMs: now }
96
+ return raw
97
+ }
98
+
27
99
  function isPlainObject(value) {
28
100
  return !!value && typeof value === 'object' && !Array.isArray(value)
29
101
  }
@@ -36,24 +108,32 @@ export function stripGeneratedMarker(data) {
36
108
 
37
109
  function readJsonFile(path) {
38
110
  let raw
39
- try {
40
- raw = readFileSync(path, 'utf8')
41
- } catch (err) {
42
- if (err.code === 'ENOENT') return null
43
- // Fail closed on unknown read errors (EACCES, EIO, …). Returning {}
44
- // here would let a subsequent writeSection() serialize an empty
45
- // object back over an existing-but-temporarily-unreadable config and
46
- // erase every other section. Better to surface the read error and
47
- // abort the RMW than silently destroy data.
48
- process.stderr.write(`[config] readJsonFile: unexpected read error for ${path}: ${err.message}\n`)
49
- throw err
111
+ if (path === CONFIG_PATH) {
112
+ // Non-RMW read path: served from the short-TTL raw-string cache. Returning
113
+ // {} on a read error here would let a subsequent writeSection() serialize
114
+ // an empty object over an existing-but-temporarily-unreadable config and
115
+ // erase every other section, so read errors surface (throw) uncached.
116
+ raw = readConfigRawCached()
117
+ } else {
118
+ try {
119
+ raw = readFileSync(path, 'utf8')
120
+ } catch (err) {
121
+ if (err.code === 'ENOENT') return null
122
+ process.stderr.write(`[config] readJsonFile: unexpected read error for ${path}: ${err.message}\n`)
123
+ throw err
124
+ }
50
125
  }
126
+ if (raw == null) return null
51
127
  try {
52
128
  return JSON.parse(raw)
53
129
  } catch (err) {
54
130
  // Quarantine a malformed mixdog-config.json so the next boot starts fresh
55
131
  // instead of looping on a broken file.
56
132
  if (path === CONFIG_PATH) {
133
+ // A cached raw string that fails to parse must not be re-served; drop it
134
+ // so the post-quarantine/restore read hits disk fresh (malformed = never
135
+ // cached).
136
+ invalidateConfigReadCache()
57
137
  const corrupt = `${path}.corrupt-${Date.now()}`
58
138
  try { renameWithRetrySync(path, corrupt) } catch {}
59
139
  process.stderr.write(`[config] mixdog-config.json is malformed (${err.message}). Renamed to ${corrupt}. Restore it or delete to start fresh.\n`)
@@ -81,6 +161,9 @@ function writeJsonFile(path, data) {
81
161
  }
82
162
  writeJsonAtomicSync(path, data, { lock: false, fsyncDir: true, mode: 0o600, secret: true })
83
163
  if (path === CONFIG_PATH) {
164
+ // Our own write just changed CONFIG_PATH on disk; drop the stale raw cache
165
+ // synchronously so the next in-process readAll() reflects it immediately.
166
+ invalidateConfigReadCache()
84
167
  try { markUserDataInitialized(DATA_DIR) } catch {}
85
168
  try { backupUserData(DATA_DIR, 'post-config-write') } catch {}
86
169
  }
@@ -226,6 +309,9 @@ async function writeJsonFileAsync(path, data) {
226
309
  }
227
310
  await writeJsonAtomicAsync(path, data, { lock: false, fsyncDir: true, mode: 0o600, secret: true })
228
311
  if (path === CONFIG_PATH) {
312
+ // Parity with writeJsonFile: invalidate synchronously after the async
313
+ // write resolves so this process never serves a stale cached config.
314
+ invalidateConfigReadCache()
229
315
  try { markUserDataInitialized(DATA_DIR) } catch {}
230
316
  try { await backupUserDataAsync(DATA_DIR, 'post-config-write') } catch {}
231
317
  }
@@ -0,0 +1,12 @@
1
+ // Many independent singletons self-register process-level drains (exit,
2
+ // beforeExit, SIGTERM, …). Standalone scripts that import runtime modules
3
+ // directly bypass src/app.mjs, so raise the cap from a shared helper too.
4
+ export function ensureProcessListenerHeadroom(min = 64) {
5
+ try {
6
+ if (typeof process.getMaxListeners !== 'function' || typeof process.setMaxListeners !== 'function') return;
7
+ const current = process.getMaxListeners();
8
+ if (current === 0 || current >= min) return;
9
+ process.setMaxListeners(min);
10
+ } catch { /* ignore */ }
11
+ }
12
+
@@ -234,9 +234,12 @@ export function createCwdPlugins({
234
234
  }
235
235
 
236
236
  async function loadCoreMemoryContext() {
237
- // Boot should not pay for memory/PG startup unless explicitly requested.
238
- // Recall and memory tools still initialize the memory service on first use.
239
- if (process.env.MIXDOG_BOOT_CORE_MEMORY !== '1') {
237
+ // User-curated core memory injects into new sessions by default.
238
+ // Explicit opt-out (MIXDOG_BOOT_CORE_MEMORY=0/false/no/off) skips the
239
+ // memory/PG startup cost; recall and memory tools still initialize the
240
+ // memory service on first use.
241
+ const bootFlag = String(process.env.MIXDOG_BOOT_CORE_MEMORY ?? '').trim().toLowerCase();
242
+ if (bootFlag === '0' || bootFlag === 'false' || bootFlag === 'no' || bootFlag === 'off') {
240
243
  bootProfile('core-memory:skipped');
241
244
  return '';
242
245
  }
@@ -17,6 +17,21 @@ import {
17
17
  SEARCH_DEFAULT_MODEL,
18
18
  } from './workflow.mjs';
19
19
  import { writeStatuslineRoute } from './statusline-route.mjs';
20
+ import { SUMMARY_PREFIX } from '../runtime/agent/orchestrator/session/compact.mjs';
21
+ import {
22
+ hasUserConversationMessage,
23
+ } from '../runtime/agent/orchestrator/session/manager/prompt-utils.mjs';
24
+
25
+ function isSummaryAnchorMessage(message) {
26
+ return message?.role === 'user'
27
+ && typeof message.content === 'string'
28
+ && message.content.startsWith(SUMMARY_PREFIX);
29
+ }
30
+
31
+ function hasRouteHistoryMessage(messages) {
32
+ const list = Array.isArray(messages) ? messages : [];
33
+ return hasUserConversationMessage(list) || list.some(isSummaryAnchorMessage);
34
+ }
20
35
 
21
36
  // Model/route/search-route selection + mutation surface. Extracted verbatim from
22
37
  // the runtime API object; stateless helpers are imported directly and the
@@ -33,6 +48,8 @@ export function createModelRouteApi(deps) {
33
48
  persistLeadRoute, refreshRouteEffort,
34
49
  refreshStatuslineUsageSnapshot, scheduleStatuslineUsageRefresh,
35
50
  invalidateContextStatusCache, invalidateProviderCaches,
51
+ createCurrentSession, invalidatePreSessionToolSurface,
52
+ pushTranscriptRebind,
36
53
  collectSearchProviderModels,
37
54
  } = deps;
38
55
  return {
@@ -125,10 +142,41 @@ export function createModelRouteApi(deps) {
125
142
  await refreshRouteEffort(modelMeta);
126
143
  refreshStatuslineUsageSnapshot(getRoute());
127
144
  scheduleStatuslineUsageRefresh();
128
- if (!applyToCurrentSession) {
145
+ const session = getSession();
146
+ // Model/provider changes are next-session-only for a session the user
147
+ // has already talked in or compacted (provider-keyed prompt cache). But
148
+ // an EMPTY current session — no committed route history and no in-flight
149
+ // first-turn prompt — has no cache to protect, so /model before the first
150
+ // chat takes effect live: route + statusline update immediately.
151
+ const currentSessionEmpty = !!session
152
+ && !hasRouteHistoryMessage(session.messages)
153
+ && !hasRouteHistoryMessage(session.liveTurnMessages);
154
+ const applyLive = applyToCurrentSession || currentSessionEmpty;
155
+ if (!applyLive) {
156
+ return getRoute();
157
+ }
158
+ if (currentSessionEmpty && session?.id && typeof createCurrentSession === 'function') {
159
+ // If the boot create is still finishing SessionStart/deferred-surface
160
+ // work, drain that promise first. Otherwise createCurrentSession()
161
+ // would return the old in-flight promise after we tombstone/null the
162
+ // session, racing the intended rebuild for the new provider.
163
+ await createCurrentSession('model-switch-empty-drain');
164
+ const emptySession = getSession();
165
+ if (!emptySession?.id
166
+ || hasRouteHistoryMessage(emptySession.messages)
167
+ || hasRouteHistoryMessage(emptySession.liveTurnMessages)) {
168
+ invalidateContextStatusCache();
169
+ return getRoute();
170
+ }
171
+ statusRoutes?.clearGatewaySessionRoute?.(emptySession.id);
172
+ mgr.closeSession?.(emptySession.id, 'cli-model-switch-empty', { tombstone: true });
173
+ setSession(null);
174
+ invalidatePreSessionToolSurface?.();
175
+ await createCurrentSession('model-switch-empty');
176
+ pushTranscriptRebind?.();
177
+ invalidateContextStatusCache();
129
178
  return getRoute();
130
179
  }
131
- const session = getSession();
132
180
  if (session) {
133
181
  const route = getRoute();
134
182
  const updated = mgr.updateSessionRoute?.(session.id, {
@@ -31,7 +31,14 @@ export function createProviderAuthApi({
31
31
  }) {
32
32
  function refreshProviderCatalogsSoon() {
33
33
  if (typeof refreshProviderCatalogs !== 'function') return;
34
- try { void Promise.resolve(refreshProviderCatalogs()).catch(() => {}); } catch { /* best-effort */ }
34
+ try {
35
+ void Promise.resolve(refreshProviderCatalogs())
36
+ .then(() => {
37
+ invalidateProviderCaches();
38
+ warmProviderModelCache();
39
+ })
40
+ .catch(() => {});
41
+ } catch { /* best-effort */ }
35
42
  }
36
43
 
37
44
  return {
@@ -277,13 +277,13 @@ export function createProviderModels({
277
277
  return providerModelsFromCacheRows(await caches.providerModelsPromise);
278
278
  }
279
279
 
280
- function warmProviderModelCache() {
280
+ function warmProviderModelCache({ loadSecrets = false } = {}) {
281
281
  if (Array.isArray(caches.providerModelsCache.models) || caches.providerModelsPromise) return caches.providerModelsPromise;
282
282
  profile('warm:start');
283
283
  const seq = ++caches.providerModelsLoadSeq;
284
- caches.providerModelsPromise = loadProviderModelsFresh({ loadSecrets: false })
284
+ caches.providerModelsPromise = loadProviderModelsFresh({ loadSecrets })
285
285
  .then((models) => {
286
- if (seq === caches.providerModelsLoadSeq && shouldAdoptProviderModelCache(models, { loadSecrets: false })) {
286
+ if (seq === caches.providerModelsLoadSeq && shouldAdoptProviderModelCache(models, { loadSecrets })) {
287
287
  caches.providerModelsCache = { models, at: Date.now() };
288
288
  }
289
289
  bootProfile('provider-models:warm-ready', { count: models.length });
@@ -80,6 +80,24 @@ export function createResourceApi(deps) {
80
80
  }
81
81
  return chain.running;
82
82
  }
83
+ function configuredProfileIdentityLine() {
84
+ try {
85
+ const config = getConfig();
86
+ const stored = config?.profile ?? config?.agent?.profile;
87
+ const profile = cfgMod.normalizeProfileConfig(stored);
88
+ const title = clean(profile?.title);
89
+ if (!title) return '';
90
+ return `[profile] Current configured user name/identity: ${title}. This profile value is authoritative; ignore stale memory rows that say the user's identity is unknown.`;
91
+ } catch {
92
+ return '';
93
+ }
94
+ }
95
+ function isIdentityRecallQuery(query) {
96
+ const q = clean(query).toLowerCase().replace(/\s+/g, '');
97
+ if (!q) return false;
98
+ return /(?:\uB0B4\uAC00|\uB098\uB294|\uB098|\uC0AC\uC6A9\uC790|\uC720\uC800|user|my|me).*(?:\uB204\uAD6C|\uB204\uAD70|\uC815\uCCB4|\uC774\uB984|name|identity)|(?:whoami|whoami\?|whoami?)|who(?:am)?i|whoami/.test(q)
99
+ || /^(?:\uB098\uB204\uAD6C\uB0D0|\uB098\uB294\uB204\uAD6C\uB0D0|\uB0B4\uAC00\uB204\uAD6C\uB0D0|\uB0B4\uC774\uB984\uBB50|\uB0B4\uC774\uB984\uBB50\uC57C|whoami)$/i.test(q);
100
+ }
83
101
  return {
84
102
  mcpStatus() {
85
103
  return mcpStatus();
@@ -300,6 +318,10 @@ export function createResourceApi(deps) {
300
318
  const session = getSession();
301
319
  const currentCwd = getCurrentCwd();
302
320
  const baseQuery = query || args?.query || '';
321
+ if (isIdentityRecallQuery(baseQuery)) {
322
+ const profileLine = configuredProfileIdentityLine();
323
+ if (profileLine) return profileLine;
324
+ }
303
325
  if (args?.currentSession !== false && session?.id) {
304
326
  const currentText = currentSessionRecallRows(session, baseQuery, { limit: args?.limit });
305
327
  if (!isEmptyRecallText(currentText)) return currentText;