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.
- package/README.md +1 -4
- package/package.json +1 -1
- package/scripts/channel-daemon-smoke.mjs +165 -0
- package/scripts/channel-daemon-stub.mjs +69 -0
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-smoke.mjs +30 -17
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +10 -10
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +8 -6
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +14 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +23 -13
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
- package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
- package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
- package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
- package/src/runtime/channels/lib/seat-lock.mjs +196 -0
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
- package/src/runtime/channels/lib/worker-main.mjs +42 -11
- package/src/runtime/memory/index.mjs +54 -7
- package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
- package/src/runtime/memory/lib/pg/process.mjs +85 -40
- package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
- package/src/runtime/shared/atomic-file.mjs +44 -10
- package/src/runtime/shared/tool-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +23 -13
- package/src/session-runtime/config-lifecycle.mjs +48 -7
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +63 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +32 -2
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +24 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +306 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-daemon-client.mjs +224 -0
- package/src/standalone/channel-daemon-transport.mjs +351 -0
- package/src/standalone/channel-daemon.mjs +139 -0
- package/src/standalone/channel-worker.mjs +213 -4
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +105 -17
- package/src/tui/app/clipboard.mjs +39 -19
- package/src/tui/app/doctor.mjs +57 -0
- package/src/tui/app/extension-pickers.mjs +53 -9
- package/src/tui/app/maintenance-pickers.mjs +0 -5
- package/src/tui/app/slash-dispatch.mjs +4 -4
- package/src/tui/app/text-layout.mjs +11 -0
- package/src/tui/app/use-mouse-input.mjs +235 -51
- package/src/tui/app/use-prompt-handlers.mjs +49 -30
- package/src/tui/app/use-transcript-scroll.mjs +124 -27
- package/src/tui/app/use-transcript-window.mjs +55 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +3 -1
- package/src/tui/components/QueuedCommands.jsx +21 -10
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +653 -310
- package/src/tui/engine/session-api.mjs +17 -7
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine.mjs +8 -0
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline.mjs +45 -5
- package/src/ui/tool-card.mjs +8 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
- package/src/workflows/bench/WORKFLOW.md +46 -0
- package/src/workflows/default/WORKFLOW.md +6 -0
- package/src/workflows/solo/WORKFLOW.md +5 -0
- package/vendor/ink/build/ink.js +23 -1
- package/vendor/ink/build/output.js +154 -71
- package/vendor/ink/build/render-node-to-output.js +44 -2
- package/vendor/ink/build/render.js +4 -0
- 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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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;
|
package/src/tui/engine.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/ui/statusline.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
563
|
-
|
|
564
|
-
|
|
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),
|
package/src/ui/tool-card.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
package/vendor/ink/build/ink.js
CHANGED
|
@@ -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
|
-
|
|
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 === '' &&
|