mixdog 0.9.47 → 0.9.49

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 (83) hide show
  1. package/package.json +14 -4
  2. package/scripts/agent-parallel-smoke.mjs +4 -1
  3. package/scripts/agent-route-batch-test.mjs +40 -0
  4. package/scripts/code-graph-aggregate-cwd-test.mjs +154 -0
  5. package/scripts/code-graph-description-contract.mjs +185 -0
  6. package/scripts/code-graph-disk-hit-test.mjs +55 -0
  7. package/scripts/code-graph-dispatch-test.mjs +96 -0
  8. package/scripts/compact-pressure-test.mjs +40 -0
  9. package/scripts/deferred-tool-loading-test.mjs +233 -0
  10. package/scripts/execution-completion-dedup-test.mjs +48 -0
  11. package/scripts/explore-prompt-policy-test.mjs +88 -3
  12. package/scripts/live-worker-smoke.mjs +68 -16
  13. package/scripts/memory-core-input-test.mjs +33 -13
  14. package/scripts/native-edit-wire-test.mjs +152 -0
  15. package/scripts/openai-oauth-ws-1006-retry-test.mjs +294 -16
  16. package/scripts/patch-binary-cache-test.mjs +181 -0
  17. package/scripts/prompt-immediate-render-test.mjs +89 -0
  18. package/scripts/provider-toolcall-test.mjs +280 -15
  19. package/scripts/shell-failure-diagnostics-test.mjs +211 -0
  20. package/scripts/statusline-quota-hysteresis-test.mjs +26 -1
  21. package/scripts/streaming-tail-window-test.mjs +29 -0
  22. package/scripts/tool-failures.mjs +21 -3
  23. package/scripts/tool-smoke.mjs +263 -38
  24. package/scripts/tool-tui-presentation-test.mjs +17 -1
  25. package/scripts/tui-perf-run.ps1 +26 -0
  26. package/scripts/tui-transcript-perf-test.mjs +7 -1
  27. package/scripts/verify-release-assets-test.mjs +647 -0
  28. package/scripts/verify-release-assets.mjs +293 -0
  29. package/scripts/windows-hide-spawn-options-test.mjs +19 -0
  30. package/src/cli.mjs +1 -0
  31. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +16 -5
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +35 -11
  33. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -11
  34. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +28 -0
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +34 -34
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +45 -38
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -11
  39. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +4 -4
  40. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +10 -6
  41. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +4 -3
  42. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +4 -3
  43. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +16 -1
  44. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -2
  45. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +0 -1
  46. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +13 -8
  47. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +30 -16
  48. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +25 -8
  49. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +69 -4
  50. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +5 -4
  51. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +12 -12
  53. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +12 -7
  54. package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +77 -18
  55. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  56. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +99 -24
  57. package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -11
  58. package/src/runtime/memory/tool-defs.mjs +1 -2
  59. package/src/session-runtime/context-status.mjs +25 -16
  60. package/src/session-runtime/model-route-api.mjs +2 -0
  61. package/src/session-runtime/runtime-core.mjs +2 -2
  62. package/src/session-runtime/session-turn-api.mjs +4 -1
  63. package/src/session-runtime/tool-catalog.mjs +113 -19
  64. package/src/session-runtime/tool-defs.mjs +1 -0
  65. package/src/standalone/agent-tool/helpers.mjs +25 -6
  66. package/src/standalone/agent-tool.mjs +75 -41
  67. package/src/tui/App.jsx +4 -0
  68. package/src/tui/app/input-parsers.mjs +8 -9
  69. package/src/tui/components/Markdown.jsx +6 -1
  70. package/src/tui/components/Message.jsx +11 -2
  71. package/src/tui/components/PromptInput.jsx +19 -21
  72. package/src/tui/components/Spinner.jsx +4 -4
  73. package/src/tui/components/StatusLine.jsx +6 -6
  74. package/src/tui/components/TranscriptItem.jsx +2 -2
  75. package/src/tui/components/prompt-input/immediate-render.mjs +47 -0
  76. package/src/tui/components/tool-execution/surface-detail.mjs +14 -9
  77. package/src/tui/dist/index.mjs +130 -45
  78. package/src/tui/engine/agent-job-feed.mjs +21 -2
  79. package/src/tui/engine/turn.mjs +12 -1
  80. package/src/tui/markdown/measure-rendered-rows.mjs +1 -1
  81. package/src/tui/markdown/streaming-markdown.mjs +20 -0
  82. package/src/ui/statusline.mjs +5 -5
  83. package/src/vendor/statusline/src/gateway/session-routes.mjs +22 -10
@@ -228,13 +228,22 @@ function activeToolForSurface(tool) {
228
228
 
229
229
  function deferredProviderMode(provider) {
230
230
  const p = clean(provider).toLowerCase();
231
- if (p === 'gemini') return 'full';
231
+ if (p === 'gemini') return 'manifest';
232
232
  if (p === 'anthropic' || p === 'anthropic-oauth'
233
- || p === 'openai' || p === 'openai-oauth'
234
- || p === 'xai' || p === 'grok-oauth') {
233
+ || p === 'openai' || p === 'openai-oauth') {
235
234
  return 'native';
236
235
  }
237
- return 'legacy';
236
+ // xAI/Grok and every other OpenAI-compatible backend have no native
237
+ // tool_search/tool_search_output contract. Give them one complete canonical
238
+ // function array instead of a load-driven array whose bytes churn.
239
+ return 'canonical';
240
+ }
241
+
242
+ function nativeProviderFamily(provider) {
243
+ const p = clean(provider).toLowerCase();
244
+ if (p === 'openai' || p === 'openai-oauth') return 'openai';
245
+ if (p === 'anthropic' || p === 'anthropic-oauth') return 'anthropic';
246
+ return '';
238
247
  }
239
248
 
240
249
  export function filterDisallowedTools(tools, disallowed = []) {
@@ -324,6 +333,7 @@ function toolSearchNativePayload(catalog, names, provider = '') {
324
333
  }
325
334
  if (!refs.length) return null;
326
335
  return {
336
+ provider: clean(provider).toLowerCase(),
327
337
  toolReferences: refs,
328
338
  openaiTools: tools,
329
339
  summary: `Loaded deferred tools: ${refs.join(', ')}`,
@@ -386,11 +396,16 @@ function setDeferredToolState(session, names) {
386
396
  }
387
397
 
388
398
  export function deferredPoolToolNames(session) {
389
- if (!session || session.deferredProviderMode === 'full') return [];
399
+ if (!session || session.deferredProviderMode === 'full'
400
+ || session.deferredProviderMode === 'manifest'
401
+ || session.deferredProviderMode === 'canonical') return [];
390
402
  const catalog = Array.isArray(session.deferredToolCatalog)
391
403
  ? session.deferredToolCatalog
392
404
  : [];
393
- const active = new Set((session.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
405
+ const active = new Set([
406
+ ...(session.tools || []).map((tool) => clean(tool?.name)).filter(Boolean),
407
+ ...parseToolSelection(session.deferredCallableTools),
408
+ ]);
394
409
  const out = [];
395
410
  for (const tool of catalog) {
396
411
  const name = clean(tool?.name);
@@ -447,10 +462,10 @@ export function applyDeferredToolSurface(session, mode, extraTools = [], options
447
462
  const catalog = sortedCatalogByMeasuredUsage([...byName.values()]);
448
463
  const defaultNames = defaultDeferredToolNames(catalog, mode);
449
464
  const storedNames = providerMode === 'native' ? [] : storedDeferredToolNames(session);
450
- let selectedNames = providerMode === 'full'
465
+ let selectedNames = providerMode === 'full' || providerMode === 'manifest' || providerMode === 'canonical'
451
466
  ? sortedNamesByMeasuredUsage(catalog.map((tool) => clean(tool?.name)).filter(Boolean))
452
467
  : [];
453
- if (providerMode !== 'full') {
468
+ if (!['full', 'manifest', 'canonical'].includes(providerMode)) {
454
469
  selectedNames = storedNames.length ? canonicalDeferredToolNames(catalog, storedNames) : [];
455
470
  if (!selectedNames.length || providerMode === 'native') selectedNames = sortedNamesByMeasuredUsage(defaultNames);
456
471
  }
@@ -460,6 +475,7 @@ export function applyDeferredToolSurface(session, mode, extraTools = [], options
460
475
  session.deferredDefaultTools = sortedNamesByMeasuredUsage(defaultNames);
461
476
  session.deferredProviderMode = providerMode;
462
477
  session.deferredNativeTools = providerMode === 'native';
478
+ session.deferredSurfaceMode = mode;
463
479
  session.tools.length = 0;
464
480
  const active = [];
465
481
  for (const tool of catalog) {
@@ -468,6 +484,7 @@ export function applyDeferredToolSurface(session, mode, extraTools = [], options
468
484
  session.tools.push(tool);
469
485
  active.push(clean(tool?.name));
470
486
  }
487
+ session.deferredCallableTools = sortedNamesByMeasuredUsage(active);
471
488
  if (providerMode === 'native') {
472
489
  const discovered = canonicalDeferredToolNames(catalog, session.deferredDiscoveredTools || []);
473
490
  session.deferredSelectedTools = active;
@@ -491,6 +508,33 @@ export function applyDeferredToolSurface(session, mode, extraTools = [], options
491
508
  return session;
492
509
  }
493
510
 
511
+ export function rebuildDeferredToolSurfaceForProvider(session, provider) {
512
+ if (!session || !Array.isArray(session.tools)) return session;
513
+ const previousFamily = nativeProviderFamily(session.provider);
514
+ const nextFamily = nativeProviderFamily(provider);
515
+ const preserveNativeState = previousFamily && previousFamily === nextFamily;
516
+ const discovered = preserveNativeState
517
+ ? canonicalDeferredToolNames(deferredCatalogUnion(session), session.deferredDiscoveredTools || [])
518
+ : [];
519
+ const catalog = deferredCatalogUnion(session).slice();
520
+ session.deferredDiscoveredTools = discovered;
521
+ applyDeferredToolSurface(
522
+ session,
523
+ session.deferredSurfaceMode || 'lead',
524
+ catalog,
525
+ { provider },
526
+ );
527
+ if (session.deferredProviderMode === 'native' && discovered.length) {
528
+ session.deferredDiscoveredTools = discovered;
529
+ session.deferredCallableTools = sortedNamesByMeasuredUsage(new Set([
530
+ ...(session.deferredCallableTools || []),
531
+ ...discovered,
532
+ ]));
533
+ session.deferredSelectedTools = session.deferredCallableTools.slice();
534
+ }
535
+ return session;
536
+ }
537
+
494
538
  // FIRST-TURN deferred-surface refresh (claude-code turn-time deferred manifest).
495
539
  // An MCP server may finish its handshake BETWEEN session-create and the first
496
540
  // user send. Fold those LIVE MCP tools into the boot deferred catalog + the
@@ -502,7 +546,7 @@ export function applyDeferredToolSurface(session, mode, extraTools = [], options
502
546
  // active — there is no deferred manifest to refresh).
503
547
  export function refreshInitialDeferredMcpSurface(session, liveMcpTools) {
504
548
  if (!session || !Array.isArray(session.messages)) return false;
505
- if (session.deferredProviderMode === 'full') return false;
549
+ if (session.deferredProviderMode === 'full' || session.deferredProviderMode === 'canonical') return false;
506
550
  const isMcp = (name) => typeof name === 'string' && name.startsWith('mcp__');
507
551
  const byName = new Map();
508
552
  for (const tool of Array.isArray(session.deferredToolCatalog) ? session.deferredToolCatalog : []) {
@@ -518,6 +562,15 @@ export function refreshInitialDeferredMcpSurface(session, liveMcpTools) {
518
562
  }
519
563
  if (!added) return false;
520
564
  session.deferredToolCatalog = sortedCatalogByMeasuredUsage([...byName.values()]);
565
+ if (session.deferredProviderMode === 'manifest') {
566
+ const next = session.deferredToolCatalog.filter((tool) => (
567
+ session.deferredSurfaceMode !== 'readonly' || isReadonlySelectable(tool)
568
+ ));
569
+ session.tools.splice(0, session.tools.length, ...next);
570
+ session.deferredCallableTools = next.map((tool) => clean(tool?.name)).filter(Boolean);
571
+ session.updatedAt = Date.now();
572
+ return true;
573
+ }
521
574
  // Refresh MCP server instructions so a newly-connected server's block is
522
575
  // included when BP1 is re-rendered below.
523
576
  session.mcpServerInstructions = getMcpServerInstructionsMap();
@@ -558,9 +611,31 @@ export function refreshInitialDeferredMcpSurface(session, liveMcpTools) {
558
611
  */
559
612
  export function reconcileDeferredMcpToolCatalog(session, liveMcpTools, options = {}) {
560
613
  if (!session || !Array.isArray(session.messages)) return null;
561
- if (session.deferredProviderMode === 'full') return null;
614
+ if (session.deferredProviderMode === 'full' || session.deferredProviderMode === 'canonical') return null;
562
615
  const isMcp = (name) => typeof name === 'string' && name.startsWith('mcp__');
563
616
  const live = Array.isArray(liveMcpTools) ? liveMcpTools : [];
617
+ if (session.deferredProviderMode === 'manifest') {
618
+ const byName = new Map();
619
+ for (const tool of Array.isArray(session.deferredToolCatalog) ? session.deferredToolCatalog : []) {
620
+ const name = clean(tool?.name);
621
+ if (name && !isMcp(name)) byName.set(name, tool);
622
+ }
623
+ for (const tool of live) {
624
+ const name = clean(tool?.name);
625
+ if (name && isMcp(name)) byName.set(name, activeToolForSurface(tool));
626
+ }
627
+ const catalog = sortedCatalogByMeasuredUsage([...byName.values()]);
628
+ const next = catalog.filter((tool) => (
629
+ session.deferredSurfaceMode !== 'readonly' || isReadonlySelectable(tool)
630
+ ));
631
+ const before = JSON.stringify((session.tools || []).map((tool) => activeToolForSurface(tool)));
632
+ const after = JSON.stringify(next);
633
+ session.deferredToolCatalog = catalog;
634
+ session.tools.splice(0, session.tools.length, ...next);
635
+ session.deferredCallableTools = next.map((tool) => clean(tool?.name)).filter(Boolean);
636
+ if (before !== after) session.updatedAt = Date.now();
637
+ return before === after ? null : session.deferredCallableTools;
638
+ }
564
639
  const lateCatalog = Array.isArray(session.deferredLateToolCatalog) ? session.deferredLateToolCatalog : [];
565
640
  const active = new Set((session.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
566
641
 
@@ -700,18 +775,18 @@ function deliverDeferredAnnouncement(session, reminder, options) {
700
775
  }
701
776
 
702
777
  export function selectDeferredTools(session, names, mode, options = {}) {
703
- const promoteToActive = options?.promoteToActive === true;
704
778
  // Resolve against the union of the boot-frozen catalog and the late-connected
705
- // MCP catalog so load_tool can load a late tool; loading promotes it onto
706
- // session.tools where the providers serialize it as a real (non-deferred) tool.
779
+ // MCP catalog so load_tool can load a late tool. Native providers register it
780
+ // independently; canonical fallback providers already expose the full array.
707
781
  const union = deferredCatalogUnion(session);
708
782
  const catalog = union.length
709
783
  ? union
710
784
  : (Array.isArray(session?.tools) ? session.tools : []);
711
- const active = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
785
+ const surfaceActive = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
786
+ const active = new Set([...surfaceActive, ...parseToolSelection(session?.deferredCallableTools)]);
712
787
  const native = session?.deferredProviderMode === 'native' || session?.deferredNativeTools === true;
713
788
  const discovered = new Set(Array.isArray(session?.deferredDiscoveredTools) ? session.deferredDiscoveredTools : []);
714
- const activateOnSurface = !native || promoteToActive;
789
+ const activateOnSurface = !native;
715
790
  const byName = new Map();
716
791
  for (const tool of catalog) {
717
792
  const name = clean(tool?.name);
@@ -745,12 +820,14 @@ export function selectDeferredTools(session, names, mode, options = {}) {
745
820
  discovered.delete(name);
746
821
  } else {
747
822
  discovered.add(name);
823
+ active.add(name);
748
824
  }
749
825
  added.push(name);
750
826
  }
751
827
  if (native) {
828
+ session.deferredCallableTools = sortedNamesByMeasuredUsage(active);
752
829
  session.deferredDiscoveredTools = sortedNamesByMeasuredUsage(
753
- [...discovered].filter((toolName) => !active.has(toolName)),
830
+ [...discovered],
754
831
  );
755
832
  session.deferredSelectedTools = sortedNamesByMeasuredUsage(active);
756
833
  } else {
@@ -828,14 +905,31 @@ export function renderToolSearch(args = {}, session, mode = 'full', options = {}
828
905
  }, null, 2);
829
906
  }
830
907
 
908
+ // Native loads update only the callable registry and provider-native history
909
+ // payload. Canonical fallback providers already carry the complete array.
831
910
  const toolSelection = selectDeferredTools(session, requestedNames, mode);
832
- const nextActiveNames = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
911
+ const nextActiveNames = new Set([
912
+ ...(session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean),
913
+ ...parseToolSelection(session?.deferredCallableTools),
914
+ ]);
833
915
  const loaded = toolSelection.added || [];
834
916
  const alreadyActive = toolSelection.already || [];
835
917
  const missing = toolSelection.missing || [];
836
918
  const blocked = toolSelection.blocked || [];
837
- const nativeToolSearch = toolSelection.native
838
- ? toolSearchNativePayload(catalog, [...new Set([...loaded, ...alreadyActive])], session?.provider)
919
+ const nativeToolSearchBase = toolSelection.native
920
+ ? (toolSearchNativePayload(catalog, loaded, session?.provider) || {
921
+ provider: clean(session?.provider).toLowerCase(),
922
+ toolReferences: [],
923
+ openaiTools: [],
924
+ summary: '',
925
+ })
926
+ : null;
927
+ const nativeSummary = [
928
+ ...(loaded.length ? [`Loaded deferred tools: ${loaded.join(', ')}`] : []),
929
+ ...(alreadyActive.length ? [`Already active: ${alreadyActive.join(', ')}`] : []),
930
+ ].join('\n');
931
+ const nativeToolSearch = nativeToolSearchBase
932
+ ? { ...nativeToolSearchBase, summary: nativeSummary || nativeToolSearchBase.summary }
839
933
  : null;
840
934
  const notes = [];
841
935
  if (missing.length && pendingMcpServers.length) {
@@ -18,6 +18,7 @@ export const TOOL_SEARCH_TOOL = {
18
18
  type: 'object',
19
19
  properties: {
20
20
  names: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], description: 'Exact deferred tool names/aliases to load.' },
21
+ select: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], description: 'Legacy alias for names; accepts exact names/aliases and select:name syntax.' },
21
22
  },
22
23
  additionalProperties: false,
23
24
  },
@@ -1,14 +1,14 @@
1
- // Pure, dependency-light helpers extracted from the agent-tool facade. No
2
- // module-level mutable state lives here (the frontmatter perm cache and all
3
- // timers stay in the facade / their owner modules). Behavior-preserving:
4
- // function bodies are identical to the originals.
1
+ // Dependency-light helpers extracted from the agent-tool facade.
5
2
  import { existsSync, readFileSync } from 'node:fs';
6
3
  import { isAbsolute, join, resolve } from 'node:path';
7
4
  import {
8
5
  normalizeAgentPermissionOrNone,
9
6
  parseMarkdownFrontmatter,
10
7
  } from '../../runtime/shared/markdown-frontmatter.mjs';
11
- import { clearGatewaySessionRoute, writeGatewaySessionRoute } from '../../vendor/statusline/src/gateway/session-routes.mjs';
8
+ import {
9
+ clearGatewaySessionRoute,
10
+ writeGatewaySessionRoutes,
11
+ } from '../../vendor/statusline/src/gateway/session-routes.mjs';
12
12
  import { PRESET_ALIASES } from './tool-def.mjs';
13
13
 
14
14
  export function envTimeoutMs(name, fallback) {
@@ -99,14 +99,33 @@ export function bridgeRouteForStatusline(preset = {}) {
99
99
  return out;
100
100
  }
101
101
 
102
+ const pendingAgentStatuslineRoutes = new Map();
103
+ let pendingAgentStatuslineRouteFlush = null;
104
+
105
+ export function flushAgentStatuslineRoutes() {
106
+ if (pendingAgentStatuslineRouteFlush) {
107
+ clearImmediate(pendingAgentStatuslineRouteFlush);
108
+ pendingAgentStatuslineRouteFlush = null;
109
+ }
110
+ if (pendingAgentStatuslineRoutes.size === 0) return false;
111
+ const entries = [...pendingAgentStatuslineRoutes.values()];
112
+ pendingAgentStatuslineRoutes.clear();
113
+ try { return writeGatewaySessionRoutes(entries); } catch { return false; }
114
+ }
115
+
102
116
  export function writeAgentStatuslineRoute(sessionId, preset) {
103
117
  const route = bridgeRouteForStatusline(preset);
104
118
  if (!sessionId || !route) return false;
105
- try { return writeGatewaySessionRoute(sessionId, route); } catch { return false; }
119
+ pendingAgentStatuslineRoutes.set(sessionId, { sessionId, route });
120
+ if (!pendingAgentStatuslineRouteFlush) {
121
+ pendingAgentStatuslineRouteFlush = setImmediate(flushAgentStatuslineRoutes);
122
+ }
123
+ return true;
106
124
  }
107
125
 
108
126
  export function clearAgentStatuslineRoute(sessionId) {
109
127
  if (!sessionId) return false;
128
+ pendingAgentStatuslineRoutes.delete(sessionId);
110
129
  try { return clearGatewaySessionRoute(sessionId); } catch { return false; }
111
130
  }
112
131
 
@@ -993,7 +993,7 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
993
993
  // to whatever raw args carry.
994
994
  let resolved = null;
995
995
  if (!clean(args.model) || !clean(args.provider)) {
996
- try { resolved = resolvePreset(cfgMod.loadConfig(), args)?.preset || null; }
996
+ try { resolved = resolveAgentSpawnPreset(cfgMod.loadConfig(), args)?.preset || null; }
997
997
  catch { resolved = null; }
998
998
  }
999
999
  return sanitizeTaskMeta({
@@ -1127,58 +1127,92 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
1127
1127
  }, notifyContext);
1128
1128
  }
1129
1129
 
1130
- function startProgressIdleWatchdog(sessionId, watchdogPolicy, agent = null) {
1131
- if (!sessionId || !agentWatchdogPolicyActive(watchdogPolicy)) return null;
1132
- if (typeof mgr.getSessionProgressSnapshot !== 'function' && typeof mgr.getSessionLastProgressAt !== 'function') return null;
1133
- if (typeof mgr.linkParentSignalToSession !== 'function') return null;
1134
- const controller = new AbortController();
1135
- const anchorTs = Date.now();
1136
- try { mgr.linkParentSignalToSession(sessionId, controller.signal); } catch { return null; }
1137
- const timer = setInterval(() => {
1138
- if (controller.signal?.aborted) return;
1139
- const now = Date.now();
1140
- const snapshot = typeof mgr.getSessionProgressSnapshot === 'function'
1141
- ? mgr.getSessionProgressSnapshot(sessionId)
1142
- : null;
1143
- const abortErr = snapshot
1144
- ? evaluateAgentWatchdogAbort(snapshot, now, watchdogPolicy)
1145
- : null;
1146
- const sess = typeof mgr.getSession === 'function' ? mgr.getSession(sessionId) : null;
1147
- const iteration = typeof sess?.lastIterationIndex === 'number' ? sess.lastIterationIndex : null;
1148
- if (!abortErr && !snapshot) {
1149
- const reported = mgr.getSessionLastProgressAt(sessionId);
1150
- const last = reported || anchorTs;
1151
- if (watchdogPolicy.idleStaleMs > 0 && now - last > watchdogPolicy.idleStaleMs) {
1152
- abortAgentProgressWatchdog(controller, {
1153
- sessionId,
1154
- agent,
1155
- error: new AgentStallAbortError(`agent task stale (${watchdogPolicy.idleStaleMs}ms without progress)`),
1156
- policy: watchdogPolicy,
1157
- now,
1158
- anchorTs,
1159
- lastProgressAt: reported,
1160
- iteration,
1161
- });
1162
- }
1163
- return;
1164
- }
1165
- if (abortErr) {
1130
+ const progressWatchdogs = new Map();
1131
+ let progressWatchdogTimer = null;
1132
+
1133
+ function stopProgressWatchdogTimerIfIdle() {
1134
+ if (progressWatchdogs.size > 0 || !progressWatchdogTimer) return;
1135
+ try { clearInterval(progressWatchdogTimer); } catch {}
1136
+ progressWatchdogTimer = null;
1137
+ }
1138
+
1139
+ function checkProgressIdleWatchdog(state) {
1140
+ const {
1141
+ sessionId,
1142
+ watchdogPolicy,
1143
+ agent,
1144
+ controller,
1145
+ anchorTs,
1146
+ } = state;
1147
+ if (controller.signal?.aborted) {
1148
+ progressWatchdogs.delete(sessionId);
1149
+ stopProgressWatchdogTimerIfIdle();
1150
+ return;
1151
+ }
1152
+ const now = Date.now();
1153
+ const snapshot = typeof mgr.getSessionProgressSnapshot === 'function'
1154
+ ? mgr.getSessionProgressSnapshot(sessionId)
1155
+ : null;
1156
+ const abortErr = snapshot
1157
+ ? evaluateAgentWatchdogAbort(snapshot, now, watchdogPolicy)
1158
+ : null;
1159
+ const sess = typeof mgr.getSession === 'function' ? mgr.getSession(sessionId) : null;
1160
+ const iteration = typeof sess?.lastIterationIndex === 'number' ? sess.lastIterationIndex : null;
1161
+ if (!abortErr && !snapshot) {
1162
+ const reported = mgr.getSessionLastProgressAt(sessionId);
1163
+ const last = reported || anchorTs;
1164
+ if (watchdogPolicy.idleStaleMs > 0 && now - last > watchdogPolicy.idleStaleMs) {
1166
1165
  abortAgentProgressWatchdog(controller, {
1167
1166
  sessionId,
1168
1167
  agent,
1169
- error: abortErr,
1170
- snapshot,
1168
+ error: new AgentStallAbortError(`agent task stale (${watchdogPolicy.idleStaleMs}ms without progress)`),
1171
1169
  policy: watchdogPolicy,
1172
1170
  now,
1173
1171
  anchorTs,
1172
+ lastProgressAt: reported,
1174
1173
  iteration,
1175
1174
  });
1176
1175
  }
1176
+ return;
1177
+ }
1178
+ if (abortErr) {
1179
+ abortAgentProgressWatchdog(controller, {
1180
+ sessionId,
1181
+ agent,
1182
+ error: abortErr,
1183
+ snapshot,
1184
+ policy: watchdogPolicy,
1185
+ now,
1186
+ anchorTs,
1187
+ iteration,
1188
+ });
1189
+ }
1190
+ }
1191
+
1192
+ function ensureProgressWatchdogTimer() {
1193
+ if (progressWatchdogTimer || progressWatchdogs.size === 0) return;
1194
+ progressWatchdogTimer = setInterval(() => {
1195
+ for (const state of [...progressWatchdogs.values()]) {
1196
+ checkProgressIdleWatchdog(state);
1197
+ }
1177
1198
  }, 1000);
1178
- timer.unref?.();
1199
+ progressWatchdogTimer.unref?.();
1200
+ }
1201
+
1202
+ function startProgressIdleWatchdog(sessionId, watchdogPolicy, agent = null) {
1203
+ if (!sessionId || !agentWatchdogPolicyActive(watchdogPolicy)) return null;
1204
+ if (typeof mgr.getSessionProgressSnapshot !== 'function' && typeof mgr.getSessionLastProgressAt !== 'function') return null;
1205
+ if (typeof mgr.linkParentSignalToSession !== 'function') return null;
1206
+ const controller = new AbortController();
1207
+ const anchorTs = Date.now();
1208
+ try { mgr.linkParentSignalToSession(sessionId, controller.signal); } catch { return null; }
1209
+ const state = { sessionId, watchdogPolicy, agent, controller, anchorTs };
1210
+ progressWatchdogs.set(sessionId, state);
1211
+ ensureProgressWatchdogTimer();
1179
1212
  return {
1180
1213
  stop: () => {
1181
- try { clearInterval(timer); } catch {}
1214
+ if (progressWatchdogs.get(sessionId) === state) progressWatchdogs.delete(sessionId);
1215
+ stopProgressWatchdogTimerIfIdle();
1182
1216
  },
1183
1217
  };
1184
1218
  }
package/src/tui/App.jsx CHANGED
@@ -95,6 +95,7 @@ import {
95
95
  TRANSCRIPT_WINDOW_MIN_ITEMS,
96
96
  TRANSCRIPT_WINDOW_OVERSCAN_ROWS,
97
97
  TRANSCRIPT_WINDOW_MAX_ITEMS,
98
+ TRANSCRIPT_WINDOW_TAIL_OVERSCAN_ROWS,
98
99
  SELECTION_PAINT_INTERVAL_MS,
99
100
  SCROLL_COALESCE_MS,
100
101
  PROMPT_HISTORY_LIMIT,
@@ -3161,6 +3162,9 @@ export function App({ store, initialStatusLine = '', forceOnboarding = false })
3161
3162
  rightTone={attachOverlayHint ? inputHintTone : 'info'}
3162
3163
  rightMessageWidth={attachOverlayHint ? (guardHintWidth || transientStatusWidth || 24) : 24}
3163
3164
  themeEpoch={state.themeEpoch || 0}
3165
+ streamingWindowRows={transcriptTailPinned && item.id === state.streamingTail?.id
3166
+ ? transcriptContentHeight + TRANSCRIPT_WINDOW_TAIL_OVERSCAN_ROWS
3167
+ : 0}
3164
3168
  />
3165
3169
  );
3166
3170
  // When measured-rows is on, wrap each row in a zero-cost flex column
@@ -98,14 +98,14 @@ export function parseMemoryCoreRows(text) {
98
98
  // meta column instead (blank for common).
99
99
  return null;
100
100
  }
101
- const match = raw.match(/^id=(\d+)\s+\[([^\]]*)\]\s+(.+?)(?:\s+—\s+(.+))?$/);
101
+ const match = raw.match(/^id=(\d+)\s+(.+?)(?:\s+—\s+(.+))?$/);
102
102
  if (match) {
103
- const [, id, category, element, summary = ''] = match;
103
+ const [, id, element, summary = ''] = match;
104
104
  return {
105
105
  value: `core-${id}`,
106
106
  // Display is summary-first: session injection only ever uses the
107
- // summary sentence (buildSessionCoreMemoryPayload), so id/category/
108
- // element are UI noise. They stay in the hidden _fields for
107
+ // summary sentence (buildSessionCoreMemoryPayload), so id/element
108
+ // are UI noise. The element stays in the hidden fields for
109
109
  // edit/delete plumbing.
110
110
  label: `#${id}`,
111
111
  meta: currentProjectId || '',
@@ -113,7 +113,6 @@ export function parseMemoryCoreRows(text) {
113
113
  _line: raw,
114
114
  _action: 'core-entry',
115
115
  _id: Number(id),
116
- _category: category,
117
116
  _element: element,
118
117
  _summary: summary || element,
119
118
  _projectId: currentProjectId,
@@ -142,8 +141,8 @@ export function parseMemoryCandidateRows(text) {
142
141
  if (!trimmed || /^core candidates:\s*none$/i.test(trimmed)) return [];
143
142
  // Backend row shape (index.mjs ~3164), one candidate per line, no group
144
143
  // headers:
145
- // id=<n> project=<COMMON|slug> [<category>] score=<x.xx|-> <element> — <summary> (<reason>)
146
- const rowPattern = /^id=(\d+)\s+project=(\S+)\s+\[([^\]]*)\]\s+score=(\S+)\s+(.+?)\s+—\s+(.+?)\s+\(([^)]*)\)$/;
144
+ // id=<n> project=<COMMON|slug> score=<x.xx|-> <element> — <summary> (<reason>)
145
+ const rowPattern = /^id=(\d+)\s+project=(\S+)\s+score=(\S+)\s+(.+?)\s+—\s+(.+?)\s+\(([^)]*)\)$/;
147
146
  return trimmed
148
147
  .split('\n')
149
148
  .filter((line) => line.trim())
@@ -151,10 +150,10 @@ export function parseMemoryCandidateRows(text) {
151
150
  const raw = line.trim();
152
151
  const match = raw.match(rowPattern);
153
152
  if (match) {
154
- const [, id, project, category, score, element, summary, reason] = match;
153
+ const [, id, project, score, element, summary, reason] = match;
155
154
  return {
156
155
  value: `candidate-${id}`,
157
- label: `#${id} [${category}] ${element}`,
156
+ label: `#${id} ${element}`,
158
157
  meta: project === 'COMMON' ? 'common' : project,
159
158
  description: `${summary}${score !== '-' ? ` (score ${score})` : ''} — ${reason}`,
160
159
  _line: raw,
@@ -28,6 +28,7 @@ export {
28
28
  resetStreamingMarkdownStablePrefix,
29
29
  resetAllStreamingMarkdownStablePrefixes,
30
30
  streamingLayoutText,
31
+ windowPlainStreamingText,
31
32
  } from '../markdown/streaming-markdown.mjs';
32
33
  export {
33
34
  measureMarkdownRenderedRows,
@@ -82,7 +83,11 @@ export function Markdown({ children, themeEpoch = 0, trimPartialFences = false,
82
83
  export function StreamingMarkdown({ children, themeEpoch = 0, columns, streamKey }) {
83
84
  const parts = resolveStreamingMarkdownParts(children, streamKey);
84
85
  if (parts.plain) {
85
- return <Markdown themeEpoch={themeEpoch} columns={columns}>{parts.unstableForRender}</Markdown>;
86
+ // Plain streaming text has no markdown tokens to style. Sending a growing
87
+ // multi-line tail through renderTokenAnsiSegments/marked on every delta
88
+ // reparses the whole response and dominates frame time; Ink can wrap the
89
+ // identical visible text directly.
90
+ return <Text color={theme.text} wrap="wrap">{parts.unstableForRender}</Text>;
86
91
  }
87
92
  return (
88
93
  <Box flexDirection="column" gap={1}>
@@ -15,7 +15,12 @@
15
15
  import React from 'react';
16
16
  import { Box, Text } from 'ink';
17
17
  import { theme, TURN_MARKER } from '../theme.mjs';
18
- import { Markdown, StreamingMarkdown, resetStreamingMarkdownStablePrefix } from './Markdown.jsx';
18
+ import {
19
+ Markdown,
20
+ StreamingMarkdown,
21
+ resetStreamingMarkdownStablePrefix,
22
+ windowPlainStreamingText,
23
+ } from './Markdown.jsx';
19
24
  import { assistantBodyWidth } from '../markdown/table-layout.mjs';
20
25
 
21
26
  // `themeEpoch` is a memo-busting prop (threaded from App): the active theme
@@ -28,6 +33,7 @@ export const AssistantMessage = React.memo(function AssistantMessage({
28
33
  columns = 80,
29
34
  themeEpoch = 0,
30
35
  assistantId,
36
+ streamingWindowRows = 0,
31
37
  }) {
32
38
  // The body column needs an EXPLICIT numeric width. Without it, ink/Yoga
33
39
  // measures the wrapped markdown body before the row's available width is
@@ -43,6 +49,9 @@ export const AssistantMessage = React.memo(function AssistantMessage({
43
49
  }, [streaming, assistantId]);
44
50
 
45
51
  const bodyWidth = assistantBodyWidth(columns);
52
+ const renderText = streaming && streamingWindowRows > 0
53
+ ? windowPlainStreamingText(text, bodyWidth, streamingWindowRows)
54
+ : text;
46
55
  return (
47
56
  <Box flexDirection="row" marginTop={1}>
48
57
  <Box flexShrink={0} minWidth={2}>
@@ -50,7 +59,7 @@ export const AssistantMessage = React.memo(function AssistantMessage({
50
59
  </Box>
51
60
  <Box flexDirection="column" flexShrink={0} width={bodyWidth}>
52
61
  {streaming
53
- ? <StreamingMarkdown themeEpoch={themeEpoch} columns={bodyWidth} streamKey={assistantId}>{text}</StreamingMarkdown>
62
+ ? <StreamingMarkdown themeEpoch={themeEpoch} columns={bodyWidth} streamKey={assistantId}>{renderText}</StreamingMarkdown>
54
63
  : <Markdown themeEpoch={themeEpoch} columns={bodyWidth}>{text}</Markdown>}
55
64
  </Box>
56
65
  </Box>
@@ -49,6 +49,10 @@ import {
49
49
  isModifiedEnterSequence,
50
50
  isAnyModifiedEnterSequence,
51
51
  } from './prompt-input/edit-helpers.mjs';
52
+ import {
53
+ cancelPromptImmediateFlush,
54
+ schedulePromptImmediateFlush,
55
+ } from './prompt-input/immediate-render.mjs';
52
56
 
53
57
  // Windows Terminal IME composition can clip a glyph that starts exactly at the
54
58
  // left edge of the editable text node. The rounded prompt box already adds a
@@ -108,6 +112,7 @@ export function PromptInput({
108
112
  });
109
113
  const [, bumpCursorAnchorEpoch] = useState(0);
110
114
  const draftRef = useRef(draft);
115
+ const interruptActiveRef = useRef(interruptActive);
111
116
  const lastReportedValueRef = useRef(draft.value);
112
117
  // Bumped on every submit/draftOverride replace/unmount so an async paste
113
118
  // (clipboard read or onPasteText promise) that resolves after the draft
@@ -132,6 +137,7 @@ export function PromptInput({
132
137
  const undoRef = useRef({ past: [], future: [], lastPushAt: 0, lastValue: null });
133
138
  const { value, cursor } = draft;
134
139
  draftRef.current = draft;
140
+ interruptActiveRef.current = interruptActive;
135
141
  if (selectionRef) {
136
142
  const range = selectionRange(draft);
137
143
  selectionRef.current = range
@@ -158,30 +164,22 @@ export function PromptInput({
158
164
  }
159
165
  };
160
166
 
161
- // commitDraft fires on every keystroke; a raw queueMicrotask(flushImmediate)
162
- // per commit bypassed ink's maxFps and forced an unthrottled render for each
163
- // char (a burst of paste/held-key edits = a render storm). Coalesce to a
164
- // frame budget: flush the first keystroke immediately (no caret lag) and
165
- // batch the rest to one flush per ~16ms, with a trailing flush so the final
166
- // char of a burst always lands.
167
- const FLUSH_INTERVAL_MS = 16;
167
+ // During an active turn, ink is already painting streaming updates at its
168
+ // 60fps budget. A second unthrottled input render competes with those frames
169
+ // and makes key echo slower, so busy input stays on ink's normal render path.
170
+ // Idle input keeps the leading+trailing immediate flush for crisp caret echo.
168
171
  const scheduleImmediateFlush = () => {
169
- const t = flushThrottleRef.current;
170
- const now = Date.now();
171
- const elapsed = now - t.lastAt;
172
- if (elapsed >= FLUSH_INTERVAL_MS) {
173
- if (t.timer !== null) { clearTimeout(t.timer); t.timer = null; }
174
- t.lastAt = now;
175
- queueMicrotask(flushImmediate);
176
- } else if (t.timer === null) {
177
- t.timer = setTimeout(() => {
178
- t.timer = null;
179
- t.lastAt = Date.now();
180
- flushImmediate();
181
- }, FLUSH_INTERVAL_MS - elapsed);
182
- }
172
+ schedulePromptImmediateFlush({
173
+ throttle: flushThrottleRef.current,
174
+ isSuppressed: () => interruptActiveRef.current,
175
+ flush: flushImmediate,
176
+ });
183
177
  };
184
178
 
179
+ useEffect(() => () => {
180
+ cancelPromptImmediateFlush(flushThrottleRef.current);
181
+ }, []);
182
+
185
183
  const commitDraft = (next, options = {}) => {
186
184
  const sameDraft = draftStateEqual(draftRef.current, next);
187
185
  if (!options.keepPreferredColumn) preferredColumnRef.current = null;