mixdog 0.9.21 → 0.9.23

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 (90) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/channel-daemon-smoke.mjs +165 -0
  4. package/scripts/channel-daemon-stub.mjs +69 -0
  5. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  6. package/scripts/tool-smoke.mjs +30 -17
  7. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
  8. package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
  9. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  10. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  11. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  12. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  13. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  15. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  16. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  18. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  20. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +10 -10
  21. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +8 -6
  22. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +14 -1
  23. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  24. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  26. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +23 -13
  27. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
  28. package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
  29. package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
  30. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  31. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  32. package/src/runtime/channels/lib/seat-lock.mjs +196 -0
  33. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  34. package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
  35. package/src/runtime/channels/lib/worker-main.mjs +42 -11
  36. package/src/runtime/memory/index.mjs +54 -7
  37. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  38. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  39. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  40. package/src/runtime/shared/atomic-file.mjs +44 -10
  41. package/src/runtime/shared/tool-primitives.mjs +31 -1
  42. package/src/runtime/shared/tool-surface.mjs +23 -13
  43. package/src/session-runtime/config-lifecycle.mjs +48 -7
  44. package/src/session-runtime/lifecycle-api.mjs +9 -0
  45. package/src/session-runtime/mcp-glue.mjs +63 -1
  46. package/src/session-runtime/resource-api.mjs +62 -8
  47. package/src/session-runtime/runtime-core.mjs +32 -2
  48. package/src/session-runtime/session-text.mjs +41 -0
  49. package/src/session-runtime/session-turn-api.mjs +24 -0
  50. package/src/session-runtime/settings-api.mjs +8 -1
  51. package/src/session-runtime/tool-catalog.mjs +306 -38
  52. package/src/session-runtime/tool-defs.mjs +7 -7
  53. package/src/session-runtime/workflow.mjs +2 -1
  54. package/src/standalone/channel-daemon-client.mjs +224 -0
  55. package/src/standalone/channel-daemon-transport.mjs +351 -0
  56. package/src/standalone/channel-daemon.mjs +139 -0
  57. package/src/standalone/channel-worker.mjs +213 -4
  58. package/src/standalone/hook-bus.mjs +71 -3
  59. package/src/tui/App.jsx +105 -17
  60. package/src/tui/app/clipboard.mjs +39 -19
  61. package/src/tui/app/doctor.mjs +57 -0
  62. package/src/tui/app/extension-pickers.mjs +53 -9
  63. package/src/tui/app/maintenance-pickers.mjs +0 -5
  64. package/src/tui/app/slash-dispatch.mjs +4 -4
  65. package/src/tui/app/text-layout.mjs +11 -0
  66. package/src/tui/app/use-mouse-input.mjs +235 -51
  67. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  68. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  69. package/src/tui/app/use-transcript-window.mjs +55 -1
  70. package/src/tui/components/Message.jsx +1 -1
  71. package/src/tui/components/PromptInput.jsx +3 -1
  72. package/src/tui/components/QueuedCommands.jsx +21 -10
  73. package/src/tui/components/ToolExecution.jsx +2 -2
  74. package/src/tui/dist/index.mjs +653 -310
  75. package/src/tui/engine/session-api.mjs +17 -7
  76. package/src/tui/engine/session-flow.mjs +6 -0
  77. package/src/tui/engine.mjs +8 -0
  78. package/src/tui/index.jsx +62 -18
  79. package/src/tui/paste-attachments.mjs +26 -0
  80. package/src/ui/statusline.mjs +45 -5
  81. package/src/ui/tool-card.mjs +8 -1
  82. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  83. package/src/workflows/bench/WORKFLOW.md +46 -0
  84. package/src/workflows/default/WORKFLOW.md +6 -0
  85. package/src/workflows/solo/WORKFLOW.md +5 -0
  86. package/vendor/ink/build/ink.js +23 -1
  87. package/vendor/ink/build/output.js +154 -71
  88. package/vendor/ink/build/render-node-to-output.js +44 -2
  89. package/vendor/ink/build/render.js +4 -0
  90. package/vendor/ink/build/renderer.js +4 -1
@@ -286,17 +286,27 @@ export function createEngineApiA(bag) {
286
286
  }
287
287
  },
288
288
  setMcpServerEnabled: async (name, enabled) => {
289
- if (getState().commandBusy) return null;
290
- set({ commandBusy: true });
291
- try {
292
- const status = await runtime.setMcpServerEnabled?.(name, enabled);
289
+ // No global commandBusy: the runtime adopts config synchronously and
290
+ // serializes the heavy connect/close/recreate per server name, so rapid
291
+ // re-toggles converge instead of being dropped. This awaits the
292
+ // background chain purely to settle the picker on completion/failure.
293
+ const status = await runtime.setMcpServerEnabled?.(name, enabled);
294
+ // The context re-estimate is a full-transcript token count; defer it off
295
+ // the interactive frame so it never runs inside the toggle key handler.
296
+ setImmediate(() => {
293
297
  resetStatsAndSyncContext();
294
298
  set({ ...routeState(), stats: { ...getState().stats } });
299
+ });
300
+ // A connect failure resolves as a status object (not a throw), so inspect
301
+ // this server's row before claiming success: enabling can fail on spawn/
302
+ // handshake. Disabling never spawns, so it is always a success.
303
+ const row = status?.servers?.find((s) => s.name === name);
304
+ if (enabled && row && (row.status === 'failed' || row.error)) {
305
+ pushNotice(`mcp enable failed: ${name}${row.error ? ` — ${row.error}` : ''}`, 'error');
306
+ } else {
295
307
  pushNotice(`mcp ${enabled ? 'enabled' : 'disabled'}: ${name}`, 'info');
296
- return status;
297
- } finally {
298
- set({ commandBusy: false });
299
308
  }
309
+ return status;
300
310
  },
301
311
  getDisabledSkills: () => runtime.getDisabledSkills?.() || { disabled: [] },
302
312
  setDisabledSkills: (disabled) => runtime.setDisabledSkills?.(disabled) || { disabled: [] },
@@ -15,6 +15,12 @@ export function createSessionFlow(bag) {
15
15
  // runtime.clear() resolve only after compaction finishes; without a bound a
16
16
  // stalled compaction wedges autoClearRunning/commandBusy forever, which
17
17
  // suppresses the input drain. On timeout we abandon this attempt.
18
+ // NOTE: this bounds how long the INPUT stays blocked (commandBusy), not the
19
+ // compaction itself — the clear path's worst case (recall cold retries ~38s
20
+ // + size-scaled semantic up to 120s) may exceed it, and that is fine: the
21
+ // abandoned promise keeps running and the late-fulfillment path below
22
+ // (autoClearInFlight / pendingClearedSessionUi) applies the clear when it
23
+ // settles. Do NOT raise this to cover compaction worst cases.
18
24
  const AUTO_CLEAR_COMPACT_TIMEOUT_MS = 60_000;
19
25
 
20
26
  const leadSessionId = () => runtime.id;
@@ -21,6 +21,7 @@ import {
21
21
  isModelVisibleToolCompletionWrapper,
22
22
  isLikelyToolCompletionWrapper,
23
23
  } from '../runtime/shared/tool-execution-contract.mjs';
24
+ import { isLateToolAnnouncement, summarizeLateToolAnnouncement } from '../session-runtime/session-text.mjs';
24
25
  import { presentErrorText } from '../runtime/shared/err-text.mjs';
25
26
  import { listThemes, getThemeSetting, setThemeSetting } from './theme.mjs';
26
27
  import { resetAllStreamingMarkdownStablePrefixes } from './markdown/streaming-markdown.mjs';
@@ -386,6 +387,13 @@ export async function createEngineSession({
386
387
  // detector only, same as before this change.
387
388
  if (origin === 'injected' && isLikelyToolCompletionWrapper(text)) return;
388
389
  if (isModelVisibleToolCompletionWrapper(text)) return;
390
+ // Late-MCP deferred-tool announcement (model-visible <system-reminder>):
391
+ // keep it in model context, but never render the raw block here. Collapse
392
+ // to one muted notice line, e.g. "MCP tools available: UnityMCP (12 tools)".
393
+ if (isLateToolAnnouncement(text)) {
394
+ pushItem({ kind: 'notice', id, text: summarizeLateToolAnnouncement(text), tone: 'info' });
395
+ return;
396
+ }
389
397
  if (upsertSyntheticToolItem(text, id)) return;
390
398
  pushItem({ kind: 'user', id, text });
391
399
  };
package/src/tui/index.jsx CHANGED
@@ -10,19 +10,30 @@ import { closeSync, constants as fsConstants, createWriteStream, mkdirSync, open
10
10
  import { tmpdir } from 'node:os';
11
11
  import { dirname, join } from 'node:path';
12
12
  import { performance } from 'node:perf_hooks';
13
+ import { format } from 'node:util';
13
14
  import { App } from './App.jsx';
14
15
  import { createEngineSession } from './engine.mjs';
15
16
  import { installProcessSignalCleanup } from '../runtime/shared/process-shutdown.mjs';
16
- import { touchUiHeartbeat } from '../runtime/channels/lib/runtime-paths.mjs';
17
17
  import { emitTerminalBackground, loadThemeSettingFromConfig, theme } from './theme.mjs';
18
18
  import { POP_KITTY, DISABLE_MODIFY_OTHER_KEYS } from './keyboard-protocol.mjs';
19
19
  import { displayWidth } from './display-width.mjs';
20
20
  import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
21
21
 
22
- const TERMINAL_MODE_RESET = '\x1b[?1006l\x1b[?1005l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[?25h';
22
+ // Trailing `\x1b[>0s` restores XTSHIFTESCAPE (shift-to-select-extend) to its
23
+ // terminal default; MOUSE_TRACKING_ON opts into `\x1b[>1s`, so every mouse/alt
24
+ // screen teardown that emits this reset also undoes that opt-in.
25
+ const TERMINAL_MODE_RESET = '\x1b[?1006l\x1b[?1005l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?2004l\x1b[>0s\x1b[?25h';
23
26
  const TERMINAL_OSC_RESET_BG = '\x1b]111\x07';
24
27
  const TERMINAL_MODE_RESET_HIDDEN_CURSOR = TERMINAL_MODE_RESET.replace('\x1b[?25h', '\x1b[?25l');
25
- const MOUSE_TRACKING_ON = '\x1b[?1000h\x1b[?1002h\x1b[?1006h';
28
+ // Trailing `\x1b[>1s` is XTSHIFTESCAPE: terminals that support it forward
29
+ // shift+click/drag to the app so our shift-extend selection paths work.
30
+ // Windows Terminal half-honors it — it forwards the shift events AND still
31
+ // paints its own native selection, so the user sees two overlapping
32
+ // highlights. Gate on WT_SESSION: in WT shift stays fully native (single
33
+ // highlight, native copy); ctrl+click/right-click remain the app-side
34
+ // extend triggers there. Restored via `\x1b[>0s` in TERMINAL_MODE_RESET.
35
+ const XTSHIFTESCAPE_ON = process.env.WT_SESSION ? '' : '\x1b[>1s';
36
+ const MOUSE_TRACKING_ON = `\x1b[?1000h\x1b[?1002h\x1b[?1006h${XTSHIFTESCAPE_ON}`;
26
37
  // Keyboard-protocol teardown. App.jsx enables kitty + modifyOtherKeys
27
38
  // synchronously at raw-mode-on (no query); here we just pop/disable them on
28
39
  // exit. POP_KITTY / DISABLE_MODIFY_OTHER_KEYS come from keyboard-protocol.mjs.
@@ -366,6 +377,36 @@ function installTuiStderrGuard() {
366
377
  };
367
378
  }
368
379
 
380
+ /**
381
+ * Route console.* to the guarded stderr (→ mixdog-tui.stderr.log) while the
382
+ * fullscreen TUI owns the terminal, and mount ink with patchConsole:false.
383
+ *
384
+ * Why: ink's patchConsole path (writeToStdout/writeToStderr) handles an
385
+ * intercepted console line by erasing the whole frame (log.clear()), writing
386
+ * the line, then re-writing the last frame RELATIVE to the cursor. On a
387
+ * fullscreen alt-screen frame that relative rewrite overflows the bottom row
388
+ * by exactly the stray line's height, so the terminal scrolls one line and
389
+ * the next incremental frame snaps it back — the visible "+1 line / -1 line"
390
+ * bounce during streaming (reproduced in a VT harness: one console.log mid-
391
+ * stream = scrollDelta 1, with or without the spinner). The stray text itself
392
+ * was already invisible (stderr guard files it), so the only user-visible
393
+ * effect of the whole dance WAS the bounce. Routing console output straight
394
+ * to the guarded stderr keeps the diagnostics AND skips ink's repaint dance.
395
+ */
396
+ function installTuiConsoleGuard() {
397
+ const methods = ['log', 'info', 'warn', 'error', 'debug', 'trace'];
398
+ const original = new Map();
399
+ for (const m of methods) {
400
+ original.set(m, console[m]);
401
+ console[m] = (...args) => {
402
+ try { process.stderr.write(`[console.${m}] ${format(...args)}\n`); } catch { /* ignore */ }
403
+ };
404
+ }
405
+ return () => {
406
+ for (const [m, fn] of original) console[m] = fn;
407
+ };
408
+ }
409
+
369
410
  export async function runTui({ provider, model, toolMode, remote, forceOnboarding } = {}) {
370
411
  const startedAt = performance.now();
371
412
  bootProfile('run:start', { provider, model, toolMode, remote });
@@ -380,6 +421,7 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
380
421
  }
381
422
 
382
423
  const restoreStderr = installTuiStderrGuard();
424
+ const restoreConsole = installTuiConsoleGuard();
383
425
  const stopPerfProbe = installTuiPerfProbe();
384
426
  const stopLoopProbe = installTuiLoopProbe();
385
427
  const restorePrimedInput = () => drainStdin(process.stdin);
@@ -438,6 +480,7 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
438
480
  stopLoopProbe();
439
481
  restoreTerminal();
440
482
  try { process.off('exit', restoreTerminal); } catch { /* ignore */ }
483
+ restoreConsole();
441
484
  restoreStderr();
442
485
  process.stderr.write(`mixdog: ${error?.message || error}\n`);
443
486
  return 1;
@@ -449,7 +492,7 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
449
492
  // Keep mouse handling app-owned by default: native terminal selections are
450
493
  // cleared by the fullscreen redraws that happen while a turn streams. Users
451
494
  // who prefer their terminal's native mouse behavior can opt out with
452
- // MIXDOG_TUI_MOUSE=0.
495
+ // MIXDOG_TUI_MOUSE=0 (mouse capture stays off at boot, no runtime toggle).
453
496
  const mouseTracking = !/^(0|false|no|off)$/i.test(String(process.env.MIXDOG_TUI_MOUSE || '1'));
454
497
  if (mouseTracking) {
455
498
  process.stdout.write(MOUSE_TRACKING_ON);
@@ -469,22 +512,11 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
469
512
  afterCleanup: (reason) => {
470
513
  restoreTerminal();
471
514
  dumpActiveHandles(`after-${reason}`);
515
+ restoreConsole();
472
516
  restoreStderr();
473
517
  },
474
518
  });
475
519
 
476
- // Zombie-Lead repro (2026-07-02): the render loop can wedge (all owning
477
- // PIDs still alive) leaving active-instance.json's pid-only staleness
478
- // check unable to detect it. Heartbeat every 30s so runtime-paths.mjs can
479
- // fall back to a ui_heartbeat_at staleness check when pids look fine.
480
- // Best-effort: a failed/late write just means the next tick catches up,
481
- // and the timer is unref'd so it never keeps the process alive on its own.
482
- const uiHeartbeatTimer = setInterval(() => {
483
- try { touchUiHeartbeat(); } catch { /* ignore */ }
484
- }, 30_000);
485
- if (typeof uiHeartbeatTimer.unref === 'function') uiHeartbeatTimer.unref();
486
- try { touchUiHeartbeat(); } catch { /* ignore */ }
487
-
488
520
  // Zombie-Lead repro (2026-07-02): stdio can die (TTY hangup, EPIPE on a
489
521
  // detached/piped stdout) without the process ever receiving SIGHUP/SIGTERM,
490
522
  // leaving a zombie renderer looping forever with nothing left to draw to.
@@ -527,7 +559,12 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
527
559
  // [render] incrementalRendering: line-diff repaint (only changed rows are
528
560
  // rewritten) instead of erase-all+rewrite per frame — removes the whole-
529
561
  // frame flash on surface transitions and slash palette open/close.
530
- const instance = render(<App store={store} forceOnboarding={forceOnboarding === true} />, { exitOnCtrlC: false, maxFps: 60, incrementalRendering: true, onRender: makeRenderProfiler() });
562
+ // patchConsole:false console.* is already routed to the stderr log by
563
+ // installTuiConsoleGuard above. Letting ink intercept it instead triggers
564
+ // its clear-frame → write → relative re-render dance, which scrolls the
565
+ // alt screen one line per stray console line (the streaming newline
566
+ // bounce). See installTuiConsoleGuard.
567
+ const instance = render(<App store={store} forceOnboarding={forceOnboarding === true} />, { exitOnCtrlC: false, maxFps: 60, incrementalRendering: true, patchConsole: false, onRender: makeRenderProfiler() });
531
568
  bootProfile('render:mounted', { ms: (performance.now() - startedAt).toFixed(1) });
532
569
  const { waitUntilExit } = instance;
533
570
  // [mixdog fork] Hand the ink renderer's drag-selection setter to the store so
@@ -550,11 +587,17 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
550
587
  if (mouseTracking && typeof instance.getSelectionRows === 'function') {
551
588
  store.getRenderSelectionRows = instance.getSelectionRows;
552
589
  }
590
+ // [mixdog] One-shot full clear+repaint. The app's mouse handler fires it on
591
+ // every button press under Windows Terminal to dismiss WT's persistent
592
+ // NATIVE (shift+drag) selection overlay, which survives incremental
593
+ // repaints and would otherwise sit on top of the app-drawn selection.
594
+ if (mouseTracking && typeof instance.forceFullRepaint === 'function') {
595
+ store.forceRenderRepaint = instance.forceFullRepaint;
596
+ }
553
597
  await waitUntilExit();
554
598
  } finally {
555
599
  stopPerfProbe();
556
600
  stopLoopProbe();
557
- clearInterval(uiHeartbeatTimer);
558
601
  for (const [stream, event, handler] of stdioDeathListeners.splice(0)) {
559
602
  try { stream.off(event, handler); } catch { /* ignore */ }
560
603
  }
@@ -566,6 +609,7 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
566
609
  }
567
610
  restoreTerminal();
568
611
  dumpActiveHandles('after-restore');
612
+ restoreConsole();
569
613
  restoreStderr();
570
614
  }
571
615
  scheduleHardExit(0);
@@ -1,4 +1,5 @@
1
1
  import { execFile } from 'node:child_process';
2
+ import { Buffer } from 'node:buffer';
2
3
  import { randomBytes } from 'node:crypto';
3
4
  import { existsSync, unlinkSync } from 'node:fs';
4
5
  import { readFile as readFileAsync, stat as statAsync } from 'node:fs/promises';
@@ -223,3 +224,28 @@ export async function readClipboardImageAttachment() {
223
224
  try { unlinkSync(file); } catch {}
224
225
  }
225
226
  }
227
+
228
+ // Cross-platform OS-clipboard TEXT read. Mirrors the per-OS command pattern in
229
+ // clipboard.mjs (writes) and readClipboardImageAttachment (reads). Returns '' on
230
+ // any miss/error so callers can cleanly fall through to the image path. Windows
231
+ // round-trips through base64 so non-ASCII survives the console codepage.
232
+ export async function readClipboardText() {
233
+ if (process.platform === 'win32') {
234
+ const ps = '$t=Get-Clipboard -Raw; if ($null -eq $t) { exit 2 }; [Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($t)))';
235
+ const r = await execFileBuffer('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', ps], { timeout: 3000 });
236
+ if (!r.ok || !r.stdout?.length) return '';
237
+ try { return Buffer.from(r.stdout.toString('ascii').trim(), 'base64').toString('utf8'); } catch { return ''; }
238
+ }
239
+ if (process.platform === 'darwin') {
240
+ const r = await execFileBuffer('pbpaste', [], { timeout: 3000 });
241
+ return r.ok && r.stdout?.length ? r.stdout.toString('utf8') : '';
242
+ }
243
+ if (process.platform === 'linux') {
244
+ const wl = await execFileBuffer('wl-paste', ['--no-newline'], { timeout: 3000 });
245
+ if (wl.ok && wl.stdout?.length) return wl.stdout.toString('utf8');
246
+ const xc = await execFileBuffer('xclip', ['-selection', 'clipboard', '-o'], { timeout: 3000 });
247
+ if (xc.ok && xc.stdout?.length) return xc.stdout.toString('utf8');
248
+ return '';
249
+ }
250
+ return '';
251
+ }
@@ -90,6 +90,28 @@ function rememberNonEmptyQuotaSegments(key, segments) {
90
90
  _lastNonEmptyQuotaSegmentsByKey.delete(oldestKey);
91
91
  }
92
92
  }
93
+
94
+ // Monotonic hysteresis for the quota/usage segment. Once a value has rendered
95
+ // (held), a new non-empty result replaces it ONLY when it is at least as fresh
96
+ // or is confirmed own-instance live data. This stops the 5H/7D values flapping
97
+ // when another mixdog instance overwrites the shared active-instance/usage cache
98
+ // with an OLDER snapshot: metricsMatch flips false, the source alternates to a
99
+ // provider-wide cache snapshot captured at a different time, and without this
100
+ // gate the two sources would oscillate tick-to-tick.
101
+ // - nothing displayed yet .................. accept
102
+ // - incoming is own-instance live data ..... accept (always wins)
103
+ // - either side lacks a comparable asOf .... accept (preserves prior behavior)
104
+ // - displayed value is own live data ....... accept only a STRICTLY newer snapshot
105
+ // - both shared-cache snapshots ............ accept same-or-newer asOf
106
+ export function acceptQuotaSnapshot(held, incoming) {
107
+ if (!held) return true;
108
+ if (incoming && incoming.owned) return true;
109
+ const incomingAsOf = num(incoming && incoming.asOf);
110
+ const heldAsOf = num(held.asOf);
111
+ if (!incomingAsOf || !heldAsOf) return true;
112
+ if (held.owned) return incomingAsOf > heldAsOf;
113
+ return incomingAsOf >= heldAsOf;
114
+ }
93
115
  // Option A boot gate: the L1 usage/quota segment stays fully empty until THIS
94
116
  // process has captured its FIRST confirmed (current-process) OAuth usage
95
117
  // snapshot for THAT provider. The latch is monotonic PER PROVIDER — once a
@@ -310,10 +332,21 @@ function renderNativeStatusline({
310
332
  if (usageReady && _oauthUsageArmedProviders.has(normalizedHoldProvider)) {
311
333
  const holdKey = quotaSegmentsHoldKey({ provider, model, effort, fast, sessionId, clientHostPid });
312
334
  if (quotaSegments.length) {
313
- rememberNonEmptyQuotaSegments(holdKey, quotaSegments);
335
+ // Monotonic replace: keep the currently displayed value unless the new
336
+ // one is same-or-newer, or is confirmed own-instance live data.
337
+ const incoming = {
338
+ asOf: num(quotaStatus?.quotaWindowsAsOf),
339
+ owned: quotaStatus?.quotaWindowsOwned === true,
340
+ };
341
+ const held = _lastNonEmptyQuotaSegmentsByKey.get(holdKey);
342
+ if (acceptQuotaSnapshot(held, incoming)) {
343
+ rememberNonEmptyQuotaSegments(holdKey, { segments: quotaSegments, asOf: incoming.asOf, owned: incoming.owned });
344
+ } else if (held?.segments?.length) {
345
+ quotaSegments = held.segments;
346
+ }
314
347
  } else {
315
348
  const held = _lastNonEmptyQuotaSegmentsByKey.get(holdKey);
316
- if (held && held.length) quotaSegments = held;
349
+ if (held?.segments?.length) quotaSegments = held.segments;
317
350
  }
318
351
  }
319
352
  for (const seg of quotaSegments) addL1(seg);
@@ -530,6 +563,10 @@ function fallbackQuotaStatus({ provider, model } = {}) {
530
563
  value = {
531
564
  ...routeInfo,
532
565
  quotaWindows: limits.quotaWindows || [],
566
+ // Shared provider-wide OAuth usage cache snapshot: not owned by
567
+ // this instance. asOf = the snapshot's cachedAt for hysteresis.
568
+ quotaWindowsAsOf: num(usageSnapshot?.cachedAt),
569
+ quotaWindowsOwned: false,
533
570
  balance: limits.balance || null,
534
571
  routeSpend: limits.routeSpend || null,
535
572
  };
@@ -556,12 +593,15 @@ function providerKindForQuota(provider) {
556
593
  function mergeQuotaStatus(primary, fallback) {
557
594
  if (!primary) return fallback || null;
558
595
  if (!fallback) return primary;
596
+ const usePrimaryWindows = Array.isArray(primary.quotaWindows) && primary.quotaWindows.length;
559
597
  return {
560
598
  ...fallback,
561
599
  ...primary,
562
- quotaWindows: Array.isArray(primary.quotaWindows) && primary.quotaWindows.length
563
- ? primary.quotaWindows
564
- : (fallback.quotaWindows || []),
600
+ quotaWindows: usePrimaryWindows ? primary.quotaWindows : (fallback.quotaWindows || []),
601
+ // Keep asOf/owned aligned with whichever windows won, so the hysteresis gate
602
+ // compares against the timestamp of the value actually being rendered.
603
+ quotaWindowsAsOf: usePrimaryWindows ? num(primary.quotaWindowsAsOf) : num(fallback.quotaWindowsAsOf),
604
+ quotaWindowsOwned: usePrimaryWindows ? primary.quotaWindowsOwned === true : fallback.quotaWindowsOwned === true,
565
605
  balance: primary.balance || fallback.balance || null,
566
606
  routeSpend: primary.routeSpend || fallback.routeSpend || null,
567
607
  providerKind: primary.providerKind || fallback.providerKind || providerKindForQuota(primary.provider || fallback.provider),
@@ -9,6 +9,7 @@
9
9
  * malformed argument objects (the engine hands us `{ name, arguments, id }`).
10
10
  */
11
11
  import { bold, cyan, gray } from './ansi.mjs';
12
+ import { parseMcpToolName, isSelfMcpToolName } from '../runtime/shared/tool-primitives.mjs';
12
13
 
13
14
  const MAX_SUMMARY = 72;
14
15
 
@@ -38,13 +39,19 @@ export function renderToolCard(call) {
38
39
 
39
40
  const summary = safeSummary(name, args);
40
41
  const bullet = cyan('>');
41
- const label = bold(name);
42
+ const label = bold(mcpCardLabel(name));
42
43
  if (!summary) return ` ${bullet} ${label}`;
43
44
  return ` ${bullet} ${label} ${gray(truncate(summary, MAX_SUMMARY))}`;
44
45
  }
45
46
 
46
47
  // --- helpers -----------------------------------------------------------------
47
48
 
49
+ function mcpCardLabel(name) {
50
+ const mcp = parseMcpToolName(name);
51
+ if (!mcp || isSelfMcpToolName(name)) return name;
52
+ return `MCP ${mcp.server}.${mcp.tool}`;
53
+ }
54
+
48
55
  function safeSummary(name, args) {
49
56
  try {
50
57
  const fn = SUMMARIZERS[name];
@@ -343,27 +343,30 @@ function cachedQuotaWindowsFallback(provider, model) {
343
343
  // OAuth quota is provider/account-scoped, not model-scoped. Prefer the
344
344
  // current route key, then provider-wide cache, then the freshest same-provider
345
345
  // route entry so a model switch keeps showing quota before the next live fetch.
346
- if (!provider || !model) return [];
346
+ // Returns the chosen entry's cachedAt alongside the windows so the caller can
347
+ // apply monotonic hysteresis (never displace a displayed value with an older
348
+ // shared-cache snapshot written by a different instance).
349
+ if (!provider || !model) return { quotaWindows: [], cachedAt: 0 };
347
350
  try {
348
351
  const cachePath = path.join(pluginDataDir(), 'gateway-oauth-usage-cache.json');
349
352
  const cache = readJson(cachePath);
350
353
  const routes = cache && typeof cache.routes === 'object' ? cache.routes : null;
351
- if (!routes) return [];
354
+ if (!routes) return { quotaWindows: [], cachedAt: 0 };
352
355
  const routeKey = `${String(provider).toLowerCase()}${String(model)}`;
353
356
  const providerOnlyKey = String(provider).toLowerCase();
354
357
  const routePrefix = `${providerOnlyKey}`;
355
358
  const entry = routes[routeKey] || routes[providerOnlyKey] || Object.entries(routes)
356
359
  .filter(([key, value]) => key.startsWith(routePrefix) && Array.isArray(value?.quotaWindows))
357
360
  .sort((a, b) => (Number(b[1]?.cachedAt) || 0) - (Number(a[1]?.cachedAt) || 0))[0]?.[1];
358
- if (!Array.isArray(entry?.quotaWindows)) return [];
361
+ if (!Array.isArray(entry?.quotaWindows)) return { quotaWindows: [], cachedAt: 0 };
359
362
  // Boot guard: do not render previous-launch usage before the current runtime
360
363
  // has captured at least one snapshot. Once captured in this process, hold it
361
364
  // instead of blanking it during idle/network gaps.
362
365
  const cachedAt = Number(entry.cachedAt);
363
- if (!Number.isFinite(cachedAt) || cachedAt < STATUSLINE_PROCESS_STARTED_AT_MS) return [];
364
- return entry.quotaWindows;
366
+ if (!Number.isFinite(cachedAt) || cachedAt < STATUSLINE_PROCESS_STARTED_AT_MS) return { quotaWindows: [], cachedAt: 0 };
367
+ return { quotaWindows: entry.quotaWindows, cachedAt };
365
368
  } catch {
366
- return [];
369
+ return { quotaWindows: [], cachedAt: 0 };
367
370
  }
368
371
  }
369
372
 
@@ -499,11 +502,18 @@ function configuredGatewayStatus(options = {}) {
499
502
 
500
503
  export function loadGatewayStatus(options = {}) {
501
504
  const configured = configuredGatewayStatus(options);
505
+ const quotaFallback = configured
506
+ ? cachedQuotaWindowsFallback(configured.provider, configured.model)
507
+ : { quotaWindows: [], cachedAt: 0 };
502
508
  const configuredStatus = configured ? {
503
509
  ...configured,
504
510
  contextUsedPct: pctOf(options.activeContextTokens, compactBoundaryForStatus(configured)),
505
511
  lastUsage: null,
506
- quotaWindows: cachedQuotaWindowsFallback(configured.provider, configured.model),
512
+ quotaWindows: quotaFallback.quotaWindows,
513
+ // Shared provider-wide cache snapshot: not owned by this instance. asOf is
514
+ // the cache entry's cachedAt so an older snapshot can be rejected downstream.
515
+ quotaWindowsAsOf: num(quotaFallback.cachedAt, 0),
516
+ quotaWindowsOwned: false,
507
517
  balance: null,
508
518
  routeSpend: null,
509
519
  } : null;
@@ -609,6 +619,12 @@ export function loadGatewayStatus(options = {}) {
609
619
  : null,
610
620
  lastUsage,
611
621
  quotaWindows: activeQuotaWindows.length ? activeQuotaWindows : (configuredStatus?.quotaWindows || []),
622
+ // Provenance for monotonic hysteresis: live active-instance windows are
623
+ // own-instance data timestamped by the advert's updatedAt; otherwise inherit
624
+ // the shared-cache fallback's (unowned) asOf. Older/unowned snapshots must
625
+ // not displace a value already rendered from newer/owned data.
626
+ quotaWindowsAsOf: activeQuotaWindows.length ? (updatedAt || 0) : num(configuredStatus?.quotaWindowsAsOf, 0),
627
+ quotaWindowsOwned: activeQuotaWindows.length ? metricsMatch : false,
612
628
  balance: metricsMatch && active.gateway_balance && typeof active.gateway_balance === 'object' ? active.gateway_balance : (configuredStatus?.balance || null),
613
629
  routeSpend: metricsMatch && active.gateway_route_spend && typeof active.gateway_route_spend === 'object' ? active.gateway_route_spend : (configuredStatus?.routeSpend || null),
614
630
  providerKind: statusProviderKind,
@@ -0,0 +1,46 @@
1
+ ---
2
+ id: bench
3
+ name: Bench
4
+ description: "Autonomous benchmark workflow — loop to completion without user approval."
5
+ hidden: true
6
+ agents: worker, heavy-worker, reviewer, debugger, maintainer
7
+ ---
8
+
9
+ # Bench Workflow
10
+
11
+ Autonomous run: no user is present. Never wait for approval or ask
12
+ questions — decide and proceed immediately. Loop until the task is verified
13
+ complete or provably blocked.
14
+
15
+ Lead supervises: delegates, coordinates, judges, decides. Route by complexity:
16
+ - Lead directly: simple 1–2 step work, plus coordination, pre-planning, config
17
+ changes, and final git deployment.
18
+ - Worker: implementation that takes several steps. Heavy Worker:
19
+ high-complexity, multi-step implementation.
20
+ - Reviewer: verify implementation scopes (diff, regressions, missing checks).
21
+ - Debugger: very high complexity, or when root-causing has already failed at
22
+ least once.
23
+
24
+ 1. Plan — form the plan yourself and start executing immediately; no approval
25
+ step exists.
26
+ 2. Delegate — split into the maximum number of independent scopes.
27
+ - PARALLEL across independent scopes by default (implementation, analysis,
28
+ review, debugging alike); spawn every scope in the SAME turn. Shared or
29
+ cross-cutting code does NOT justify merging — split per path and verify
30
+ the shared parts yourself. The only single scope is a genuinely
31
+ inseparable dependency; then state it.
32
+ - SEQUENTIAL within a single complex scope: ordered steps with a
33
+ build/test-green gate between them, not one shot.
34
+ - Write briefs per the Lead brief contract.
35
+ - After spawning async agents, END THE TURN — no polling, guessing, or
36
+ dependent work until the completion notification resumes you.
37
+ 3. Review — pair one reviewer 1:1 with each implementation scope, spawned in the
38
+ same turn, never deferred or batched; wait for its result. Fact-check agent
39
+ responses and cross-check implementation and review results yourself before
40
+ acting. Send fixes back to the original scope and loop verify -> fix ->
41
+ re-verify until clean. Skip review only for simple, low-risk tasks. If a bug
42
+ survives 2+ fix cycles, have the debugger investigate first instead of
43
+ another fix round.
44
+ 4. Finish — verify the final state yourself; only stop when the task is
45
+ complete and verified, or a hard blocker makes progress impossible. State
46
+ the outcome and evidence in the final message.
@@ -7,7 +7,13 @@ agents: worker, heavy-worker, reviewer, debugger, maintainer
7
7
 
8
8
  # Default Workflow
9
9
 
10
+ HARD APPROVAL GATE — before the user gives an explicit go-ahead ("do it",
11
+ "proceed", "ㄱㄱ"), NO edits, apply_patch, state-changing shell commands, or
12
+ agent spawns. Read-only exploration only. Agreeing with a diagnosis or
13
+ pointing out a problem is NOT approval.
14
+
10
15
  Lead supervises: delegates, coordinates, judges, decides. Route by complexity:
16
+ (routing applies only AFTER approval)
11
17
  - Lead directly: simple 1–2 step work, plus coordination, pre-planning, config
12
18
  changes, and final git deployment.
13
19
  - Worker: implementation that takes several steps. Heavy Worker:
@@ -7,6 +7,11 @@ agents:
7
7
 
8
8
  # Solo Workflow
9
9
 
10
+ HARD APPROVAL GATE — before the user gives an explicit go-ahead ("do it",
11
+ "proceed", "ㄱㄱ"), NO edits, apply_patch, or state-changing shell commands.
12
+ Read-only exploration only. Agreeing with a diagnosis or pointing out a
13
+ problem is NOT approval.
14
+
10
15
  1. Plan — Lead discusses the request with the user, forms a plan, and waits for
11
16
  approval before execution.
12
17
  Only an explicit go-ahead is approval; diagnosis agreement is not. When
@@ -225,6 +225,10 @@ export default class Ink {
225
225
  // refreshed every render; read back via getSelectionRows() so the app can
226
226
  // stitch selections taller than the viewport. null when no selection.
227
227
  lastSelectionRows = null;
228
+ // [mixdog fork] One-shot flag: the next interactive frame takes the full
229
+ // clearTerminal+rewrite path even when nothing changed. Set via
230
+ // forceFullRepaint(); consumed (and reset) in renderInteractiveFrame.
231
+ forceClearNextFrame = false;
228
232
  constructor(options) {
229
233
  autoBind(this);
230
234
  this.options = options;
@@ -569,6 +573,19 @@ export default class Ink {
569
573
  getSelectionRows = () => {
570
574
  return this.lastSelectionRows;
571
575
  };
576
+ // [mixdog fork] Force a one-shot whole-screen clear + rewrite. Windows
577
+ // Terminal keeps its NATIVE (shift+drag) selection overlay alive across
578
+ // incremental in-place repaints, so a stale native highlight can sit on top
579
+ // of the app-drawn selection; a real clearTerminal+full rewrite dismisses
580
+ // it. The frame is BSU/ESU-wrapped by renderInteractiveFrame, so there is
581
+ // no visible flash. Called by the app on mouse-press via the render handle.
582
+ forceFullRepaint = () => {
583
+ if (this.isUnmounted || this.isUnmounting) {
584
+ return;
585
+ }
586
+ this.forceClearNextFrame = true;
587
+ this.rootNode.onImmediateRender();
588
+ };
572
589
  restoreLastOutput = () => {
573
590
  if (!this.interactive) {
574
591
  return;
@@ -1115,7 +1132,11 @@ export default class Ink {
1115
1132
  if (isFullscreen && outputToRender.endsWith('\n')) {
1116
1133
  outputToRender += '\u001B[0m';
1117
1134
  }
1118
- const shouldClearTerminal = shouldClearTerminalForFrame({
1135
+ // [mixdog fork] One-shot forced clear (see forceFullRepaint): TTY-only,
1136
+ // consumed here so it applies to exactly one frame.
1137
+ const forcedClear = this.forceClearNextFrame && isTty;
1138
+ this.forceClearNextFrame = false;
1139
+ const shouldClearTerminal = forcedClear || shouldClearTerminalForFrame({
1119
1140
  isTty,
1120
1141
  viewportRows,
1121
1142
  previousViewportRows,
@@ -1147,6 +1168,7 @@ export default class Ink {
1147
1168
  // with identical output text but must still force a full clear to
1148
1169
  // avoid ghosting stale cells left outside the new viewport.
1149
1170
  if (shouldClearTerminal &&
1171
+ !forcedClear &&
1150
1172
  !this.isUnmounting &&
1151
1173
  output === this.lastOutput &&
1152
1174
  staticOutput === '' &&