mixdog 0.9.22 → 0.9.24

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 (132) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/boot-smoke.mjs +1 -1
  4. package/scripts/channel-daemon-smoke.mjs +483 -0
  5. package/scripts/channel-daemon-stub.mjs +80 -0
  6. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  7. package/scripts/explore-bench-tmp.mjs +17 -0
  8. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  9. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  10. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  11. package/scripts/tool-smoke.mjs +68 -47
  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 +226 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
  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/providers/openai-compat-stream.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  24. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  28. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  29. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  31. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  32. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
  33. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  34. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  35. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  36. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  37. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  38. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  39. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  40. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  41. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  42. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
  45. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  46. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  47. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  48. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  49. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
  51. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  52. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  53. package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
  54. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
  55. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  56. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  57. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  58. package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
  59. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  60. package/src/runtime/channels/lib/worker-main.mjs +42 -30
  61. package/src/runtime/memory/index.mjs +54 -7
  62. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  63. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  64. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  65. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  66. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  67. package/src/runtime/memory/tool-defs.mjs +1 -1
  68. package/src/runtime/shared/atomic-file.mjs +150 -6
  69. package/src/runtime/shared/background-tasks.mjs +1 -1
  70. package/src/runtime/shared/config.mjs +53 -1
  71. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  72. package/src/runtime/shared/tool-primitives.mjs +31 -1
  73. package/src/runtime/shared/tool-surface.mjs +42 -13
  74. package/src/runtime/shared/update-checker.mjs +3 -0
  75. package/src/runtime/shared/user-data-guard.mjs +66 -0
  76. package/src/session-runtime/config-lifecycle.mjs +221 -20
  77. package/src/session-runtime/lifecycle-api.mjs +9 -0
  78. package/src/session-runtime/mcp-glue.mjs +93 -1
  79. package/src/session-runtime/resource-api.mjs +62 -8
  80. package/src/session-runtime/runtime-core.mjs +118 -4
  81. package/src/session-runtime/session-text.mjs +41 -0
  82. package/src/session-runtime/session-turn-api.mjs +50 -0
  83. package/src/session-runtime/settings-api.mjs +8 -1
  84. package/src/session-runtime/tool-catalog.mjs +350 -38
  85. package/src/session-runtime/tool-defs.mjs +7 -7
  86. package/src/session-runtime/workflow.mjs +2 -1
  87. package/src/standalone/channel-admin.mjs +32 -3
  88. package/src/standalone/channel-daemon-client.mjs +226 -0
  89. package/src/standalone/channel-daemon-transport.mjs +545 -0
  90. package/src/standalone/channel-daemon.mjs +176 -0
  91. package/src/standalone/channel-worker.mjs +224 -4
  92. package/src/standalone/explore-tool.mjs +87 -15
  93. package/src/standalone/hook-bus.mjs +71 -3
  94. package/src/tui/App.jsx +107 -19
  95. package/src/tui/app/clipboard.mjs +39 -19
  96. package/src/tui/app/doctor.mjs +57 -0
  97. package/src/tui/app/extension-pickers.mjs +53 -9
  98. package/src/tui/app/maintenance-pickers.mjs +0 -5
  99. package/src/tui/app/slash-dispatch.mjs +4 -4
  100. package/src/tui/app/text-layout.mjs +11 -0
  101. package/src/tui/app/use-mouse-input.mjs +235 -51
  102. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  103. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  104. package/src/tui/app/use-transcript-window.mjs +55 -1
  105. package/src/tui/components/Message.jsx +1 -1
  106. package/src/tui/components/PromptInput.jsx +3 -1
  107. package/src/tui/components/QueuedCommands.jsx +21 -10
  108. package/src/tui/components/StatusLine.jsx +3 -3
  109. package/src/tui/components/ToolExecution.jsx +16 -4
  110. package/src/tui/components/TranscriptItem.jsx +1 -1
  111. package/src/tui/dist/index.mjs +820 -326
  112. package/src/tui/engine/agent-job-feed.mjs +5 -0
  113. package/src/tui/engine/notification-plan.mjs +5 -0
  114. package/src/tui/engine/session-api.mjs +23 -8
  115. package/src/tui/engine/session-flow.mjs +6 -0
  116. package/src/tui/engine/tool-card-results.mjs +14 -5
  117. package/src/tui/engine/turn.mjs +9 -2
  118. package/src/tui/engine.mjs +32 -5
  119. package/src/tui/index.jsx +62 -18
  120. package/src/tui/paste-attachments.mjs +26 -0
  121. package/src/ui/statusline-agents.mjs +36 -0
  122. package/src/ui/statusline.mjs +60 -10
  123. package/src/ui/tool-card.mjs +8 -1
  124. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  125. package/src/workflows/bench/WORKFLOW.md +46 -0
  126. package/src/workflows/default/WORKFLOW.md +6 -0
  127. package/src/workflows/solo/WORKFLOW.md +5 -0
  128. package/vendor/ink/build/ink.js +23 -1
  129. package/vendor/ink/build/output.js +154 -71
  130. package/vendor/ink/build/render-node-to-output.js +44 -2
  131. package/vendor/ink/build/render.js +4 -0
  132. package/vendor/ink/build/renderer.js +4 -1
@@ -352,11 +352,79 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
352
352
  return saveRules(rules);
353
353
  }
354
354
 
355
+ // --- debounced hooks.json persist ------------------------------------------
356
+ // Rule state is flipped in the in-memory rulesCache synchronously so the
357
+ // picker reopen renders from memory (no disk re-read); the heavy file RMW
358
+ // (saveRules) is deferred so a burst of toggle key presses collapses into one
359
+ // write. Mirrors config-lifecycle's scheduleSkillsSave pattern.
360
+ const RULES_SAVE_DEBOUNCE_MS = 400;
361
+ // Pending flips are tracked as index→enabled patches (not a full snapshot) so
362
+ // that if hooks.json is edited externally during the debounce window the flush
363
+ // can reload the current disk rules and reapply only our enabled-flag changes
364
+ // instead of clobbering the external edit.
365
+ let pendingRulePatches = null;
366
+ let rulesBaseMtime = null;
367
+ let rulesSaveTimer = null;
368
+
369
+ function flushRules() {
370
+ if (rulesSaveTimer) {
371
+ clearTimeout(rulesSaveTimer);
372
+ rulesSaveTimer = null;
373
+ }
374
+ if (!pendingRulePatches || pendingRulePatches.size === 0) {
375
+ pendingRulePatches = null;
376
+ rulesBaseMtime = null;
377
+ return;
378
+ }
379
+ const patches = pendingRulePatches;
380
+ const base = rulesBaseMtime;
381
+ pendingRulePatches = null;
382
+ rulesBaseMtime = null;
383
+ try {
384
+ let rules;
385
+ const diskChanged = rulesPath && existsSync(rulesPath) && statSync(rulesPath).mtimeMs !== base;
386
+ if (diskChanged) {
387
+ // External edit during the debounce window: reload from disk and reapply
388
+ // only our flips so the external change survives.
389
+ const parsed = JSON.parse(readFileSync(rulesPath, 'utf8'));
390
+ rules = normalizeRules(parsed).filter((rule) => rule && typeof rule === 'object');
391
+ } else {
392
+ rules = [...(rulesCache.rules || [])];
393
+ }
394
+ for (const [index, enabled] of patches) {
395
+ if (index >= 0 && index < rules.length) rules[index] = { ...rules[index], enabled };
396
+ }
397
+ saveRules(rules);
398
+ } catch (error) {
399
+ emit('hook:error', { error: `debounced hooks save failed: ${error?.message || error}` });
400
+ }
401
+ }
402
+
403
+ function scheduleRulesSave() {
404
+ if (rulesSaveTimer) clearTimeout(rulesSaveTimer);
405
+ rulesSaveTimer = setTimeout(flushRules, RULES_SAVE_DEBOUNCE_MS);
406
+ rulesSaveTimer.unref?.();
407
+ }
408
+
355
409
  function setRuleEnabled(index, enabled) {
356
410
  const rules = [...loadRules()];
357
411
  if (!Number.isInteger(index) || index < 0 || index >= rules.length) throw new Error(`hook rule not found: ${index}`);
358
- rules[index] = { ...rules[index], enabled: enabled !== false };
359
- return saveRules(rules);
412
+ const nextEnabled = enabled !== false;
413
+ rules[index] = { ...rules[index], enabled: nextEnabled };
414
+ // Adopt in memory immediately: keep the last-known disk mtime so loadRules
415
+ // returns this flipped cache (disk is untouched until the debounce flushes),
416
+ // and drop the config cache so a re-read reflects the change.
417
+ rulesCache = { ...rulesCache, rules };
418
+ configCache.key = '';
419
+ if (!pendingRulePatches) {
420
+ pendingRulePatches = new Map();
421
+ // mtime the in-memory cache was loaded from; flush compares against it to
422
+ // detect an external edit made during the debounce window.
423
+ rulesBaseMtime = rulesCache.mtimeMs;
424
+ }
425
+ pendingRulePatches.set(index, nextEnabled);
426
+ scheduleRulesSave();
427
+ return listRules();
360
428
  }
361
429
 
362
430
  function deleteRule(index) {
@@ -496,5 +564,5 @@ export function createStandaloneHookBus({ maxEvents = 80, dataDir = null, prompt
496
564
  return rewakeHandler;
497
565
  }
498
566
 
499
- return { addRule, beforeTool, deleteRule, dispatch, emit, listRules, setRewakeHandler, setRuleEnabled, status };
567
+ return { addRule, beforeTool, deleteRule, dispatch, emit, flushRules, listRules, setRewakeHandler, setRuleEnabled, status };
500
568
  }
package/src/tui/App.jsx CHANGED
@@ -85,7 +85,7 @@ import {
85
85
  memoryCoreResultErrorText,
86
86
  } from './app/input-parsers.mjs';
87
87
  import { copyToClipboard } from './app/clipboard.mjs';
88
- import { wrappedTextRows, promptContentRows, wrappedDetailRows, textEntryReservedRows } from './app/text-layout.mjs';
88
+ import { wrappedTextRows, promptContentRows, wrappedDetailRows, textEntryReservedRows, queuedBandRows } from './app/text-layout.mjs';
89
89
  import stringWidth from 'string-width';
90
90
  import { useMouseInput } from './app/use-mouse-input.mjs';
91
91
  import { useTranscriptScroll } from './app/use-transcript-scroll.mjs';
@@ -194,6 +194,8 @@ const PANEL_LAYOUT_SIG = {
194
194
  // Prompt-wrap/meta row counts (trailing churn tokens, see token order note
195
195
  // below). PROMPT_META is the 2-row live-spinner band slot.
196
196
  PROMPT_META: 9,
197
+ // Queued steering band rows (full wrapped height, see queuedBandRows).
198
+ QUEUED: 10,
197
199
  };
198
200
  const PROJECT_TEXT_ENTRY_KINDS = new Set(['project-new', 'project-create-confirm', 'project-rename']);
199
201
  const CORE_MULTILINE_TEXT_ENTRY_KINDS = new Set(['core-add', 'core-edit']);
@@ -319,7 +321,17 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
319
321
  // factory is instantiated (see initialPickerBuiltRef below), which React
320
322
  // applies before the first commit — no picker-less flash frame.
321
323
  const [picker, setPickerState] = useState(null);
324
+ // Live handle to the current picker state so async callbacks (e.g. the MCP
325
+ // toggle settle guard in extension-pickers) read the picker actually on
326
+ // screen at call time — including pickers opened by other factories — rather
327
+ // than a stale closure. Updated synchronously in setPicker (below) so a
328
+ // settle firing before the next render sees the right _kind; render-time
329
+ // sync further down is a backstop.
330
+ const livePickerRef = useRef(null);
322
331
  const setPicker = useCallback((next) => {
332
+ // Synchronous ref update so out-of-band setPicker(null/other) is visible to
333
+ // in-flight async guards immediately, before React commits the next render.
334
+ livePickerRef.current = typeof next === 'function' ? next(livePickerRef.current) : next;
323
335
  setPickerState((prev) => {
324
336
  const resolved = typeof next === 'function' ? next(prev) : next;
325
337
  if (resolved && typeof resolved === 'object' && pickerOpenedFromEnterRef.current) {
@@ -330,9 +342,22 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
330
342
  }
331
343
  return resolved.indexMode ? resolved : { ...resolved, indexMode: 'always' };
332
344
  }
345
+ // Same-kind reopen (toggle-driven rebuilds like the MCP ←/→ flip):
346
+ // carry the previous picker's indexMode so an 'always' injected at
347
+ // Enter-open time survives the rebuild instead of falling back to
348
+ // 'auto' and hiding the row indexes.
349
+ if (
350
+ resolved && typeof resolved === 'object' && !resolved.indexMode
351
+ && prev && typeof prev === 'object' && prev.indexMode
352
+ && prev._kind && prev._kind === resolved._kind
353
+ ) {
354
+ return { ...resolved, indexMode: prev.indexMode };
355
+ }
333
356
  return resolved;
334
357
  });
335
358
  }, []);
359
+ // Backstop: keep the ref aligned with committed state each render.
360
+ livePickerRef.current = picker;
336
361
  const [contextPanel, setContextPanel] = useState(null);
337
362
  const [usagePanel, setUsagePanel] = useState(null);
338
363
  const usageRequestRef = useRef(0);
@@ -385,7 +410,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
385
410
  onboardingOwnsScreen = status?.completed !== true || forceOnboarding;
386
411
  } catch { /* status probe failed → fall through to the project picker */ }
387
412
  if (!onboardingOwnsScreen && state.items.length === 0) {
388
- setPickerState(projectPicker.buildProjectPickerState({ initialEntry: true }));
413
+ setPicker(projectPicker.buildProjectPickerState({ initialEntry: true }));
389
414
  }
390
415
  }
391
416
  const {
@@ -465,6 +490,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
465
490
  clean,
466
491
  copyToClipboard,
467
492
  setPicker,
493
+ getPicker: () => livePickerRef.current,
468
494
  setProviderPrompt,
469
495
  setChannelPrompt,
470
496
  setHookPrompt,
@@ -1061,14 +1087,23 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
1061
1087
  // The stitch buffer accumulates rows harvested across every scroll position
1062
1088
  // during a transcript drag, so it can reconstruct rows that scrolled out of
1063
1089
  // view entirely (neither renderText nor the last-full-paint remembered text
1064
- // ever saw them). Prefer it only when it is strictly longer than both.
1065
- const stitched = getStitchedSelectionText?.() || '';
1066
- if (stitched.length > text.length) text = stitched;
1090
+ // ever saw them). getStitchedSelectionText now reports a `complete` flag:
1091
+ // prefer the stitch ONLY when it contiguously covers the selection (no
1092
+ // interior gap) AND adds rows. A gapped stitch silently drops a scrolled-off
1093
+ // row, so preferring it purely on length yielded a mangled copy.
1094
+ const stitched = getStitchedSelectionText?.() || { text: '', complete: false };
1095
+ if (stitched.complete && stitched.text.length > text.length) text = stitched.text;
1067
1096
  if ((!text || !text.trim()) && attempt < 4) {
1068
1097
  setTimeout(() => copySelection(attempt + 1), attempt === 0 ? 0 : 24);
1069
1098
  return;
1070
1099
  }
1071
- if (!text || !text.trim()) return;
1100
+ if (!text || !text.trim()) {
1101
+ // Retries exhausted with nothing to copy: never return silently — the
1102
+ // user pressed Ctrl+C expecting feedback. Surface a hint (and still
1103
+ // swallow the key, which the caller already did).
1104
+ showSelectionCopyHint('nothing to copy · select text first', 'error');
1105
+ return;
1106
+ }
1072
1107
  selectionTextRef.current = text;
1073
1108
  copyToClipboard(text)
1074
1109
  .then(() => {
@@ -2344,12 +2379,12 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2344
2379
  const cat = v && typeof v === 'object' ? v.category : null;
2345
2380
  const c = Math.max(1, Number(v && typeof v === 'object' ? v.count : 1) || 1);
2346
2381
  if (cat === 'Explore') exploreHits += c;
2347
- else if (cat === 'Search') searchHits += c;
2382
+ else if (cat === 'Web Research') searchHits += c;
2348
2383
  }
2349
2384
  } else if (it.name) {
2350
2385
  const cat = classifyToolCategory(it.name, it.args || {});
2351
2386
  if (cat === 'Explore') exploreHits = count;
2352
- else if (cat === 'Search') searchHits = count;
2387
+ else if (cat === 'Web Research') searchHits = count;
2353
2388
  }
2354
2389
  if (exploreHits > 0) {
2355
2390
  exploreCount += exploreHits;
@@ -2532,9 +2567,21 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2532
2567
  // active.
2533
2568
  const overlayHintRequested = !inputBoxHidden && !hasFloatingPanel && !liveSpinner && !!inputHint && !queuedVisible;
2534
2569
  const overlayHintRows = 0;
2535
- // QueuedCommands renders one row per queued command, pinned above the prompt
2536
- // box (no extra top-margin row).
2537
- const queuedRows = queuedVisible ? state.queued.length : 0;
2570
+ // QueuedCommands renders each queued command at its FULL wrapped height
2571
+ // (same content width the promoted transcript user row wraps at), pinned
2572
+ // above the prompt box with no extra top-margin row. Reserving the true
2573
+ // height keeps promotion from re-expanding the text mid-flight ("row jump").
2574
+ // If the whole queue would eat too much of the frame, fall back to the old
2575
+ // compact 1-row-per-entry truncation so the input box never leaves screen.
2576
+ const queuedFullRows = queuedVisible
2577
+ ? state.queued.reduce(
2578
+ (sum, item) => sum + queuedBandRows(String(item.displayText || item.text || ''), Math.max(1, frameColumns - 4)),
2579
+ 0,
2580
+ )
2581
+ : 0;
2582
+ const queuedRowBudget = Math.max(3, Math.floor(resizeState.rows / 3));
2583
+ const queuedCompact = queuedFullRows > queuedRowBudget;
2584
+ const queuedRows = queuedVisible ? (queuedCompact ? state.queued.length : queuedFullRows) : 0;
2538
2585
  const INPUT_BOX_ROWS = promptBoxRows + promptMetaRows + overlayHintRows;
2539
2586
  const baseReserve = WELCOME_ROWS + SCROLL_HINT_ROWS + LIVE_STATUS_ROWS + INPUT_BOX_ROWS + STATUSLINE_ROWS + queuedRows;
2540
2587
  const maxFloatingPanelRows = Math.max(0, resizeState.rows - baseReserve - 1);
@@ -2617,6 +2664,22 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2617
2664
  const spinnerMetaCollapseRows = doneTailAppendedThisCommit
2618
2665
  ? Math.max(0, prevMetaRows - nextMetaRows)
2619
2666
  : 0;
2667
+ // Queued-band promotion: drain() removes the queued band and appends the
2668
+ // promoted user transcript row in the SAME commit (session-flow.mjs drain
2669
+ // → pushUserOrSyntheticItem → runTurn spinner, one microtask flush). The
2670
+ // new user row (full wrapped height + margin) already backfills the
2671
+ // vacated band rows, so masking them blank for one commit only to drop
2672
+ // the mask on the next commit made the whole transcript bounce down.
2673
+ // Exempt exactly the vacated queued rows when a user row landed at the
2674
+ // tail in this commit; queue edits/removals without a tail append (tail
2675
+ // id unchanged, or non-user tail) keep the mask.
2676
+ const prevQueuedSigRows = Number(String(panelTransition.signature).split('|')[PANEL_LAYOUT_SIG.QUEUED]) || 0;
2677
+ const nextQueuedSigRows = Number(String(panelLayoutSignature).split('|')[PANEL_LAYOUT_SIG.QUEUED]) || 0;
2678
+ const userTailAppendedThisCommit = latestTranscriptItem?.kind === 'user'
2679
+ && (latestTranscriptItem?.id ?? null) !== panelTransition.tailId;
2680
+ const queuedPromoteCollapseRows = userTailAppendedThisCommit
2681
+ ? Math.max(0, prevQueuedSigRows - nextQueuedSigRows)
2682
+ : 0;
2620
2683
  const instantPanelClose = panelShrinkRows > 0
2621
2684
  && (promptRowsOnlyChange
2622
2685
  || isInstantPanelCloseTransition(panelTransition.signature, panelLayoutSignature, initialProjectEntryClose));
@@ -2637,8 +2700,9 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2637
2700
  // the transcript clip instead of inflating bottomReserve + reclaiming on
2638
2701
  // the next tick. Subtract any turn-end spinner-meta rows that the same-
2639
2702
  // commit done tail already backfills (spinnerMetaCollapseRows) so that
2640
- // transition masks nothing and does not bounce.
2641
- panelCloseInkMaskRowsRef.current = Math.max(0, panelShrinkRows - spinnerMetaCollapseRows);
2703
+ // transition masks nothing and does not bounce; same for queued-band
2704
+ // rows backfilled by a just-promoted user row (queuedPromoteCollapseRows).
2705
+ panelCloseInkMaskRowsRef.current = Math.max(0, panelShrinkRows - spinnerMetaCollapseRows - queuedPromoteCollapseRows);
2642
2706
  panelTransition.clearRows = 0;
2643
2707
  panelTransition.guardRows = 0;
2644
2708
  panelTransition.epoch = panelTransitionEpoch;
@@ -2695,8 +2759,13 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2695
2759
  // renderScrollOffset=0. Without this, the stale offset shrinks
2696
2760
  // transcriptContentHeight by one row that never actually gets rendered,
2697
2761
  // producing a one-row bounce that snaps back once state catches up.
2698
- const scrollGuardNearBottom = followingRef.current || scrollTargetRef.current <= baseGuardRows;
2699
- const scrollGuardRows = (!scrollGuardNearBottom && scrollOffset > 0 && guardCapacityRows > baseGuardRows) ? 1 : 0;
2762
+ // [2026-07-06] Scroll-time extra guard DISABLED: the widened (2-row) guard
2763
+ // rendered as a visibly empty band between the transcript and the prompt box
2764
+ // whenever the viewport was scrolled up (user-reported "bottom rows look
2765
+ // blank while scrolling"). The base 1-row guard below stays; if the
2766
+ // scrolled-row-over-statusline overpaint resurfaces, fix it in the renderer
2767
+ // diff (clip/erase) instead of carving more blank viewport rows.
2768
+ const scrollGuardRows = 0;
2700
2769
  const transcriptGuardRows = Math.min(guardCapacityRows, baseGuardRows + panelTransitionGuardRows + scrollGuardRows);
2701
2770
  // Welcome prompt hint: a one-row band rendered INSIDE the transcript
2702
2771
  // viewport (as a sibling below the content clip), so it must be part of the
@@ -2724,6 +2793,17 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2724
2793
  const welcomePromptHintRows = welcomePromptHintVisible
2725
2794
  && (viewportHeight - transcriptGuardRows) >= 2 ? 1 : 0;
2726
2795
  welcomePromptHintVisibleRef.current = welcomePromptHintRows > 0;
2796
+ // Transient hint/error on the EMPTY transcript: the guard row sits directly
2797
+ // above the prompt box, so painting the hint there hugs the textbox one row
2798
+ // below where the live-spinner line renders. Carve one in-viewport row ABOVE
2799
+ // the guard row instead so the hint's baseline matches the spinner row (two
2800
+ // rows above the box, guard row stays blank as the spacer). This is an
2801
+ // in-viewport carve like welcomePromptHintRows — bottomReserve is untouched,
2802
+ // so the prompt box and statusline never move. Non-empty transcripts keep the
2803
+ // existing attach-to-last-item / guard-row fallback placements.
2804
+ const overlayHintBandRows = overlayHintRequested
2805
+ && state.items.length === 0
2806
+ && (viewportHeight - transcriptGuardRows - welcomePromptHintRows) >= 2 ? 1 : 0;
2727
2807
  // Instant panel close (slash palette): the reclaimed rows stay blank for
2728
2808
  // exactly one commit via panelCloseMaskRows. The mask MUST be part of this
2729
2809
  // frame's row accounting — subtract it from the transcript content height
@@ -2734,11 +2814,11 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
2734
2814
  // commit (the "textbox dips when the slash palette closes" bug).
2735
2815
  const panelCloseMaskRows = Math.min(
2736
2816
  panelCloseInkMaskRows,
2737
- Math.max(0, viewportHeight - transcriptGuardRows - welcomePromptHintRows - 1),
2817
+ Math.max(0, viewportHeight - transcriptGuardRows - welcomePromptHintRows - overlayHintBandRows - 1),
2738
2818
  );
2739
2819
  const transcriptContentHeight = Math.max(
2740
2820
  1,
2741
- viewportHeight - transcriptGuardRows - panelCloseMaskRows - welcomePromptHintRows,
2821
+ viewportHeight - transcriptGuardRows - panelCloseMaskRows - welcomePromptHintRows - overlayHintBandRows,
2742
2822
  );
2743
2823
  // Bottom-follow / pin semantics must NOT widen with the scroll-time guard, or
2744
2824
  // the "pinned to tail" threshold would drift and streaming could freeze a row
@@ -3049,10 +3129,18 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
3049
3129
  backgroundColor={surfaceBackground()}
3050
3130
  />
3051
3131
  ) : null}
3132
+ {overlayHintBandRows > 0 ? (
3133
+ <Box height={1} flexShrink={0} backgroundColor={surfaceBackground()} flexDirection="row" width="100%" overflow="hidden">
3134
+ <Box flexGrow={1} flexShrink={1} overflow="hidden" />
3135
+ <Box flexShrink={0} width={guardHintWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
3136
+ <Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
3137
+ </Box>
3138
+ </Box>
3139
+ ) : null}
3052
3140
  {transcriptGuardRows > 0 ? (
3053
3141
  <Box height={transcriptGuardRows} flexShrink={0} backgroundColor={surfaceBackground()} flexDirection="row" width="100%" overflow="hidden">
3054
3142
  <Box flexGrow={1} flexShrink={1} overflow="hidden" />
3055
- {overlayHintFallbackRow ? (
3143
+ {overlayHintFallbackRow && overlayHintBandRows === 0 ? (
3056
3144
  <Box flexShrink={0} width={guardHintWidth || 1} marginLeft={1} marginRight={1} justifyContent="flex-end" overflow="hidden">
3057
3145
  <Text color={promptStatusColor(inputHintTone)} wrap="truncate">{inputHint}</Text>
3058
3146
  </Box>
@@ -3314,7 +3402,7 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
3314
3402
  </>
3315
3403
  ) : null}
3316
3404
  {queuedVisible ? (
3317
- <QueuedCommands queued={state.queued} columns={frameColumns} />
3405
+ <QueuedCommands queued={state.queued} columns={frameColumns} compact={queuedCompact} />
3318
3406
  ) : null}
3319
3407
  <Box
3320
3408
  marginTop={0}
@@ -12,9 +12,17 @@ export function osc52ClipboardSequence(text) {
12
12
  return `\x1bPtmux;${raw.replaceAll('\x1b', '\x1b\x1b')}\x1b\\`;
13
13
  }
14
14
 
15
+ // Base64 of large selections becomes a multi-hundred-KB TTY write. Emitting
16
+ // that as a single OSC 52 sequence blocks the terminal (and our render loop)
17
+ // for a noticeable beat, so skip OSC 52 past this size and rely on the native
18
+ // helper. ~256KB of clipboard text → ~350KB of base64.
19
+ const OSC52_MAX_BYTES = 256 * 1024;
20
+
15
21
  export function writeOsc52Clipboard(text) {
22
+ const value = String(text ?? '');
23
+ if (Buffer.byteLength(value, 'utf8') > OSC52_MAX_BYTES) return false;
16
24
  try {
17
- process.stdout.write(osc52ClipboardSequence(text));
25
+ process.stdout.write(osc52ClipboardSequence(value));
18
26
  return true;
19
27
  } catch {
20
28
  return false;
@@ -22,27 +30,32 @@ export function writeOsc52Clipboard(text) {
22
30
  }
23
31
 
24
32
  export function nativeClipboardCommand(text) {
33
+ const value = String(text ?? '');
25
34
  if (process.platform === 'win32') {
35
+ // clip.exe starts in tens of ms (vs 1s+ for powershell.exe). It reads its
36
+ // stdin as UTF-16LE, so feed plain UTF-16LE bytes for correct Unicode. No
37
+ // BOM: clip.exe copies a leading FF FE verbatim, leaking U+FEFF as the
38
+ // first pasted char.
26
39
  return {
27
- cmd: 'powershell.exe',
28
- args: [
29
- '-NoLogo',
30
- '-NoProfile',
31
- '-NonInteractive',
32
- '-Command',
33
- '$b=[Console]::In.ReadToEnd();$t=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b));Set-Clipboard -Value $t',
34
- ],
35
- input: Buffer.from(String(text ?? ''), 'utf8').toString('base64'),
40
+ cmd: 'clip.exe',
41
+ args: [],
42
+ input: Buffer.from(value, 'utf16le'),
36
43
  };
37
44
  }
38
- if (process.platform === 'darwin') return { cmd: 'pbcopy', args: [], input: text };
39
- if (process.env.WAYLAND_DISPLAY) return { cmd: 'wl-copy', args: [], input: text };
40
- return { cmd: 'xclip', args: ['-selection', 'clipboard'], input: text };
45
+ if (process.platform === 'darwin') return { cmd: 'pbcopy', args: [], input: value };
46
+ if (process.env.WAYLAND_DISPLAY) return { cmd: 'wl-copy', args: [], input: value };
47
+ return { cmd: 'xclip', args: ['-selection', 'clipboard'], input: value };
41
48
  }
42
49
 
43
50
  export function copyToClipboard(text) {
44
51
  const value = String(text ?? '');
45
52
  const wroteOsc52 = writeOsc52Clipboard(value);
53
+ // When OSC 52 already wrote the clipboard, fire-and-forget the native helper
54
+ // and resolve immediately so the TUI never blocks on subprocess startup/exit
55
+ // (clip.exe/pbcopy/etc. still finish in the background). When OSC 52 was
56
+ // skipped (payload too large), the native helper is the ONLY writer, so we
57
+ // must await its exit to report real success/failure instead of a false
58
+ // "copied" hint.
46
59
  return new Promise((resolve, reject) => {
47
60
  const { cmd, args, input } = nativeClipboardCommand(value);
48
61
  let child;
@@ -54,14 +67,21 @@ export function copyToClipboard(text) {
54
67
  return;
55
68
  }
56
69
  child.on('error', (e) => {
57
- if (wroteOsc52) resolve();
58
- else reject(e);
59
- });
60
- child.on('close', (code) => {
61
- if (code === 0 || wroteOsc52) resolve();
62
- else reject(new Error(`${cmd} exited with code ${code}`));
70
+ // Surface only if OSC 52 didn't cover us; otherwise the copy still landed.
71
+ if (!wroteOsc52) reject(e);
63
72
  });
73
+ if (!wroteOsc52) {
74
+ child.on('close', (code) => {
75
+ if (code === 0) resolve();
76
+ else reject(new Error(`${cmd} exited with code ${code}`));
77
+ });
78
+ }
64
79
  child.stdin.on('error', () => { /* ignore EPIPE if the helper closed early */ });
65
80
  child.stdin.end(input);
81
+ // OSC 52 covered us: resolve now, don't await the child's exit.
82
+ if (wroteOsc52) {
83
+ child.unref?.();
84
+ resolve();
85
+ }
66
86
  });
67
87
  }
@@ -14,9 +14,37 @@ import { compareSemver } from '../../runtime/shared/update-checker.mjs';
14
14
  import { readFileSync } from 'node:fs';
15
15
  import { dirname, join } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
+ import { resolvePluginData } from '../../runtime/shared/plugin-paths.mjs';
17
18
 
18
19
  const GLYPH = { ok: '✓', warn: '⚠', fail: '✗' };
19
20
 
21
+ // Windows Defender real-time scanning of the Postgres data dir shows up as
22
+ // pathologically long checkpoint write phases in pg.log (e.g. write=12 s for a
23
+ // <10MB checkpoint). Get-MpPreference needs admin, so we never read the actual
24
+ // exclusion list — we infer interference from checkpoint timings instead.
25
+ const CKPT_WRITE_WARN_S = 10; // any single checkpoint write phase over this = suspicious
26
+ const CKPT_WRITE_MEDIAN_S = 5; // sustained median over this = suspicious
27
+
28
+ function parseCheckpointWriteSeconds(logText) {
29
+ const lines = String(logText).split(/\r?\n/).slice(-200);
30
+ const out = [];
31
+ for (const line of lines) {
32
+ const m = /checkpoint complete:.*?\bwrite=([\d.]+)\s*s/i.exec(line);
33
+ if (m) {
34
+ const v = Number(m[1]);
35
+ if (Number.isFinite(v)) out.push(v);
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function median(nums) {
42
+ if (!nums.length) return 0;
43
+ const s = [...nums].sort((a, b) => a - b);
44
+ const mid = s.length >> 1;
45
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
46
+ }
47
+
20
48
  function readPackageJson() {
21
49
  try {
22
50
  const dir = dirname(fileURLToPath(import.meta.url));
@@ -171,5 +199,34 @@ export async function buildDoctorReport(runtime = {}, getState = () => ({})) {
171
199
  row('ok', `${enabled ? 'enabled' : 'disabled'} · ${events.length} event${events.length === 1 ? '' : 's'}`);
172
200
  });
173
201
 
202
+ // 8. defender (win32 only): infer AV real-time-scan interference on the PG
203
+ // data dir from checkpoint write timings; report-only, so surface the
204
+ // exact elevated exclusion one-liner as copyable fix text.
205
+ if (process.platform === 'win32') {
206
+ await check('defender', async (row) => {
207
+ const dataDir = resolvePluginData();
208
+ const pgdata = join(dataDir, 'pgdata');
209
+ let writes;
210
+ try {
211
+ writes = parseCheckpointWriteSeconds(readFileSync(join(dataDir, 'pg.log'), 'utf8'));
212
+ } catch {
213
+ row('ok', 'pg.log unavailable · check skipped');
214
+ return;
215
+ }
216
+ if (!writes.length) {
217
+ row('ok', 'no checkpoint timings yet');
218
+ return;
219
+ }
220
+ const max = Math.max(...writes);
221
+ const med = median(writes);
222
+ if (max <= CKPT_WRITE_WARN_S && med <= CKPT_WRITE_MEDIAN_S) {
223
+ row('ok', `checkpoint write median ${med.toFixed(1)}s · max ${max.toFixed(1)}s`);
224
+ return;
225
+ }
226
+ const fix = `Start-Process powershell -Verb RunAs -ArgumentList '-NoProfile','-Command',"Add-MpPreference -ExclusionPath '${pgdata}'"`;
227
+ row('warn', `slow checkpoints (median ${med.toFixed(1)}s · max ${max.toFixed(1)}s) suggest Defender real-time scan of ${pgdata}. Fix (run in PowerShell): ${fix}`);
228
+ });
229
+ }
230
+
174
231
  return ['mixdog doctor — installation health', ...rows].join('\n');
175
232
  }
@@ -15,6 +15,7 @@ export function createExtensionPickers({
15
15
  clean,
16
16
  copyToClipboard,
17
17
  setPicker,
18
+ getPicker,
18
19
  setProviderPrompt,
19
20
  setChannelPrompt,
20
21
  setHookPrompt,
@@ -22,6 +23,12 @@ export function createExtensionPickers({
22
23
  getDisabledSkills,
23
24
  setDisabledSkills,
24
25
  }) {
26
+ // MCP toggle settle-guard state: bumped per toggle (epoch) and armed only
27
+ // while the MCP picker is on screen (see openMcpServersPicker). The live
28
+ // picker is read via getPicker() so a stale settle can also detect pickers
29
+ // opened outside this factory replacing the MCP one.
30
+ let mcpEpoch = 0;
31
+ let mcpActive = false;
25
32
  const mcpStatus = () => {
26
33
  let status;
27
34
  try {
@@ -37,6 +44,7 @@ export function createExtensionPickers({
37
44
  const status = mcpStatus();
38
45
  if (!status) return;
39
46
  const servers = status.servers || [];
47
+ const optimistic = options?.optimistic || null;
40
48
  const items = [];
41
49
  if (servers.length === 0) {
42
50
  items.push({
@@ -47,13 +55,16 @@ export function createExtensionPickers({
47
55
  });
48
56
  }
49
57
  for (const server of servers) {
50
- const enabled = server.enabled !== false;
58
+ const pending = optimistic && optimistic.name === server.name;
59
+ const enabled = pending ? optimistic.enabled : server.enabled !== false;
51
60
  items.push({
52
61
  value: `server:${server.name}`,
53
62
  label: server.name,
54
63
  marker: enabled ? '●' : '○',
55
64
  markerColor: enabled ? theme.success : theme.inactive,
56
- description: `${server.status || 'unknown'} · ${server.transport || 'unknown'} · ${server.toolCount || 0} tools${server.error ? ` · ${server.error}` : ''}`,
65
+ description: pending
66
+ ? `${optimistic.enabled ? 'enabling' : 'disabling'}… · ${server.transport || 'unknown'}`
67
+ : `${server.status || 'unknown'} · ${server.transport || 'unknown'} · ${server.toolCount || 0} tools${server.error ? ` · ${server.error}` : ''}`,
57
68
  _action: 'server',
58
69
  _server: server,
59
70
  _enabled: enabled,
@@ -65,11 +76,31 @@ export function createExtensionPickers({
65
76
  setSettingsPrompt(null);
66
77
  const toggleServer = (item) => {
67
78
  if (item._action !== 'server' || !item._server?.name) return;
68
- void store.setMcpServerEnabled?.(item._server.name, !item._enabled)
69
- .then(() => openMcpServersPicker({ highlightValue: `server:${item._server.name}` }))
70
- .catch((e) => store.pushNotice(`mcp toggle failed: ${e?.message || e}`, 'error'));
79
+ const name = item._server.name;
80
+ const target = !item._enabled;
81
+ const highlightValue = `server:${name}`;
82
+ // A settle is only allowed to touch the UI if it is still the newest
83
+ // toggle (token === mcpEpoch) and the MCP picker is still on screen.
84
+ const token = ++mcpEpoch;
85
+ const settle = (fn) => {
86
+ if (token !== mcpEpoch || !mcpActive || getPicker?.()?._kind !== 'mcp-servers') return;
87
+ fn();
88
+ };
89
+ // Optimistic: instantly reopen with the row flipped + pending status.
90
+ openMcpServersPicker({ highlightValue, optimistic: { name, enabled: target } });
91
+ Promise.resolve(store.setMcpServerEnabled?.(name, target))
92
+ .then(() => {
93
+ // Per-server serialization in the runtime already converged rapid
94
+ // re-toggles to the last requested state; just refresh the row.
95
+ settle(() => openMcpServersPicker({ highlightValue }));
96
+ })
97
+ .catch((e) => {
98
+ store.pushNotice(`mcp toggle failed: ${e?.message || e}`, 'error');
99
+ settle(() => openMcpServersPicker({ highlightValue }));
100
+ });
71
101
  };
72
102
  setPicker({
103
+ _kind: 'mcp-servers',
73
104
  title: 'MCP servers',
74
105
  description: 'Enable or disable configured MCP servers.',
75
106
  initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options?.highlightValue)),
@@ -78,9 +109,13 @@ export function createExtensionPickers({
78
109
  onLeft: (item) => toggleServer(item),
79
110
  onRight: (item) => toggleServer(item),
80
111
  onCancel: () => {
112
+ // Leaving the picker invalidates any in-flight settle.
113
+ mcpActive = false;
114
+ mcpEpoch++;
81
115
  setPicker(null);
82
116
  },
83
117
  });
118
+ mcpActive = true;
84
119
  };
85
120
 
86
121
  const openMcpPicker = () => {
@@ -141,9 +176,16 @@ export function createExtensionPickers({
141
176
  };
142
177
 
143
178
  const openSkillsPicker = (options = {}) => {
144
- const status = skillsStatus();
145
- if (!status) return;
146
- const skills = status.skills || [];
179
+ // Reuse the skills list already fetched by the opening call when a toggle
180
+ // reopens the picker: avoids a store.skillsStatus() round-trip per keypress.
181
+ let skills;
182
+ if (Array.isArray(options.skills)) {
183
+ skills = options.skills;
184
+ } else {
185
+ const status = skillsStatus();
186
+ if (!status) return;
187
+ skills = status.skills || [];
188
+ }
147
189
  const disabledSet = options.disabledOverride instanceof Set ? options.disabledOverride : getDisabledSkills();
148
190
  const items = [];
149
191
  if (skills.length === 0) {
@@ -181,9 +223,10 @@ export function createExtensionPickers({
181
223
  `skill ${item._enabled ? 'disabled' : 'enabled'}: ${name} (prompt updates next session /clear)`,
182
224
  'info',
183
225
  );
184
- openSkillsPicker({ highlightValue: name, disabledOverride: next });
226
+ openSkillsPicker({ highlightValue: name, disabledOverride: next, skills });
185
227
  };
186
228
  setPicker({
229
+ _kind: 'skills',
187
230
  title: 'Skills',
188
231
  description: 'Enable or disable project skills.',
189
232
  initialIndex: Math.max(0, items.findIndex((entry) => entry.value === options.highlightValue)),
@@ -482,6 +525,7 @@ export function createExtensionPickers({
482
525
  }
483
526
  };
484
527
  setPicker({
528
+ _kind: 'hooks',
485
529
  title: 'Hooks',
486
530
  description: 'Before-tool hook rules; Enter toggles a rule.',
487
531
  items,
@@ -210,8 +210,6 @@ export function createMaintenancePickers({
210
210
  const current = readCurrent();
211
211
  const enabled = current?.enabled !== false;
212
212
  const idleMs = Number(current?.idleMs || HOUR_MS);
213
- const custom = current?.custom === true;
214
- const providerDefault = Number(current?.providerDefault || HOUR_MS);
215
213
  const cacheTtlLabel = !enabled || idleMs >= HOUR_MS ? '1h' : '5m';
216
214
  const items = [
217
215
  {
@@ -226,9 +224,6 @@ export function createMaintenancePickers({
226
224
  {
227
225
  value: 'advanced',
228
226
  label: 'Advanced',
229
- marker: !custom ? '✓' : '',
230
- markerColor: theme.success,
231
- meta: `${current?.provider || 'current'} · ${formatDuration(providerDefault)}`,
232
227
  description: 'Edit provider-paired default idle windows as text.',
233
228
  _action: 'advanced',
234
229
  },