mixdog 0.9.23 → 0.9.25

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 (89) hide show
  1. package/package.json +2 -1
  2. package/scripts/boot-smoke.mjs +1 -1
  3. package/scripts/channel-daemon-smoke.mjs +327 -9
  4. package/scripts/channel-daemon-stub.mjs +12 -1
  5. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  6. package/scripts/explore-bench-tmp.mjs +17 -0
  7. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  8. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  9. package/scripts/tool-smoke.mjs +38 -30
  10. package/src/defaults/cycle3-review-prompt.md +11 -4
  11. package/src/defaults/memory-promote-prompt.md +9 -0
  12. package/src/rules/agent/30-explorer.md +6 -0
  13. package/src/rules/shared/01-tool.md +11 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  15. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  17. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  20. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  21. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  22. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  23. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
  24. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  25. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  26. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  27. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  28. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  29. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  31. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  32. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
  34. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  35. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  36. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  37. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  38. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
  39. package/src/runtime/channels/backends/discord.mjs +10 -3
  40. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  41. package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
  42. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  43. package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
  44. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
  45. package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
  46. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
  47. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  48. package/src/runtime/channels/lib/worker-main.mjs +9 -28
  49. package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -1
  50. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
  51. package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
  52. package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
  53. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  54. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  55. package/src/runtime/memory/tool-defs.mjs +1 -1
  56. package/src/runtime/shared/atomic-file.mjs +130 -2
  57. package/src/runtime/shared/background-tasks.mjs +1 -1
  58. package/src/runtime/shared/config.mjs +53 -1
  59. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  60. package/src/runtime/shared/tool-surface.mjs +19 -0
  61. package/src/runtime/shared/update-checker.mjs +3 -0
  62. package/src/runtime/shared/user-data-guard.mjs +66 -0
  63. package/src/session-runtime/config-lifecycle.mjs +175 -15
  64. package/src/session-runtime/mcp-glue.mjs +30 -0
  65. package/src/session-runtime/runtime-core.mjs +91 -7
  66. package/src/session-runtime/session-turn-api.mjs +42 -16
  67. package/src/session-runtime/tool-catalog.mjs +44 -0
  68. package/src/standalone/channel-admin.mjs +32 -3
  69. package/src/standalone/channel-daemon-client.mjs +3 -1
  70. package/src/standalone/channel-daemon-transport.mjs +202 -8
  71. package/src/standalone/channel-daemon.mjs +54 -17
  72. package/src/standalone/channel-worker.mjs +18 -7
  73. package/src/standalone/explore-tool.mjs +87 -15
  74. package/src/tui/App.jsx +2 -2
  75. package/src/tui/components/StatusLine.jsx +3 -3
  76. package/src/tui/components/ToolExecution.jsx +14 -2
  77. package/src/tui/components/TranscriptItem.jsx +1 -1
  78. package/src/tui/dist/index.mjs +246 -47
  79. package/src/tui/engine/agent-job-feed.mjs +47 -3
  80. package/src/tui/engine/notification-plan.mjs +5 -0
  81. package/src/tui/engine/session-api.mjs +6 -1
  82. package/src/tui/engine/tool-card-results.mjs +14 -5
  83. package/src/tui/engine/turn.mjs +9 -2
  84. package/src/tui/engine.mjs +31 -12
  85. package/src/ui/statusline-agents.mjs +36 -0
  86. package/src/ui/statusline.mjs +15 -5
  87. package/src/workflows/default/WORKFLOW.md +29 -38
  88. package/src/workflows/solo/WORKFLOW.md +15 -20
  89. package/src/runtime/channels/lib/seat-lock.mjs +0 -196
@@ -11,6 +11,65 @@ const LLM_JUDGE_CAP = 20
11
11
 
12
12
  const TRANSIENT_PROMOTE_CATEGORIES = new Set(['task', 'issue'])
13
13
 
14
+ // Category grades the pipeline trusts outright: user/cycle1 tagged durable
15
+ // knowledge. These are NEVER content-scanned — a curated constraint like
16
+ // "95% pass threshold" or a rule that cites a metric must still promote.
17
+ const DURABLE_TRUSTED_CATEGORIES = new Set(['rule', 'constraint', 'decision', 'preference', 'goal'])
18
+
19
+ // Deterministic (non-LLM) signature for transient work-state: status/review
20
+ // churn and benchmark/measurement RESULT snapshots. Narrow on purpose — it
21
+ // requires snapshot phrasing (a measurement verb or an explicit result-metric
22
+ // noun), NOT a bare percentage, so SLO/threshold constraints don't trip it.
23
+ // Applied only to non-durable categories (e.g. 'fact'), catching benchmark
24
+ // snapshots mis-tagged as durable-ish. The gate prompt only *discourages*
25
+ // these; this is the structural enforcement.
26
+ const TRANSIENT_SNAPSHOT_RE = new RegExp(
27
+ [
28
+ 'terminal-bench',
29
+ '\\bbenchmark(ed|ing|\\s+(run|result|score))\\b',
30
+ 'pass@\\d',
31
+ '\\btokens?\\s?\\/\\s?s(ec)?\\b',
32
+ 'status\\s+snapshot',
33
+ 'review(er)?\\s+(validation\\s+)?cycles?',
34
+ 'in[-\\s]progress',
35
+ '\\bWIP\\b',
36
+ '\\b(scored|achieved|measured|reached)\\b[^.]{0,40}\\d+(\\.\\d+)?\\s?%',
37
+ '\\d+(\\.\\d+)?\\s?%\\s+(pass\\s+rate|accuracy|score|throughput|latency)',
38
+ ].join('|'),
39
+ 'i',
40
+ )
41
+
42
+ // Structural promotion gate: is this row transient content (task/issue chatter
43
+ // or a status/benchmark snapshot) that must not be promoted pending→active?
44
+ // task/issue block by category. Durable knowledge categories are trusted and
45
+ // skip the content scan. Everything else (e.g. 'fact') is content-scanned so
46
+ // snapshots mis-categorized as durable are still blocked.
47
+ export function isTransientPromotion(row) {
48
+ if (!row) return false
49
+ const cat = String(row.category ?? '').toLowerCase()
50
+ if (TRANSIENT_PROMOTE_CATEGORIES.has(cat)) return true
51
+ if (DURABLE_TRUSTED_CATEGORIES.has(cat)) return false
52
+ return TRANSIENT_SNAPSHOT_RE.test(`${row.element ?? ''} ${row.summary ?? ''}`)
53
+ }
54
+
55
+ // Strip pending→active promotions for transient content out of the status
56
+ // batch BEFORE the cap clamp. Blocked rows are simply dropped from the batch,
57
+ // so they stay pending (held for re-confirmation on a later cycle) — never
58
+ // archived here, and reviewed_at is still bumped via the caller's reviewedIds.
59
+ export function blockTransientPromotions(statusBatch, rowsById) {
60
+ if (!statusBatch?.length) return { batch: statusBatch ?? [], blocked: 0 }
61
+ let blocked = 0
62
+ const batch = statusBatch.filter((item) => {
63
+ if (item.was_pending && item.new_status === 'active'
64
+ && isTransientPromotion(rowsById.get(Number(item.entry_id)))) {
65
+ blocked += 1
66
+ return false
67
+ }
68
+ return true
69
+ })
70
+ return { batch, blocked }
71
+ }
72
+
14
73
  // After the gate, cap how many pending→active promotions may land in one batch.
15
74
  // Overflow stays pending (not archived). Tiebreak: durable category before
16
75
  // task/issue, then score DESC, then older last_seen_at, then id ASC.
@@ -11,7 +11,7 @@ import { backfillCoreEmbeddings, nominateCoreCandidates, CORE_SUMMARY_MAX } from
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 {
14
- applyBatchStatusVerdicts, clampPendingPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
14
+ applyBatchStatusVerdicts, clampPendingPromotions, blockTransientPromotions, applySimpleStatus, applyUpdate, applyMerge, runPhaseMerge,
15
15
  } from './memory-cycle2-mutations.mjs'
16
16
  import {
17
17
  CYCLE2_ACTIVE_TARGET_CAP, CYCLE2_ACTIVE_MIN_FLOOR, loadCurrentRulesDigest, runUnifiedGate, sonnetCascade,
@@ -54,6 +54,7 @@ function mergeCycle2Results(a, b) {
54
54
  rejected_verb: Number(a.rejected_verb || 0) + Number(b.rejected_verb || 0),
55
55
  merge_rejected: Number(a.merge_rejected || 0) + Number(b.merge_rejected || 0),
56
56
  missing_core_summary: Number(a.missing_core_summary || 0) + Number(b.missing_core_summary || 0),
57
+ promotion_blocked: Number(a.promotion_blocked || 0) + Number(b.promotion_blocked || 0),
57
58
  core_embedding_backfill: Number(a.core_embedding_backfill || 0) + Number(b.core_embedding_backfill || 0),
58
59
  core_candidates_nominated: Number(a.core_candidates_nominated || 0) + Number(b.core_candidates_nominated || 0),
59
60
  rescore: mergeNestedNumeric(a.rescore, b.rescore),
@@ -211,6 +212,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
211
212
  updated: 0, kept: 0, rejected_verb: 0,
212
213
  merge_rejected: 0,
213
214
  missing_core_summary: 0,
215
+ promotion_blocked: 0,
214
216
  core_embedding_backfill: 0,
215
217
  core_candidates_nominated: 0,
216
218
  rescore: { updated: 0 },
@@ -523,8 +525,13 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
523
525
  }
524
526
  guardedBatch.push(item)
525
527
  }
528
+ // Structural transient block runs BEFORE the cap clamp: task/issue chatter
529
+ // and status/benchmark snapshots can never promote pending→active, so they
530
+ // are stripped here (held pending) regardless of remaining cap slots.
531
+ const blockRes = blockTransientPromotions(guardedBatch, rowsById)
532
+ stats.promotion_blocked = blockRes.blocked
526
533
  const activeCountForClamp = Math.max(0, activeCount - reservedDemotions)
527
- const clampRes = clampPendingPromotions(guardedBatch, rowsById, activeCountForClamp, activeTargetCap)
534
+ const clampRes = clampPendingPromotions(blockRes.batch, rowsById, activeCountForClamp, activeTargetCap)
528
535
  stats.promotion_clamped = clampRes.clamped
529
536
  const batchRes = await applyBatchStatusVerdicts(db, clampRes.batch, nowMs)
530
537
  stats.promoted += batchRes.promoted
@@ -702,7 +709,7 @@ async function _runCycle2Impl(db, config = {}, options = {}, dataDir = null) {
702
709
  ` core_backfill=${stats.core_embedding_backfill}` +
703
710
  ` active=${activeCount}/${activeTargetCap} review_active=${reviewActiveRows ? 1 : 0}` +
704
711
  ` | gate promoted=${stats.promoted} archived=${stats.archived}` +
705
- ` promotion_clamped=${stats.promotion_clamped}` +
712
+ ` promotion_clamped=${stats.promotion_clamped} promotion_blocked=${stats.promotion_blocked}` +
706
713
  ` updated=${stats.updated} kept=${stats.kept}` +
707
714
  ` rejected_verb=${stats.rejected_verb} merge_rejected=${stats.merge_rejected}` +
708
715
  ` missing_core=${stats.missing_core_summary}` +
@@ -11,13 +11,17 @@ function __mixdogMemoryLog(...args) {
11
11
  // {{CORE_REVIEW}} block for defaults/cycle3-review-prompt.md, then asks the
12
12
  // maintenance-preset LLM for one verdict per id. By default Cycle3 performs
13
13
  // conservative cleanup: safe compression updates and strict duplicate merges
14
- // are applied, while deletes stay proposals unless explicitly confirmed.
14
+ // are applied. Deletes now also apply in conservative mode when the model
15
+ // tags the entry as clear junk (a whitelisted junk reason) and — for
16
+ // redundant-with-default reasons — the text actually echoes a current rule,
17
+ // bounded by a per-run delete cap so a run keeps a safety margin instead of
18
+ // nuking the whole set. Unreasoned / non-junk deletes still require APPLY.
15
19
  //
16
20
  // Verdict line grammar (mirrors parseUnifiedFormat in memory-cycle2.mjs):
17
21
  // <id>|keep
18
22
  // <id>|update|<element>|<summary>
19
23
  // <id>|merge|<target_id>|<source_ids_csv>
20
- // <id>|delete
24
+ // <id>|delete|<reason>
21
25
 
22
26
  import { existsSync, readFileSync } from 'fs'
23
27
  import { join } from 'path'
@@ -136,6 +140,58 @@ function isStrictDuplicate(a, b) {
136
140
  return sim >= 0.78
137
141
  }
138
142
 
143
+ // Whitelisted delete reasons that conservative mode may auto-apply. These are
144
+ // the "clear junk" classes: a copy of a built-in/default rule, a bare
145
+ // restatement, an obsolete/already-implemented decision, or a past-event log.
146
+ // Anything outside this set (or a bare `delete` with no reason) stays held for
147
+ // APPLY CYCLE3 so genuinely durable rules are never removed unattended.
148
+ const SAFE_DELETE_REASONS = new Set([
149
+ 'duplicate', 'dup', 'duplicate_of_default', 'default', 'redundant',
150
+ 'restatement', 'restate', 'restates_default',
151
+ 'obsolete', 'implemented', 'done', 'completed', 'resolved',
152
+ 'superseded_decision', 'stale', 'past_event', 'event_log', 'log',
153
+ ])
154
+ // Reasons that claim redundancy with a built-in/default rule — these demand
155
+ // corroboration (the core text must actually echo the current rules digest)
156
+ // before a conservative auto-delete, so a mislabelled durable rule survives.
157
+ const DEFAULT_ECHO_REASONS = new Set([
158
+ 'duplicate', 'dup', 'duplicate_of_default', 'default', 'redundant',
159
+ 'restatement', 'restate', 'restates_default',
160
+ ])
161
+
162
+ function normalizeDeleteReason(reason) {
163
+ return String(reason ?? '')
164
+ .toLowerCase()
165
+ .trim()
166
+ .replace(/[^a-z0-9]+/g, '_')
167
+ .replace(/^_+|_+$/g, '')
168
+ }
169
+
170
+ // Max trigram similarity of `text` against any substantive line of the current
171
+ // rules digest — how strongly a core entry echoes a built-in rule.
172
+ function digestRedundancy(text, rulesDigest) {
173
+ if (!text || !rulesDigest) return 0
174
+ const lines = String(rulesDigest).split('\n').map(l => l.trim()).filter(l => l.length >= 12)
175
+ let best = 0
176
+ for (const line of lines) {
177
+ const d = charDice(text, line)
178
+ if (d > best) best = d
179
+ if (best >= 0.9) break
180
+ }
181
+ return best
182
+ }
183
+
184
+ function isSafeConservativeDelete(core, action, rulesDigest) {
185
+ const reason = normalizeDeleteReason(action?.reason)
186
+ if (!reason) return { ok: false, reason: 'delete needs a junk reason → APPLY CYCLE3' }
187
+ if (!SAFE_DELETE_REASONS.has(reason)) return { ok: false, reason: `delete reason "${reason}" not in safe set → APPLY CYCLE3` }
188
+ if (DEFAULT_ECHO_REASONS.has(reason)) {
189
+ const red = digestRedundancy(coreText(core), rulesDigest)
190
+ if (red < 0.5) return { ok: false, reason: `not redundant with defaults (sim=${red.toFixed(2)})` }
191
+ }
192
+ return { ok: true, reason }
193
+ }
194
+
139
195
  function formatRelatedRow(r) {
140
196
  const tag = r.project_id ? r.project_id : 'COMMON'
141
197
  const stat = r.status ? `[${r.status}]` : '[?]'
@@ -206,7 +262,8 @@ function parseVerdicts(raw, idSet) {
206
262
  }
207
263
  actions.push({ id, verb: 'merge', targetId, sourceIds })
208
264
  } else if (verb === 'delete') {
209
- actions.push({ id, verb: 'delete' })
265
+ const reason = (parts[2] ?? '').trim()
266
+ actions.push({ id, verb: 'delete', reason: reason || null })
210
267
  } else if (verb === 'superseded' || verb === 'supersede') {
211
268
  // Require newer-id proof: `id|superseded|<newer_id>`. Without a valid
212
269
  // newer active core id the supersession has no evidence → drop to keep.
@@ -553,6 +610,10 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
553
610
  const held = { updated: 0, merged: 0, deleted: 0, superseded: 0 }
554
611
  const details = []
555
612
  const touched = new Set() // ids already acted on this cycle — avoid double action
613
+ // Safety margin: even when every junk verdict is legitimate, a single
614
+ // conservative run applies at most this many outright deletes and holds the
615
+ // rest for the next pass. Prevents an over-eager model from clearing the set.
616
+ const conservativeDeleteCap = Math.max(1, Math.ceil(cores.length * 0.35))
556
617
 
557
618
  // Core-store edit/delete calls are the mutation unit; checkpoints sit before
558
619
  // each action/source and after each awaited unit, not inside one file-store write.
@@ -596,19 +657,28 @@ async function _runCycle3Impl(db, config, dataDir, options = {}) {
596
657
  }
597
658
  if (a.verb === 'delete') {
598
659
  proposed.deleted++
599
- if (!confirmed) {
660
+ // confirmed → always delete. conservative → delete only clear junk (safe
661
+ // reason, corroborated for redundant-with-default), capped per run.
662
+ // proposal → always hold.
663
+ let applyDelete = confirmed
664
+ let holdReason = 'proposal mode'
665
+ let safeReason = null
666
+ if (!confirmed && conservative) {
667
+ const safeDel = isSafeConservativeDelete(coreById.get(a.id), a, rulesDigest)
668
+ if (!safeDel.ok) holdReason = safeDel.reason
669
+ else if (deleted >= conservativeDeleteCap) holdReason = `conservative delete cap ${conservativeDeleteCap} reached`
670
+ else { applyDelete = true; safeReason = safeDel.reason }
671
+ }
672
+ if (!applyDelete) {
600
673
  held.deleted++
601
- details.push({
602
- id: a.id, verb: 'delete', applied: false, held: true,
603
- reason: conservative ? 'delete requires APPLY CYCLE3' : 'proposal mode',
604
- })
674
+ details.push({ id: a.id, verb: 'delete', applied: false, held: true, reason: holdReason })
605
675
  touched.add(a.id)
606
676
  continue
607
677
  }
608
678
  try {
609
679
  await deleteCore(dataDir, a.id)
610
680
  deleted++
611
- details.push({ id: a.id, verb: 'delete', applied: true })
681
+ details.push({ id: a.id, verb: 'delete', applied: true, reason: safeReason || 'confirmed' })
612
682
  touched.add(a.id)
613
683
  } catch (err) {
614
684
  if (signal?.aborted) throw signal.reason ?? err
@@ -700,7 +700,10 @@ export function createQueryHandlers({
700
700
  // caller's own session marked "(current)" via the currentSessionId hint.
701
701
  // Falls through to the flat list when everything is one session.
702
702
  const _currentSessionHint = String(args?.currentSessionId || '').trim()
703
- return { text: recallCapPrefix + renderSessionGroupedLines(sliced, { currentSessionId: _currentSessionHint }) }
703
+ // recencyOrder on the date path: without it, chunk members (stored ts-ASC
704
+ // per root) interleave out of order with raw rows inside each session
705
+ // group (e.g. 04:33 rendered above 04:41).
706
+ return { text: recallCapPrefix + renderSessionGroupedLines(sliced, { currentSessionId: _currentSessionHint, recencyOrder: sort === 'date' }) }
704
707
  }
705
708
 
706
709
  async function dumpSessionRootChunks(args = {}) {
@@ -316,7 +316,11 @@ function shortSessionLabel(sid) {
316
316
  // The caller's own session (currentSessionId hint) is marked "(current)".
317
317
  // Single-session (or session-less) result sets fall through to the flat
318
318
  // renderer — no headers when grouping adds nothing.
319
- export function renderSessionGroupedLines(rows, { currentSessionId } = {}) {
319
+ // recencyOrder (date-sorted browse) is forwarded to renderEntryLines so each
320
+ // group's lines are globally ts-desc: without it a chunk root's members (stored
321
+ // ts-ASC) interleave with raw rows and invert the visible timeline within a
322
+ // session (e.g. 04:33 rendered above 04:41).
323
+ export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder = false } = {}) {
320
324
  if (!rows || rows.length === 0) return '(no results)'
321
325
  const groups = new Map()
322
326
  for (const r of rows) {
@@ -325,14 +329,14 @@ export function renderSessionGroupedLines(rows, { currentSessionId } = {}) {
325
329
  if (!groups.has(key)) groups.set(key, [])
326
330
  groups.get(key).push(r)
327
331
  }
328
- if (groups.size <= 1) return renderEntryLines(rows)
332
+ if (groups.size <= 1) return renderEntryLines(rows, { recencyOrder })
329
333
  const current = String(currentSessionId || '').trim()
330
334
  const parts = []
331
335
  for (const [sid, groupRows] of groups) {
332
336
  const mark = current && sid === current ? ' (current)' : ''
333
337
  const label = sid === '(no session)' ? sid : `session ${shortSessionLabel(sid)}`
334
338
  parts.push(`## ${label}${mark}`)
335
- parts.push(renderEntryLines(groupRows))
339
+ parts.push(renderEntryLines(groupRows, { recencyOrder }))
336
340
  }
337
341
  return parts.join('\n')
338
342
  }
@@ -21,7 +21,7 @@ export const TOOL_DEFS = [
21
21
  status: { type: 'string', enum: ['pending','active','archived'], description: 'Lifecycle status.' },
22
22
  limit: { type: 'number', description: 'Max rows/items.' },
23
23
  confirm: { type: 'string', description: 'Exact confirmation phrase for destructive actions.' },
24
- project_id: { type: 'string', description: 'Core pool: common, slug, or *.' },
24
+ project_id: { type: 'string', description: 'Core pool: common, slug, or *. Required for core add/edit; there is no default pool.' },
25
25
  },
26
26
  required: ['action'],
27
27
  },
@@ -13,7 +13,10 @@ import {
13
13
  } from 'fs';
14
14
  import { dirname, basename, join } from 'path';
15
15
  import { randomBytes } from 'crypto';
16
- import { execFileSync } from 'child_process';
16
+ import { execFile, execFileSync } from 'child_process';
17
+ import { promisify } from 'util';
18
+
19
+ const _execFileAsync = promisify(execFile);
17
20
 
18
21
  const RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY', 'EEXIST']);
19
22
  const LOCK_WAIT_CODES = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
@@ -419,7 +422,10 @@ export async function withFileLock(lockPath, fn, opts = {}) {
419
422
  }
420
423
  try { writeFileSync(fd, `${process.pid} ${Date.now()} ${_OWNER_TOKEN}\n`, 'utf8'); } catch {}
421
424
  try {
422
- if (opts.secret === true) _enforceOwnerOnlyAclWin32(lockPath);
425
+ // Async ACL enforcement (promisified execFile) so a secret-bearing
426
+ // async holder never blocks the event loop on icacls; identical
427
+ // fail-closed semantics to the sync variant.
428
+ if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(lockPath);
423
429
  return await fn();
424
430
  } finally {
425
431
  try { closeSync(fd); } catch {}
@@ -523,6 +529,59 @@ function _enforceOwnerOnlyAclWin32(targetPath) {
523
529
  }
524
530
  }
525
531
 
532
+ // ── Async owner-only ACL enforcement (non-blocking, fail-closed) ─────
533
+ // Byte-for-byte the same policy as `_enforceOwnerOnlyAclWin32`, but the two
534
+ // icacls invocations (and the one-time whoami SID resolution) run through
535
+ // promisified execFile so the event loop is never blocked on a subprocess.
536
+ // Shares the `_cachedUserSid` cache with the sync variant, and throws the
537
+ // same EACL* codes so callers keep their fail-closed guarantees.
538
+ async function _resolveCurrentUserPrincipalAsync() {
539
+ const systemRoot = process.env.SystemRoot || process.env.windir;
540
+ if (systemRoot) {
541
+ const whoami = join(systemRoot, 'System32', 'whoami.exe');
542
+ if (existsSync(whoami)) {
543
+ try {
544
+ const { stdout } = await _execFileAsync(whoami, ['/user', '/fo', 'csv', '/nh'], {
545
+ encoding: 'utf8',
546
+ windowsHide: true,
547
+ });
548
+ const m = String(stdout).match(/S-1-5-[0-9-]+/);
549
+ if (m) return m[0];
550
+ } catch {}
551
+ }
552
+ }
553
+ const err = new Error('cannot resolve current Windows user for owner-only ACL enforcement');
554
+ err.code = 'EACLNOUSER';
555
+ throw err;
556
+ }
557
+
558
+ async function _enforceOwnerOnlyAclWin32Async(targetPath) {
559
+ if (process.platform !== 'win32') return;
560
+ const systemRoot = process.env.SystemRoot || process.env.windir;
561
+ if (!systemRoot) {
562
+ const err = new Error('SystemRoot not set; cannot locate icacls.exe for owner-only ACL enforcement');
563
+ err.code = 'EACLNOROOT';
564
+ throw err;
565
+ }
566
+ const icacls = join(systemRoot, 'System32', 'icacls.exe');
567
+ if (!existsSync(icacls)) {
568
+ const err = new Error(`icacls.exe not found at ${icacls}; refusing to leave secret world-readable`);
569
+ err.code = 'EACLNOICACLS';
570
+ throw err;
571
+ }
572
+ if (_cachedUserSid === null) _cachedUserSid = await _resolveCurrentUserPrincipalAsync();
573
+ const principal = _icaclsPrincipal(_cachedUserSid);
574
+ try {
575
+ await _execFileAsync(icacls, [targetPath, '/reset'], { windowsHide: true });
576
+ await _execFileAsync(icacls, [targetPath, '/inheritance:r', '/grant:r', `${principal}:(F)`], { windowsHide: true });
577
+ } catch (err) {
578
+ const e = new Error(`icacls failed to apply owner-only ACL to ${targetPath}: ${err?.message || err}`);
579
+ e.code = 'EACLFAIL';
580
+ e.cause = err;
581
+ throw e;
582
+ }
583
+ }
584
+
526
585
  export function writeFileAtomicSync(filePath, data, opts = {}) {
527
586
  const run = () => {
528
587
  const dir = dirname(filePath);
@@ -648,6 +707,75 @@ export function updateJsonAtomicSync(filePath, mutator, opts = {}) {
648
707
  }, opts);
649
708
  }
650
709
 
710
+ // ── Async atomic file write (non-blocking secret ACL) ───────────────
711
+ // Mirror of writeFileAtomicSync, but the owner-only ACL enforcement on the
712
+ // temp and final path runs through the async icacls variant so a debounced
713
+ // timer flush never blocks the event loop on a subprocess. The tiny
714
+ // writeFileSync/renameSync of a small JSON payload stay synchronous (they are
715
+ // not the hitch); only the icacls calls — the actual blockers — are awaited.
716
+ // The truncate renameFallback branch is intentionally omitted: it only ever
717
+ // applied to non-secret writes (opts.secret !== true), and every async caller
718
+ // here is a secret config write, so behavior is identical.
719
+ export async function writeFileAtomicAsync(filePath, data, opts = {}) {
720
+ const run = async () => {
721
+ const dir = dirname(filePath);
722
+ mkdirSync(dir, { recursive: true });
723
+ const tmp = join(dir, `.${basename(filePath)}.${randomBytes(12).toString('hex')}.tmp`);
724
+ try {
725
+ const writeOpts = { encoding: opts.encoding || 'utf8', flag: 'wx', mode: opts.mode !== undefined ? opts.mode : 0o600 };
726
+ writeFileSync(tmp, data, writeOpts);
727
+ if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(tmp);
728
+ if (opts.fsync !== false) {
729
+ let fd = null;
730
+ try {
731
+ fd = openSync(tmp, 'r');
732
+ fsyncSync(fd);
733
+ } catch (err) {
734
+ if (!['EPERM', 'ENOTSUP', 'EINVAL'].includes(err?.code)) throw err;
735
+ } finally {
736
+ try { if (fd !== null) closeSync(fd); } catch {}
737
+ }
738
+ }
739
+ if (opts.createOnly === true) {
740
+ try {
741
+ linkSync(tmp, filePath);
742
+ } catch (err) {
743
+ try { unlinkSync(tmp); } catch {}
744
+ if (err?.code === 'EEXIST') return false;
745
+ throw err;
746
+ }
747
+ try { unlinkSync(tmp); } catch {}
748
+ } else {
749
+ renameWithRetrySync(tmp, filePath, opts);
750
+ }
751
+ if (opts.secret === true) await _enforceOwnerOnlyAclWin32Async(filePath);
752
+ if (opts.fsyncDir === true) {
753
+ let dfd = null;
754
+ try {
755
+ dfd = openSync(dir, 'r');
756
+ fsyncSync(dfd);
757
+ } catch (err) {
758
+ if (!['EPERM', 'ENOTSUP', 'EINVAL', 'EACCES'].includes(err?.code)) throw err;
759
+ } finally {
760
+ try { if (dfd !== null) closeSync(dfd); } catch {}
761
+ }
762
+ }
763
+ return true;
764
+ } catch (err) {
765
+ try { if (existsSync(tmp)) unlinkSync(tmp); } catch {}
766
+ throw err;
767
+ }
768
+ };
769
+ if (opts.lock === true) {
770
+ return withFileLock(`${filePath}.lock`, run, opts);
771
+ }
772
+ return run();
773
+ }
774
+
775
+ export function writeJsonAtomicAsync(filePath, value, opts = {}) {
776
+ return writeFileAtomicAsync(filePath, JSON.stringify(value, null, opts.compact ? 0 : 2) + '\n', opts);
777
+ }
778
+
651
779
  // Async read-modify-write. Same lock path (`${filePath}.lock`) and protocol
652
780
  // as updateJsonAtomicSync, so it is mutually exclusive with the sync variant.
653
781
  // The critical section itself is synchronous (readFileSync + writeJsonAtomicSync);
@@ -51,7 +51,7 @@ export function executionModeSchemaDescription(defaultMode = 'sync') {
51
51
  if (defaultMode === 'async') {
52
52
  return 'sync = inline result; async = task_id + completion notification. Default async.';
53
53
  }
54
- return 'Runs sync; long-running auto-promotes to a background task_id + completion notification. async forces background.';
54
+ return 'Runs sync (inline result); no default auto-background. async forces a background task_id + completion notification.';
55
55
  }
56
56
 
57
57
  export function taskIdFromArgs(args = {}) {
@@ -6,9 +6,10 @@ import { readFileSync, 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'
9
- import { renameWithRetrySync, writeJsonAtomicSync, withFileLockSync } from './atomic-file.mjs'
9
+ import { renameWithRetrySync, writeJsonAtomicSync, writeJsonAtomicAsync, withFileLockSync, withFileLock } from './atomic-file.mjs'
10
10
  import {
11
11
  backupUserData,
12
+ backupUserDataAsync,
12
13
  markUserDataInitialized,
13
14
  loadLatestMixdogConfigFromBackup,
14
15
  hasUserDataInitMarker,
@@ -210,6 +211,57 @@ export function updateSection(section, updater) {
210
211
  })
211
212
  }
212
213
 
214
+ // ── Async write path (non-blocking) ─────────────────────────────────
215
+ // Parity with the sync RMW above, but every heavy blocker (cross-process
216
+ // lock wait, owner-only icacls ACL, user-data backup copy tree) is awaited
217
+ // off the event loop. Reuses the SAME lock file (`${CONFIG_PATH}.lock`) and
218
+ // secret:true ACL protocol, so an async writer and a sync writer are mutually
219
+ // exclusive against one another AND against other processes — cross-process
220
+ // RMW linearizability is preserved. The in-lock read (readAllForRmW) stays
221
+ // synchronous: it is a small local read, not the hitch source.
222
+ async function writeJsonFileAsync(path, data) {
223
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
224
+ if (path === CONFIG_PATH) {
225
+ try { await backupUserDataAsync(DATA_DIR, 'pre-config-write') } catch {}
226
+ }
227
+ await writeJsonAtomicAsync(path, data, { lock: false, fsyncDir: true, mode: 0o600, secret: true })
228
+ if (path === CONFIG_PATH) {
229
+ try { markUserDataInitialized(DATA_DIR) } catch {}
230
+ try { await backupUserDataAsync(DATA_DIR, 'post-config-write') } catch {}
231
+ }
232
+ }
233
+
234
+ async function writeAllAsync(data) {
235
+ await writeJsonFileAsync(CONFIG_PATH, data)
236
+ }
237
+
238
+ // secret:true clamps the shared-home lock file owner-only on win32 via the
239
+ // ASYNC icacls variant inside withFileLock (fail-closed, non-blocking).
240
+ function withConfigLockAsync(fn) {
241
+ return withFileLock(`${CONFIG_PATH}.lock`, fn, { secret: true })
242
+ }
243
+
244
+ export async function updateConfigAsync(updater) {
245
+ let saved = null
246
+ await withConfigLockAsync(async () => {
247
+ const current = stripGeneratedMarker(readAllForRmW()) || {}
248
+ const next = typeof updater === 'function' ? updater({ ...current }) : updater
249
+ if (!isPlainObject(next)) throw new Error('[config] updateConfigAsync updater must return an object')
250
+ saved = stripGeneratedMarker(next) || {}
251
+ await writeAllAsync(saved)
252
+ })
253
+ return saved
254
+ }
255
+
256
+ export async function updateSectionAsync(section, updater) {
257
+ await withConfigLockAsync(async () => {
258
+ const all = readAllForRmW()
259
+ const current = stripGeneratedMarker(all[section] || {})
260
+ all[section] = stripGeneratedMarker(typeof updater === 'function' ? updater(current) : updater)
261
+ await writeAllAsync(all)
262
+ })
263
+ }
264
+
213
265
  // ── Capabilities (B2 central path policy) ───────────────────────────
214
266
  // Top-level `capabilities` section in mixdog-config.json. Safe defaults
215
267
  // win on missing/malformed input — every cap is OFF unless explicitly
@@ -2,7 +2,7 @@ export const TOOL_SYNC_EXECUTION_CONTRACT =
2
2
  'Runs synchronously in this tool call.';
3
3
 
4
4
  export const TOOL_ASYNC_EXECUTION_CONTRACT =
5
- 'Runs sync; long-running auto-promotes to a task_id + completion notification. async forces background. status/read/wait are recovery/blocking only.';
5
+ 'Runs sync inline; no default auto-background. async forces a background task_id + completion notification. status/read/wait are recovery/blocking only.';
6
6
 
7
7
  export const TOOL_MANUAL_CONTROL_CONTRACT =
8
8
  'wait/read/status/cancel are for explicit blocking or recovery only.';
@@ -583,6 +583,25 @@ export function aggregateToolCategoryEntry(name, args = {}, category = '') {
583
583
  };
584
584
  }
585
585
 
586
+ /**
587
+ * Rebuild the per-category count map for the DONE state, excluding calls that
588
+ * terminated in an error (rec.isError). Mirrors the call-time accumulation in
589
+ * turn.mjs (sum aggregateToolCategoryEntry(...).count per key) but drops failed
590
+ * calls so the completed label counts only successful work — across ALL
591
+ * categories, not just Memory. The active/in-flight header keeps using the raw
592
+ * `categories` map, so in-flight counting is unchanged.
593
+ */
594
+ export function aggregateDoneCategories(calls = []) {
595
+ const map = new Map();
596
+ for (const rec of calls || []) {
597
+ if (!rec || rec.isError) continue;
598
+ const entry = aggregateToolCategoryEntry(rec.name, rec.args, rec.category);
599
+ const prev = map.get(entry.key);
600
+ map.set(entry.key, { ...entry, count: Number(prev?.count || 0) + Number(entry.count || 1) });
601
+ }
602
+ return Object.fromEntries(map);
603
+ }
604
+
586
605
  function aggregateCount(value) {
587
606
  if (value && typeof value === 'object') return Math.max(0, Number(value.count || 0));
588
607
  return Math.max(0, Number(value || 0));
@@ -188,6 +188,9 @@ export function runGlobalUpdate() {
188
188
  child = spawn(npmCmd, ['install', '-g', `${PACKAGE_NAME}@latest`], {
189
189
  stdio: 'ignore',
190
190
  windowsHide: true,
191
+ // detached: survive parent exit — quitting the TUI mid-install must
192
+ // not kill npm halfway and leave the global install corrupted.
193
+ detached: true,
191
194
  shell: process.platform === 'win32',
192
195
  });
193
196
  } catch (err) {