mixdog 0.9.93 → 0.9.95
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/NOTICE.md +72 -0
- package/package.json +16 -6
- package/scripts/lib/isolated-root-cleanup.mjs +19 -0
- package/scripts/run-suite.mjs +100 -0
- package/src/lib/rules-builder.cjs +4 -3
- package/src/output-styles/detailed.md +13 -15
- package/src/output-styles/extreme-minimal.md +7 -11
- package/src/output-styles/minimal.md +4 -7
- package/src/output-styles/simple.md +11 -13
- package/src/rules/agent/30-explorer.md +22 -16
- package/src/rules/lead/01-general.md +1 -2
- package/src/rules/lead/lead-brief.md +4 -5
- package/src/rules/lead/lead-tool.md +3 -2
- package/src/rules/shared/01-tool.md +28 -29
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +23 -7
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
- package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +6 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
- package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +75 -15
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +7 -8
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
- package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +10 -10
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
- package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -15
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +12 -32
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +42 -2
- package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
- package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +7 -4
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +40 -13
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +5 -9
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +3 -3
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +103 -92
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +98 -3
- package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -3
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +25 -3
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +12 -3
- package/src/runtime/agent/orchestrator/tools/builtin/grep-formatting.mjs +22 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +491 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +91 -13
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +90 -27
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +20 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +19 -9
- package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +7 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +32 -4
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +16 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +546 -23
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +8 -5
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +35 -5
- package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +48 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +71 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +104 -14
- package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +6 -17
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +2 -4
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +151 -18
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +5 -1
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +109 -13
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -5
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +4 -0
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +1 -1
- package/src/runtime/channels/backends/discord.mjs +5 -14
- package/src/runtime/channels/backends/telegram.mjs +0 -5
- package/src/runtime/channels/lib/inbound-handler.mjs +0 -1
- package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/worker-main.mjs +1 -1
- package/src/runtime/media/renditions.mjs +21 -2
- package/src/runtime/memory/lib/tool-call-handler.mjs +16 -1
- package/src/runtime/memory/tool-defs.mjs +6 -6
- package/src/runtime/shared/atomic-file.mjs +53 -0
- package/src/runtime/shared/background-tasks.mjs +16 -6
- package/src/runtime/shared/child-spawn-gate.mjs +50 -26
- package/src/runtime/shared/resource-admission.mjs +40 -1
- package/src/runtime/shared/task-notification-envelope.mjs +11 -2
- package/src/runtime/shared/tool-card-model.mjs +6 -2
- package/src/runtime/shared/tool-status.mjs +10 -1
- package/src/runtime/shared/tool-surface.mjs +5 -0
- package/src/runtime/shared/turn-snapshot.mjs +385 -21
- package/src/runtime/shared/turn-worktree-snapshot.mjs +540 -0
- package/src/session-runtime/context-status.mjs +9 -3
- package/src/session-runtime/lifecycle-api.mjs +35 -5
- package/src/session-runtime/mcp-glue.mjs +11 -6
- package/src/session-runtime/provider-models.mjs +94 -43
- package/src/session-runtime/provider-usage.mjs +26 -2
- package/src/session-runtime/runtime-core.mjs +46 -8
- package/src/session-runtime/runtime-tunables.mjs +4 -0
- package/src/session-runtime/self-update.mjs +33 -4
- package/src/session-runtime/session-lifecycle.mjs +2 -0
- package/src/session-runtime/session-text.mjs +2 -1
- package/src/session-runtime/session-turn-api.mjs +106 -22
- package/src/session-runtime/workflow.mjs +11 -4
- package/src/standalone/agent-tool.mjs +4 -4
- package/src/standalone/backend-daemon.mjs +570 -0
- package/src/standalone/channel-daemon-transport.mjs +141 -1
- package/src/standalone/channel-worker.mjs +3 -2
- package/src/standalone/engine-daemon-client.mjs +894 -0
- package/src/standalone/engine-daemon-local-bridge.mjs +20 -0
- package/src/standalone/engine-daemon-protocol.mjs +33 -0
- package/src/standalone/engine-daemon-service.mjs +864 -0
- package/src/standalone/engine-daemon-transport.mjs +603 -0
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/App.jsx +62 -47
- package/src/tui/app/app-format.mjs +4 -2
- package/src/tui/app/app-view.jsx +5 -0
- package/src/tui/app/channel-pickers.mjs +7 -6
- package/src/tui/app/core-memory-picker.mjs +4 -4
- package/src/tui/app/extension-pickers.mjs +20 -18
- package/src/tui/app/maintenance-pickers.mjs +27 -27
- package/src/tui/app/onboarding-steps.mjs +23 -18
- package/src/tui/app/prompt-submit.mjs +13 -2
- package/src/tui/app/route-pickers.mjs +13 -8
- package/src/tui/app/settings-picker.mjs +55 -58
- package/src/tui/app/slash-dispatch.mjs +32 -28
- package/src/tui/app/transcript-window.mjs +19 -0
- package/src/tui/app/usage-context-panels.mjs +22 -7
- package/src/tui/app/use-mouse-input.mjs +25 -3
- package/src/tui/app/use-prompt-queue-history.mjs +31 -16
- package/src/tui/app/use-transcript-scroll.mjs +30 -8
- package/src/tui/app/use-transcript-window.mjs +22 -1
- package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
- package/src/tui/components/PromptInput.jsx +10 -0
- package/src/tui/components/Spinner.jsx +89 -86
- package/src/tui/components/TextEntryPanel.jsx +14 -0
- package/src/tui/components/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +1537 -9718
- package/src/tui/engine/agent-job-feed.mjs +2 -2
- package/src/tui/engine/live-share.mjs +97 -4
- package/src/tui/engine/session-api-ext.mjs +23 -1
- package/src/tui/engine/session-api.mjs +88 -41
- package/src/tui/engine/session-flow.mjs +43 -4
- package/src/tui/engine/tool-card-results.mjs +6 -0
- package/src/tui/engine/turn.mjs +84 -4
- package/src/tui/engine-local-session.mjs +1108 -0
- package/src/tui/engine.mjs +16 -1057
- package/src/tui/index.jsx +47 -2
- package/src/tui/markdown/stream-fence.mjs +1 -1
- package/src/tui/spinner-meta.mjs +80 -0
- package/src/tui/spinner-verbs.mjs +35 -0
- package/src/ui/statusline-segments.mjs +43 -10
- package/src/ui/statusline.mjs +10 -1
- package/scripts/tmp-cdp-errors.mjs +0 -41
- package/scripts/tmp-cdp-inspect.mjs +0 -41
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
- package/src/standalone/channel-daemon.mjs +0 -226
package/src/tui/index.jsx
CHANGED
|
@@ -18,11 +18,19 @@ import { scheduleRenderFrameAck, TUI_RENDER_FPS } from './engine/render-timing.m
|
|
|
18
18
|
import { installProcessSignalCleanup } from '../runtime/shared/process-shutdown.mjs';
|
|
19
19
|
import { finishProcessLifecycle } from '../runtime/shared/process-lifecycle.mjs';
|
|
20
20
|
import { rgbSgr } from '../ui/ansi.mjs';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
emitTerminalBackground,
|
|
23
|
+
getThemeSetting,
|
|
24
|
+
listThemes,
|
|
25
|
+
loadThemeSettingFromConfig,
|
|
26
|
+
setThemeSetting,
|
|
27
|
+
theme,
|
|
28
|
+
} from './theme.mjs';
|
|
22
29
|
import { POP_KITTY, DISABLE_MODIFY_OTHER_KEYS } from './keyboard-protocol.mjs';
|
|
23
30
|
import { displayWidth } from './display-width.mjs';
|
|
24
31
|
import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
|
|
25
32
|
import { localPackageVersion } from '../runtime/shared/update-checker.mjs';
|
|
33
|
+
import { touchProjectSelected } from '../standalone/projects.mjs';
|
|
26
34
|
|
|
27
35
|
// Trailing `\x1b[>0s` restores XTSHIFTESCAPE (shift-to-select-extend) to its
|
|
28
36
|
// terminal default; MOUSE_TRACKING_ON opts into `\x1b[>1s`, so every mouse/alt
|
|
@@ -505,6 +513,29 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
|
|
|
505
513
|
const outcome = await storeOutcomePromise;
|
|
506
514
|
if (outcome.error) throw outcome.error;
|
|
507
515
|
store = outcome.store;
|
|
516
|
+
// Launching mixdog INSIDE a registered folder is an "open" too: without
|
|
517
|
+
// this, a project entered straight from the shell never moved in the
|
|
518
|
+
// picker's most-recent-first order. Unregistered folders are ignored.
|
|
519
|
+
try {
|
|
520
|
+
touchProjectSelected(store.getState?.()?.cwd || process.cwd());
|
|
521
|
+
} catch { /* best-effort recency */ }
|
|
522
|
+
// The palette lives in THIS process: on a daemon-backed store the engine's
|
|
523
|
+
// theme methods would run inside the daemon, so the picker saw a promise
|
|
524
|
+
// instead of the list and a switch repainted nothing here. Bind the three
|
|
525
|
+
// theme methods locally (same pattern as the ink selection helpers below)
|
|
526
|
+
// and mirror the switch to the engine with persist:false, purely so its
|
|
527
|
+
// themeEpoch bump re-renders this tree.
|
|
528
|
+
if (store.isRemoteEngine === true) {
|
|
529
|
+
const remoteSetTheme = store.setTheme;
|
|
530
|
+
store.listThemes = () => listThemes();
|
|
531
|
+
store.getTheme = () => getThemeSetting();
|
|
532
|
+
store.setTheme = (id, options = {}) => {
|
|
533
|
+
const applied = setThemeSetting(id, options);
|
|
534
|
+
void Promise.resolve(remoteSetTheme?.(id, { ...options, persist: false }))
|
|
535
|
+
.catch(() => { /* the local palette is already applied */ });
|
|
536
|
+
return applied;
|
|
537
|
+
};
|
|
538
|
+
}
|
|
508
539
|
bootProfile('store:ready', { ms: (performance.now() - startedAt).toFixed(1) });
|
|
509
540
|
} catch (error) {
|
|
510
541
|
splash.stop();
|
|
@@ -517,6 +548,20 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
|
|
|
517
548
|
process.stderr.write(`mixdog: ${error?.message || error}\n`);
|
|
518
549
|
return 1;
|
|
519
550
|
}
|
|
551
|
+
// The engine store is a daemon-backed Proxy: every method it does not
|
|
552
|
+
// implement locally comes back as an ASYNC remote call. A synchronous
|
|
553
|
+
// getOnboardingStatus() probe inside App therefore sees a Promise, never
|
|
554
|
+
// `.completed`, and re-opens the finished wizard on every launch. Resolve
|
|
555
|
+
// the status once here and hand the decided verdict to App. An unreadable
|
|
556
|
+
// status counts as completed so a transport hiccup cannot resurrect a
|
|
557
|
+
// wizard the user already dismissed (`mixdog --onboarding` still forces it).
|
|
558
|
+
let onboardingCompleted = true;
|
|
559
|
+
try {
|
|
560
|
+
const onboardingStatus = await store.getOnboardingStatus?.();
|
|
561
|
+
if (onboardingStatus && typeof onboardingStatus === 'object') {
|
|
562
|
+
onboardingCompleted = onboardingStatus.completed === true;
|
|
563
|
+
}
|
|
564
|
+
} catch { /* status probe failed → treat as completed */ }
|
|
520
565
|
// Stop the spinner BEFORE ink mounts so no stray splash write can land
|
|
521
566
|
// between (or after) ink's first frames.
|
|
522
567
|
splash.stop();
|
|
@@ -598,7 +643,7 @@ export async function runTui({ provider, model, toolMode, remote, forceOnboardin
|
|
|
598
643
|
// its clear-frame → write → relative re-render dance, which scrolls the
|
|
599
644
|
// alt screen one line per stray console line (the streaming newline
|
|
600
645
|
// bounce). See installTuiConsoleGuard.
|
|
601
|
-
const instance = render(<App store={store} forceOnboarding={forceOnboarding === true} />, { exitOnCtrlC: false, maxFps: TUI_RENDER_FPS, incrementalRendering: true, patchConsole: false, onRender: makeRenderProfiler() });
|
|
646
|
+
const instance = render(<App store={store} forceOnboarding={forceOnboarding === true} onboardingCompleted={onboardingCompleted} />, { exitOnCtrlC: false, maxFps: TUI_RENDER_FPS, incrementalRendering: true, patchConsole: false, onRender: makeRenderProfiler() });
|
|
602
647
|
bootProfile('render:mounted', { ms: (performance.now() - startedAt).toFixed(1) });
|
|
603
648
|
const { waitUntilExit } = instance;
|
|
604
649
|
// [mixdog fork] Hand the ink renderer's drag-selection setter to the store so
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* so the block height stays stable across deltas.
|
|
10
10
|
*
|
|
11
11
|
* Kept in its own dependency-free module so it can be unit-tested without the
|
|
12
|
-
* ink/JSX render stack.
|
|
12
|
+
* ink/JSX render stack.
|
|
13
13
|
*/
|
|
14
14
|
export function trimPartialClosingFences(tokens) {
|
|
15
15
|
const token = tokens?.[tokens.length - 1];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/tui/spinner-meta.mjs — the spinner BYLINE contract, shared by the TUI
|
|
3
|
+
* spinner row and the desktop live-activity band.
|
|
4
|
+
*
|
|
5
|
+
* The verb pool already lives in spinner-verbs.mjs so both surfaces say the
|
|
6
|
+
* same word at the same second. Everything AFTER the verb (elapsed gate, token
|
|
7
|
+
* label, thinking / "thought for Ns") used to be assembled twice, which is how
|
|
8
|
+
* the desktop ended up showing tokens from second one while the TUI hid them
|
|
9
|
+
* for 30s. One builder, one answer.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Short turns stay quiet: elapsed/token meta only after this long. */
|
|
13
|
+
export const SHOW_TOKENS_AFTER_MS = 30_000;
|
|
14
|
+
|
|
15
|
+
const compactNumberFormatter = new Intl.NumberFormat('en-US', {
|
|
16
|
+
notation: 'compact',
|
|
17
|
+
maximumFractionDigits: 1,
|
|
18
|
+
minimumFractionDigits: 0,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export function formatSpinnerTokens(value) {
|
|
22
|
+
const tokens = Math.max(0, Number(value || 0));
|
|
23
|
+
if (tokens >= 1000) return compactNumberFormatter.format(tokens).toLowerCase();
|
|
24
|
+
return String(Math.round(tokens));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// `auto` is the absence of a choice, so it never becomes a visible suffix —
|
|
28
|
+
// only an explicitly picked level earns "thinking (high)".
|
|
29
|
+
function effortSuffix(effort) {
|
|
30
|
+
const level = String(effort || '').trim().toLowerCase();
|
|
31
|
+
if (!level || level === 'auto' || level === 'default' || level === 'off' || level === 'none') return '';
|
|
32
|
+
return ` (${level})`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Live thinking reads as `thinking`; a finished thinking span reads as
|
|
37
|
+
* `thought for Ns` until the next one starts. Sub-second
|
|
38
|
+
* spans are noise and stay hidden.
|
|
39
|
+
*/
|
|
40
|
+
export function spinnerThinkingLabel({ thinking = false, thinkingSince = 0, thinkingMs = 0, effort = '' } = {}) {
|
|
41
|
+
if (thinking || Number(thinkingSince) > 0) return `thinking${effortSuffix(effort)}`;
|
|
42
|
+
const ms = Math.max(0, Number(thinkingMs) || 0);
|
|
43
|
+
if (ms >= 1000) return `thought for ${Math.max(1, Math.round(ms / 1000))}s`;
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Byline pieces for one live turn. Width gating stays with each surface (the
|
|
49
|
+
* TUI measures terminal columns, the desktop wraps in CSS) — this only decides
|
|
50
|
+
* WHAT is worth showing and how it reads.
|
|
51
|
+
*/
|
|
52
|
+
export function buildSpinnerMeta({
|
|
53
|
+
elapsedMs = 0,
|
|
54
|
+
outputTokens = 0,
|
|
55
|
+
thinking = false,
|
|
56
|
+
thinkingSince = 0,
|
|
57
|
+
thinkingMs = 0,
|
|
58
|
+
effort = '',
|
|
59
|
+
verbose = false,
|
|
60
|
+
} = {}) {
|
|
61
|
+
const tokens = Math.max(0, Number(outputTokens) || 0);
|
|
62
|
+
const thinkingText = spinnerThinkingLabel({ thinking, thinkingSince, thinkingMs, effort });
|
|
63
|
+
return {
|
|
64
|
+
tokensText: tokens > 0 ? `${formatSpinnerTokens(tokens)} tokens` : '',
|
|
65
|
+
showTokens: tokens > 0 && (Boolean(verbose) || Number(elapsedMs) > SHOW_TOKENS_AFTER_MS),
|
|
66
|
+
thinkingText,
|
|
67
|
+
thinkingActive: Boolean(thinking || Number(thinkingSince) > 0),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reduced motion: one switch for the whole process — animation is a nicety,
|
|
73
|
+
* and low-power/SSH/recording sessions must be able to drop it without losing
|
|
74
|
+
* the timer or the token readout.
|
|
75
|
+
*/
|
|
76
|
+
export function isReducedMotion(env) {
|
|
77
|
+
const source = env || (typeof process !== 'undefined' && process?.env) || {};
|
|
78
|
+
const value = String(source.MIXDOG_REDUCED_MOTION ?? '').trim().toLowerCase();
|
|
79
|
+
return value === '1' || value === 'true' || value === 'on' || value === 'yes';
|
|
80
|
+
}
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* src/tui/spinner-verbs.mjs — playful "thinking" verbs for the spinner.
|
|
3
3
|
*
|
|
4
4
|
* Playful spinner verbs. Shown as `<Verb>… (Ns · ↑ N tokens)` while a turn runs.
|
|
5
|
+
*
|
|
6
|
+
* ONE common pool, never a per-mode pool: the stream flips
|
|
7
|
+
* thinking → tool-use → responding many times inside a single turn, so a
|
|
8
|
+
* mode-keyed phrase rewrites itself every few seconds and reads as flicker.
|
|
9
|
+
* The phrase is drawn from this list and held for a fixed window instead.
|
|
5
10
|
*/
|
|
6
11
|
export const SPINNER_VERBS = [
|
|
7
12
|
'Accomplishing', 'Actioning', 'Actualizing', 'Architecting', 'Baking', 'Beaming',
|
|
@@ -40,5 +45,35 @@ export const SPINNER_VERBS = [
|
|
|
40
45
|
'Zesting', 'Zigzagging',
|
|
41
46
|
];
|
|
42
47
|
|
|
48
|
+
/** How long one phrase holds before the pool advances. */
|
|
49
|
+
export const SPINNER_VERB_ROTATE_MS = 30_000;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Modes that describe a STATE rather than ongoing work: they replace the pool
|
|
53
|
+
* phrase for as long as the mode lasts, then the rotation resumes. Everything
|
|
54
|
+
* else (requesting/thinking/tool-use/tool-input/responding) shares the pool.
|
|
55
|
+
* `reconnecting` is absent on purpose — the engine authors that text itself
|
|
56
|
+
* (retry countdown), so the surfaces pass `spinner.verb` through unchanged.
|
|
57
|
+
*/
|
|
58
|
+
export const SPINNER_MODE_OVERRIDE_VERBS = {
|
|
59
|
+
compacting: 'Compacting conversation',
|
|
60
|
+
'auto-clear': 'Auto-clearing conversation',
|
|
61
|
+
resuming: 'Resuming conversation',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The phrase for a turn at a point in time. Derived from the turn's
|
|
66
|
+
* `startedAt` so the TUI and the desktop show the SAME word at the same
|
|
67
|
+
* second, and so mode flips cannot change it — only the 30s window can.
|
|
68
|
+
*/
|
|
69
|
+
export function spinnerVerbFor(startedAt = 0, now = 0, pool = SPINNER_VERBS) {
|
|
70
|
+
const list = Array.isArray(pool) && pool.length ? pool : SPINNER_VERBS;
|
|
71
|
+
const anchor = Math.max(0, Number(startedAt) || 0);
|
|
72
|
+
const elapsed = anchor > 0 ? Math.max(0, (Number(now) || 0) - anchor) : 0;
|
|
73
|
+
const slot = Math.floor(elapsed / SPINNER_VERB_ROTATE_MS);
|
|
74
|
+
const seed = Math.floor(anchor / 1000) + slot;
|
|
75
|
+
return list[((seed * 7) + 3) % list.length];
|
|
76
|
+
}
|
|
77
|
+
|
|
43
78
|
/** Spinner glyph frames. */
|
|
44
79
|
export const SPINNER_FRAMES = ['◇', '◆', '◈', '◆', '◇'];
|
|
@@ -18,7 +18,13 @@ const DEFAULT_MIXDOG_HOME = process.env.MIXDOG_HOME || join(homedir(), '.mixdog'
|
|
|
18
18
|
const DEFAULT_STANDALONE_DATA_DIR = join(DEFAULT_MIXDOG_HOME, 'data');
|
|
19
19
|
const SHELL_JOBS_SEGMENT_CACHE_MS = 1000;
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Session buckets ride along with the owner-wide totals: one host process can
|
|
22
|
+
// own many sessions (the desktop pools every pane's engine), so a pane must be
|
|
23
|
+
// able to ask for ITS OWN jobs instead of the process aggregate.
|
|
24
|
+
const EMPTY_SHELL_JOBS_SESSION = Object.freeze({ count: 0, elapsedLabel: '' });
|
|
25
|
+
const EMPTY_SHELL_JOBS = Object.freeze({ count: 0, elapsedLabel: '', sessions: Object.freeze({}) });
|
|
26
|
+
|
|
27
|
+
let _shellJobsSegmentCache = { ownerPid: 0, at: 0, value: EMPTY_SHELL_JOBS };
|
|
22
28
|
let _shellJobsRefreshInFlight = false;
|
|
23
29
|
|
|
24
30
|
function dataDir() {
|
|
@@ -26,10 +32,16 @@ function dataDir() {
|
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
// Render-path entry point: synchronous, cache-only. Never blocks on fs.
|
|
29
|
-
|
|
35
|
+
// Without `sessionId` this reports the owner process aggregate (CLI statusline,
|
|
36
|
+
// keep-awake); with one it reports only that session's own jobs.
|
|
37
|
+
export function shellJobsStatus({ clientHostPid, sessionId } = {}) {
|
|
30
38
|
const ownerPid = positiveInt(clientHostPid);
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
// Asking for a scope is what matters, not whether that scope is non-empty:
|
|
40
|
+
// an explicit blank id (a desktop pane with no session yet) owns nothing,
|
|
41
|
+
// while an OMITTED id means "the whole owner process".
|
|
42
|
+
const scoped = sessionId !== undefined && sessionId !== null;
|
|
43
|
+
const scope = String(sessionId ?? '').trim();
|
|
44
|
+
if (!ownerPid) return scoped ? EMPTY_SHELL_JOBS_SESSION : EMPTY_SHELL_JOBS;
|
|
33
45
|
const now = Date.now();
|
|
34
46
|
const sameOwner = _shellJobsSegmentCache.ownerPid === ownerPid;
|
|
35
47
|
const fresh = sameOwner && now - _shellJobsSegmentCache.at < SHELL_JOBS_SEGMENT_CACHE_MS;
|
|
@@ -37,7 +49,9 @@ export function shellJobsStatus({ clientHostPid } = {}) {
|
|
|
37
49
|
_shellJobsRefreshInFlight = true;
|
|
38
50
|
refreshShellJobsStatus(ownerPid).finally(() => { _shellJobsRefreshInFlight = false; });
|
|
39
51
|
}
|
|
40
|
-
|
|
52
|
+
const value = sameOwner ? (_shellJobsSegmentCache.value || EMPTY_SHELL_JOBS) : EMPTY_SHELL_JOBS;
|
|
53
|
+
if (!scoped) return value;
|
|
54
|
+
return (scope && value.sessions?.[scope]) || EMPTY_SHELL_JOBS_SESSION;
|
|
41
55
|
}
|
|
42
56
|
|
|
43
57
|
// Memory cycle L2 segment source. The memory daemon writes
|
|
@@ -95,8 +109,7 @@ function pidAlive(pid) {
|
|
|
95
109
|
}
|
|
96
110
|
|
|
97
111
|
async function refreshShellJobsStatus(ownerPid) {
|
|
98
|
-
|
|
99
|
-
let value = empty;
|
|
112
|
+
let value = EMPTY_SHELL_JOBS;
|
|
100
113
|
try {
|
|
101
114
|
const dir = join(dataDir(), 'shell-jobs');
|
|
102
115
|
let names;
|
|
@@ -123,23 +136,43 @@ async function refreshShellJobsStatus(ownerPid) {
|
|
|
123
136
|
.slice(0, 30);
|
|
124
137
|
let count = 0;
|
|
125
138
|
let oldestMs = Infinity;
|
|
139
|
+
// sessionId -> { count, oldestMs }. Jobs with no session stamp (legacy
|
|
140
|
+
// records, plain CLI runs) still count toward the owner total but belong to
|
|
141
|
+
// no pane, so they never light up a session's indicator.
|
|
142
|
+
const bySession = new Map();
|
|
126
143
|
for (const id of ids) {
|
|
127
144
|
const p = join(dir, `${id}.json`);
|
|
128
145
|
let detail;
|
|
129
146
|
try { detail = JSON.parse(await readFile(p, 'utf-8')); } catch { continue; }
|
|
130
147
|
if (!(await isShellJobAlive(detail, p, dir, id))) continue;
|
|
131
148
|
count++;
|
|
149
|
+
let jobMs = Infinity;
|
|
132
150
|
try {
|
|
133
151
|
const st = await stat(p);
|
|
152
|
+
jobMs = st.mtimeMs;
|
|
134
153
|
if (st.mtimeMs < oldestMs) oldestMs = st.mtimeMs;
|
|
135
154
|
} catch {}
|
|
155
|
+
const owner = String(detail?.ownerSessionId ?? '').trim();
|
|
156
|
+
if (!owner) continue;
|
|
157
|
+
const bucket = bySession.get(owner) || { count: 0, oldestMs: Infinity };
|
|
158
|
+
bucket.count += 1;
|
|
159
|
+
if (jobMs < bucket.oldestMs) bucket.oldestMs = jobMs;
|
|
160
|
+
bySession.set(owner, bucket);
|
|
136
161
|
}
|
|
137
162
|
if (count) {
|
|
138
|
-
const
|
|
139
|
-
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
const elapsedLabel = Number.isFinite(oldestMs) ? formatElapsed(now - oldestMs) : '';
|
|
165
|
+
const sessions = {};
|
|
166
|
+
for (const [owner, bucket] of bySession) {
|
|
167
|
+
sessions[owner] = {
|
|
168
|
+
count: bucket.count,
|
|
169
|
+
elapsedLabel: Number.isFinite(bucket.oldestMs) ? formatElapsed(now - bucket.oldestMs) : '',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
value = { count, elapsedLabel, sessions };
|
|
140
173
|
}
|
|
141
174
|
} catch {
|
|
142
|
-
value =
|
|
175
|
+
value = EMPTY_SHELL_JOBS;
|
|
143
176
|
}
|
|
144
177
|
_shellJobsSegmentCache = { ownerPid, at: Date.now(), value };
|
|
145
178
|
}
|
package/src/ui/statusline.mjs
CHANGED
|
@@ -350,7 +350,16 @@ function renderNativeStatusline({
|
|
|
350
350
|
...activeHiddenAgentWorkers({ sessionId, clientHostPid }),
|
|
351
351
|
], agentJobs);
|
|
352
352
|
const { runningWorkers } = classifyAgentWorkers(agentPayload.workers);
|
|
353
|
-
|
|
353
|
+
// Shell segment scope: one host process can own MANY sessions' jobs (the
|
|
354
|
+
// desktop pools every pane's engine; the daemon backend hosts every
|
|
355
|
+
// terminal's session), so the owner-wide aggregate would show one
|
|
356
|
+
// terminal's running shell on every other terminal. Render this session's
|
|
357
|
+
// own jobs whenever we know which session we are; only a session-less
|
|
358
|
+
// caller (plain statusline shim) keeps the process aggregate.
|
|
359
|
+
const shellScope = String(sessionId ?? '').trim();
|
|
360
|
+
const shellStatus = shellScope
|
|
361
|
+
? shellJobsStatus({ clientHostPid, sessionId: shellScope })
|
|
362
|
+
: shellJobsStatus({ clientHostPid });
|
|
354
363
|
|
|
355
364
|
const spinnerNow = Date.now();
|
|
356
365
|
const sp = l2SpinnerFrame(spinnerNow);
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import WebSocket from "ws";
|
|
2
|
-
|
|
3
|
-
const list = await fetch("http://127.0.0.1:9342/json/list").then((r) => r.json());
|
|
4
|
-
const page = list.find((t) => t.type === "page");
|
|
5
|
-
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
6
|
-
let id = 0; const pending = new Map();
|
|
7
|
-
function send(method, params) {
|
|
8
|
-
return new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
|
|
9
|
-
}
|
|
10
|
-
const seen = new Map();
|
|
11
|
-
ws.on("message", (raw) => {
|
|
12
|
-
const msg = JSON.parse(raw);
|
|
13
|
-
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg.result); pending.delete(msg.id); return; }
|
|
14
|
-
if (msg.method === "Runtime.exceptionThrown") {
|
|
15
|
-
const d = msg.params.exceptionDetails;
|
|
16
|
-
const desc = d.exception?.description || d.text || "";
|
|
17
|
-
const key = desc.slice(0, 200);
|
|
18
|
-
seen.set(key, (seen.get(key) || 0) + 1);
|
|
19
|
-
} else if (msg.method === "Runtime.consoleAPICalled" && (msg.params.type === "error" || msg.params.type === "warning")) {
|
|
20
|
-
const text = msg.params.args.map((a) => a.value ?? a.description ?? "").join(" ");
|
|
21
|
-
const key = "[console." + msg.params.type + "] " + text.slice(0, 300);
|
|
22
|
-
seen.set(key, (seen.get(key) || 0) + 1);
|
|
23
|
-
} else if (msg.method === "Log.entryAdded") {
|
|
24
|
-
const e = msg.params.entry;
|
|
25
|
-
if (e.level === "error" || e.level === "warning") {
|
|
26
|
-
const key = "[log." + e.level + "] " + (e.text || "").slice(0, 300) + " @" + (e.url || "");
|
|
27
|
-
seen.set(key, (seen.get(key) || 0) + 1);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
});
|
|
31
|
-
ws.on("open", async () => {
|
|
32
|
-
await send("Runtime.enable", {});
|
|
33
|
-
await send("Log.enable", {});
|
|
34
|
-
setTimeout(() => {
|
|
35
|
-
for (const [key, count] of [...seen.entries()].sort((a, b) => b[1] - a[1])) {
|
|
36
|
-
console.log(`x${count} ${key.replace(/\n/g, " | ")}`);
|
|
37
|
-
}
|
|
38
|
-
if (!seen.size) console.log("(no errors captured in window)");
|
|
39
|
-
process.exit(0);
|
|
40
|
-
}, 12000);
|
|
41
|
-
});
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import WebSocket from "ws";
|
|
2
|
-
|
|
3
|
-
const list = await fetch("http://127.0.0.1:9342/json/list").then((r) => r.json());
|
|
4
|
-
const page = list.find((t) => t.type === "page");
|
|
5
|
-
if (!page) { console.error("no page target"); process.exit(1); }
|
|
6
|
-
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
7
|
-
let id = 0; const pending = new Map();
|
|
8
|
-
function send(method, params) {
|
|
9
|
-
return new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
|
|
10
|
-
}
|
|
11
|
-
ws.on("message", (raw) => {
|
|
12
|
-
const msg = JSON.parse(raw);
|
|
13
|
-
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg.result); pending.delete(msg.id); }
|
|
14
|
-
});
|
|
15
|
-
ws.on("open", async () => {
|
|
16
|
-
const expr = `(() => {
|
|
17
|
-
const gate = document.querySelector('.desktop-boot-gate');
|
|
18
|
-
const covers = [...document.querySelectorAll('.pane-surface-cover, .desktop-boot-cover')].map(c => ({
|
|
19
|
-
cls: c.className,
|
|
20
|
-
rect: Math.round(c.getBoundingClientRect().width) + 'x' + Math.round(c.getBoundingClientRect().height),
|
|
21
|
-
parent: (c.parentElement?.className || '').slice(0, 90),
|
|
22
|
-
hasSpinner: !!c.querySelector('.desktop-loading-spinner'),
|
|
23
|
-
}));
|
|
24
|
-
const gates = [...document.querySelectorAll('.pane-surface-gate[data-ready="false"], .stable-surface-switch[data-ready="false"], .stable-content-swap[data-ready="false"]')]
|
|
25
|
-
.map(g => g.className + ' | parent=' + (g.parentElement?.className || '').slice(0, 70));
|
|
26
|
-
return JSON.stringify({
|
|
27
|
-
bootGate: gate ? { ready: gate.dataset.ready, pending: gate.dataset.pending, timeout: gate.dataset.timeout } : null,
|
|
28
|
-
revealed: window.__mixdogDesktopRevealed,
|
|
29
|
-
shown: window.__mixdogWindowShown,
|
|
30
|
-
covers, gates,
|
|
31
|
-
metrics: (window.__mixdogBootMetrics || []).slice(-30),
|
|
32
|
-
recovery: !!document.querySelector('.desktop-recovery-screen'),
|
|
33
|
-
rootKids: document.getElementById('root')?.childElementCount,
|
|
34
|
-
url: location.href,
|
|
35
|
-
}, null, 1);
|
|
36
|
-
})()`;
|
|
37
|
-
const r = await send("Runtime.evaluate", { expression: expr, returnByValue: true });
|
|
38
|
-
console.log(r?.result?.value ?? JSON.stringify(r));
|
|
39
|
-
process.exit(0);
|
|
40
|
-
});
|
|
41
|
-
setTimeout(() => { console.error("timeout"); process.exit(1); }, 8000);
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
// Completion-first steering ladder (worker runaway prevention), extracted from
|
|
2
|
-
// loop.mjs. Owns the mutable ladder counters and the post-batch steering-hint
|
|
3
|
-
// emitters. State is threaded live via a context object of getters/setters so
|
|
4
|
-
// no snapshot goes stale — the loop mutates `messages`/`iterations` in place and
|
|
5
|
-
// this module reads them through the accessors on each call. No behavior change:
|
|
6
|
-
// the counters, thresholds, and emitted messages are verbatim from agentLoop.
|
|
7
|
-
import { appendAgentTrace } from '../../agent-trace.mjs';
|
|
8
|
-
import { level2SteerMessage } from './completion-guards.mjs';
|
|
9
|
-
import { isEagerDispatchable } from './tool-helpers.mjs';
|
|
10
|
-
|
|
11
|
-
// A single chained segment is read-only only if it STARTS with a read-only verb.
|
|
12
|
-
function _isReadOnlyShellSegment(seg) {
|
|
13
|
-
const s = seg.trim();
|
|
14
|
-
if (!s) return null; // empty (trailing operator)
|
|
15
|
-
if (/^git\s+(status|log)\b/.test(s)) return true;
|
|
16
|
-
if (/^git\s+diff\b[^\n]*--stat\b/.test(s)) return true;
|
|
17
|
-
if (/^get-childitem\b/.test(s)) return true;
|
|
18
|
-
return /^(cat|ls|dir|type)\b/.test(s);
|
|
19
|
-
}
|
|
20
|
-
// Read-only only when EVERY chained segment is read-only — a single mutating
|
|
21
|
-
// segment (e.g. `git status && npm test`) disqualifies the whole command.
|
|
22
|
-
function _isReadOnlyShellCmd(cmd) {
|
|
23
|
-
const s = String(cmd || '').toLowerCase().trim();
|
|
24
|
-
if (!s) return false;
|
|
25
|
-
let sawCmd = false;
|
|
26
|
-
for (const seg of s.split(/&&|\|\||[;&|]/)) {
|
|
27
|
-
const verdict = _isReadOnlyShellSegment(seg);
|
|
28
|
-
if (verdict === null) continue; // blank segment
|
|
29
|
-
if (verdict === false) return false; // a mutating segment
|
|
30
|
-
sawCmd = true;
|
|
31
|
-
}
|
|
32
|
-
return sawCmd;
|
|
33
|
-
}
|
|
34
|
-
// Build the completion-first steering-ladder controller. `ctx` supplies live
|
|
35
|
-
// accessors so every read reflects the loop's current mutable state:
|
|
36
|
-
// - messages, sessionId, sessionAgent, tools (stable refs/values)
|
|
37
|
-
// - getIterations() (current iteration)
|
|
38
|
-
// - getEditCount() (mutated by the loop)
|
|
39
|
-
// - pushSystemReminder(text) → push a meta:'hook' user message
|
|
40
|
-
// - pushUserMessage(msg) → push a raw user message (level-2 latch text)
|
|
41
|
-
export function createSteeringLadder(ctx) {
|
|
42
|
-
const {
|
|
43
|
-
sessionId,
|
|
44
|
-
sessionAgent,
|
|
45
|
-
tools,
|
|
46
|
-
getIterations,
|
|
47
|
-
getEditCount,
|
|
48
|
-
} = ctx;
|
|
49
|
-
const pushSystemReminder = ctx.pushSystemReminder;
|
|
50
|
-
const pushUserMessage = ctx.pushUserMessage;
|
|
51
|
-
// Permission-based role detection (agent names are user-definable):
|
|
52
|
-
// read-permission sessions legitimately never edit, so they get the
|
|
53
|
-
// report-oriented level-2 text.
|
|
54
|
-
const readOnlyRole = ctx.readOnlyRole === true;
|
|
55
|
-
// Edit-push steering is EXPLORER-ONLY: leads legitimately read broadly
|
|
56
|
-
// before delegating, and reviewer/debugger-style roles read continuously
|
|
57
|
-
// by design. Pushing "start editing / apply the edit" at those roles
|
|
58
|
-
// suppresses delegation (observed on TB2.1: forced-delegation workflow
|
|
59
|
-
// leads went solo right after these nudges). Non-explorer roles keep the
|
|
60
|
-
// batching guidance but never receive an edit directive, and level-2
|
|
61
|
-
// edit-push is skipped for them entirely.
|
|
62
|
-
const editPushEligible = sessionAgent === 'explorer';
|
|
63
|
-
|
|
64
|
-
// Step 1: escalation ladder. _level1FireCount is CUMULATIVE (never reset)
|
|
65
|
-
// so repeated batching reminders accumulate across the whole session.
|
|
66
|
-
// _level2LatchAtIteration latches level-2 steering to at most once / 5 turns.
|
|
67
|
-
let _level1FireCount = 0;
|
|
68
|
-
let _level2LatchAtIteration = -Infinity;
|
|
69
|
-
// Independent ladder counter: consecutive turns where EVERY call is
|
|
70
|
-
// read-only (any count) with zero edits. Catches multi-call read-only
|
|
71
|
-
// turns that the single-call level-1 streak misses. Reset on any edit.
|
|
72
|
-
let _allReadOnlyStreak = 0;
|
|
73
|
-
// Tracks consecutive assistant turns that ran exactly one read-only tool
|
|
74
|
-
// call (missed parallelism). Not reset per-iteration — only by the
|
|
75
|
-
// steering-hint fire below or by a turn that batches/edits.
|
|
76
|
-
let _serialReadOnlyStreak = 0;
|
|
77
|
-
let _level2FireCount = 0;
|
|
78
|
-
|
|
79
|
-
// Level-2 steering emitter shared by both ladder paths (single-call
|
|
80
|
-
// level-1 streak and the independent all-read-only streak). Sets the latch
|
|
81
|
-
// so it fires at most once per 5 turns regardless of which path triggered.
|
|
82
|
-
const _emitLevel2Steer = () => {
|
|
83
|
-
if (!editPushEligible && !readOnlyRole) return;
|
|
84
|
-
const iterations = getIterations();
|
|
85
|
-
_level2LatchAtIteration = iterations;
|
|
86
|
-
_level2FireCount += 1;
|
|
87
|
-
pushUserMessage({ role: 'user', content: level2SteerMessage(_level1FireCount, readOnlyRole, _level2FireCount), meta: 'hook' });
|
|
88
|
-
try {
|
|
89
|
-
appendAgentTrace({
|
|
90
|
-
sessionId,
|
|
91
|
-
iteration: iterations,
|
|
92
|
-
kind: 'steer',
|
|
93
|
-
payload: { tag: 'level2_steer', level1_fires: _level1FireCount, level2_fires: _level2FireCount, edit_count: getEditCount(), all_read_only_streak: _allReadOnlyStreak },
|
|
94
|
-
agent: sessionAgent || null,
|
|
95
|
-
});
|
|
96
|
-
} catch { /* best-effort */ }
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
// Post-batch steering hint gate. `hintAlreadyFired` seeds the once-per-turn
|
|
101
|
-
// latch. Returns nothing;
|
|
102
|
-
// pushes at most one steering message via the ctx push callbacks.
|
|
103
|
-
emitPostBatchSteering(calls, hintAlreadyFired) {
|
|
104
|
-
const iterations = getIterations();
|
|
105
|
-
const editCount = getEditCount();
|
|
106
|
-
// Steering hint gate: at most ONE hint per turn.
|
|
107
|
-
let _hintFiredThisTurn = hintAlreadyFired;
|
|
108
|
-
// Missed-parallelism steering: 2+ consecutive turns of a single
|
|
109
|
-
// read-only tool call suggest the model isn't batching independent
|
|
110
|
-
// lookups. Nudge once, then reset (fires again after 2 more).
|
|
111
|
-
if (calls.length === 1 && isEagerDispatchable(calls[0].name, tools)) {
|
|
112
|
-
_serialReadOnlyStreak += 1;
|
|
113
|
-
// Escalation ladder (Step 1). Cumulative level-1 fires are tracked
|
|
114
|
-
// and NEVER reset. Once level-1 has fired >=3 times with ZERO edits,
|
|
115
|
-
// escalate to level-2 steering (latched once per 5 turns). The
|
|
116
|
-
// level-2 escalation always wins.
|
|
117
|
-
const _canEscalate = (_level1FireCount + 1) >= 3 && editCount === 0 && (iterations - _level2LatchAtIteration) >= 5;
|
|
118
|
-
if (_serialReadOnlyStreak >= 2 && !_hintFiredThisTurn) {
|
|
119
|
-
_serialReadOnlyStreak = 0;
|
|
120
|
-
_level1FireCount += 1;
|
|
121
|
-
if (_canEscalate) {
|
|
122
|
-
_emitLevel2Steer();
|
|
123
|
-
} else {
|
|
124
|
-
pushSystemReminder(editPushEligible
|
|
125
|
-
? 'Last 2 turns each ran a single read-only tool. Batch independent lookups (read/grep/glob/code_graph) into ONE turn, or start editing if you have enough context.'
|
|
126
|
-
: 'Last 2 turns each ran a single read-only tool. Batch independent lookups (read/grep/glob/code_graph) into ONE turn.');
|
|
127
|
-
try {
|
|
128
|
-
appendAgentTrace({
|
|
129
|
-
sessionId,
|
|
130
|
-
iteration: iterations,
|
|
131
|
-
kind: 'steer',
|
|
132
|
-
payload: { tag: 'level1_batching', level1_fires: _level1FireCount, edit_count: editCount },
|
|
133
|
-
agent: sessionAgent || null,
|
|
134
|
-
});
|
|
135
|
-
} catch { /* best-effort */ }
|
|
136
|
-
}
|
|
137
|
-
_hintFiredThisTurn = true;
|
|
138
|
-
}
|
|
139
|
-
} else {
|
|
140
|
-
_serialReadOnlyStreak = 0;
|
|
141
|
-
}
|
|
142
|
-
// Independent all-read-only escalation (audit finding): the level-1
|
|
143
|
-
// streak above only counts single-call turns, so a worker that runs
|
|
144
|
-
// 2+ read-only calls per turn escapes the ladder entirely. Track a
|
|
145
|
-
// cumulative count of consecutive turns where EVERY call is read-only
|
|
146
|
-
// (any count) and no edit has been made; at 12 such turns fire level-2
|
|
147
|
-
// directly (same once-per-5-turn latch), reset on any edit.
|
|
148
|
-
{
|
|
149
|
-
const _allReadOnly = calls.length > 0 && calls.every((c) => isEagerDispatchable(c.name, tools));
|
|
150
|
-
if (_allReadOnly && editCount === 0) {
|
|
151
|
-
_allReadOnlyStreak += 1;
|
|
152
|
-
if (_allReadOnlyStreak >= 12 && (iterations - _level2LatchAtIteration) >= 5 && !_hintFiredThisTurn) {
|
|
153
|
-
_emitLevel2Steer();
|
|
154
|
-
_hintFiredThisTurn = true;
|
|
155
|
-
}
|
|
156
|
-
} else {
|
|
157
|
-
_allReadOnlyStreak = 0;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
// Detector: read-only shell (git status/log/diff --stat, ls/dir/cat/
|
|
161
|
-
// type/Get-ChildItem) inspects state the dedicated tools cover.
|
|
162
|
-
// Nudge toward them; never blocks execution.
|
|
163
|
-
{
|
|
164
|
-
if (!_hintFiredThisTurn && calls.some((c) => c?.name === 'shell' && _isReadOnlyShellCmd(c?.arguments?.command))) {
|
|
165
|
-
pushSystemReminder('Read-only shell (git status/log/diff --stat, ls/dir/cat/type/Get-ChildItem) inspects state the dedicated tools cover — use grep/read/list/find/code_graph; shell is for changing state or running programs.');
|
|
166
|
-
_hintFiredThisTurn = true;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
},
|
|
170
|
-
// A zero-tool turn must not bridge any cross-turn ladder streak.
|
|
171
|
-
resetAllReadOnlyStreak() {
|
|
172
|
-
_allReadOnlyStreak = 0;
|
|
173
|
-
_serialReadOnlyStreak = 0;
|
|
174
|
-
},
|
|
175
|
-
};
|
|
176
|
-
}
|