mixdog 0.9.22 → 0.9.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/build-runtime-windows.ps1 +242 -242
  4. package/scripts/channel-daemon-smoke.mjs +165 -0
  5. package/scripts/channel-daemon-stub.mjs +69 -0
  6. package/scripts/recall-usecase-cases.json +1 -1
  7. package/scripts/smoke-runtime-negative.ps1 +106 -106
  8. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  9. package/scripts/tool-efficiency-diag.mjs +1 -1
  10. package/scripts/tool-smoke.mjs +30 -17
  11. package/src/rules/lead/02-channels.md +3 -3
  12. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
  13. package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
  14. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  16. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  17. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  18. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  20. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  21. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  23. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -0
  24. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  25. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
  27. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  28. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
  29. package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
  30. package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
  31. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  32. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  33. package/src/runtime/channels/lib/seat-lock.mjs +196 -0
  34. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  35. package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
  36. package/src/runtime/channels/lib/worker-main.mjs +42 -11
  37. package/src/runtime/memory/index.mjs +54 -7
  38. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  39. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  40. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  41. package/src/runtime/shared/atomic-file.mjs +20 -4
  42. package/src/runtime/shared/tool-primitives.mjs +31 -1
  43. package/src/runtime/shared/tool-surface.mjs +23 -13
  44. package/src/session-runtime/config-lifecycle.mjs +48 -7
  45. package/src/session-runtime/lifecycle-api.mjs +9 -0
  46. package/src/session-runtime/mcp-glue.mjs +63 -1
  47. package/src/session-runtime/resource-api.mjs +62 -8
  48. package/src/session-runtime/runtime-core.mjs +32 -2
  49. package/src/session-runtime/session-text.mjs +41 -0
  50. package/src/session-runtime/session-turn-api.mjs +24 -0
  51. package/src/session-runtime/settings-api.mjs +8 -1
  52. package/src/session-runtime/tool-catalog.mjs +306 -38
  53. package/src/session-runtime/tool-defs.mjs +7 -7
  54. package/src/session-runtime/workflow.mjs +2 -1
  55. package/src/standalone/channel-daemon-client.mjs +224 -0
  56. package/src/standalone/channel-daemon-transport.mjs +351 -0
  57. package/src/standalone/channel-daemon.mjs +139 -0
  58. package/src/standalone/channel-worker.mjs +213 -4
  59. package/src/standalone/hook-bus.mjs +71 -3
  60. package/src/tui/App.jsx +105 -17
  61. package/src/tui/app/clipboard.mjs +39 -19
  62. package/src/tui/app/doctor.mjs +57 -0
  63. package/src/tui/app/extension-pickers.mjs +53 -9
  64. package/src/tui/app/maintenance-pickers.mjs +0 -5
  65. package/src/tui/app/slash-dispatch.mjs +4 -4
  66. package/src/tui/app/text-layout.mjs +11 -0
  67. package/src/tui/app/use-mouse-input.mjs +235 -51
  68. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  69. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  70. package/src/tui/app/use-transcript-window.mjs +55 -1
  71. package/src/tui/components/Message.jsx +1 -1
  72. package/src/tui/components/PromptInput.jsx +3 -1
  73. package/src/tui/components/QueuedCommands.jsx +21 -10
  74. package/src/tui/components/ToolExecution.jsx +2 -2
  75. package/src/tui/dist/index.mjs +635 -306
  76. package/src/tui/engine/session-api.mjs +17 -7
  77. package/src/tui/engine/session-flow.mjs +6 -0
  78. package/src/tui/engine.mjs +8 -0
  79. package/src/tui/index.jsx +62 -18
  80. package/src/tui/paste-attachments.mjs +26 -0
  81. package/src/ui/statusline.mjs +45 -5
  82. package/src/ui/tool-card.mjs +8 -1
  83. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  84. package/src/workflows/bench/WORKFLOW.md +46 -0
  85. package/src/workflows/default/WORKFLOW.md +6 -0
  86. package/src/workflows/solo/WORKFLOW.md +5 -0
  87. package/vendor/ink/build/ink.js +23 -1
  88. package/vendor/ink/build/output.js +154 -71
  89. package/vendor/ink/build/render-node-to-output.js +44 -2
  90. package/vendor/ink/build/render.js +4 -0
  91. package/vendor/ink/build/renderer.js +4 -1
@@ -1,9 +1,9 @@
1
1
  // Deferred-tool catalog: measured-usage ordering, kind/bucket classification,
2
2
  // tool_search ranking + auto-selection, and the session tool-surface
3
3
  // application/selection logic. Pure module (session objects passed in).
4
- import { clean } from './session-text.mjs';
4
+ import { clean, LATE_TOOL_ANNOUNCEMENT_SENTINEL } from './session-text.mjs';
5
5
  import { estimateToolSchemaTokens } from '../runtime/agent/orchestrator/session/context-utils.mjs';
6
- import { applyInitialDeferredToolManifestToBp1 } from '../runtime/agent/orchestrator/context/collect.mjs';
6
+ import { applyInitialDeferredToolManifestToBp1, buildDeferredToolManifest } from '../runtime/agent/orchestrator/context/collect.mjs';
7
7
  import { getMcpServerInstructionsMap } from '../runtime/agent/orchestrator/mcp/client.mjs';
8
8
  import {
9
9
  isResponsesFreeformTool,
@@ -37,7 +37,7 @@ export const DEFERRED_DEFAULT_FULL_TOOLS = Object.freeze([
37
37
  'explore',
38
38
  'apply_patch',
39
39
  'Skill',
40
- 'tool_search',
40
+ 'load_tool',
41
41
  ]);
42
42
  export const DEFERRED_DEFAULT_READONLY_TOOLS = Object.freeze([
43
43
  'read',
@@ -48,7 +48,7 @@ export const DEFERRED_DEFAULT_READONLY_TOOLS = Object.freeze([
48
48
  'list',
49
49
  'explore',
50
50
  'Skill',
51
- 'tool_search',
51
+ 'load_tool',
52
52
  ]);
53
53
  export const DEFERRED_DEFAULT_LEAD_TOOLS = Object.freeze([
54
54
  'read',
@@ -68,7 +68,7 @@ export const DEFERRED_DEFAULT_LEAD_TOOLS = Object.freeze([
68
68
  'cwd',
69
69
  'session_manage',
70
70
  'Skill',
71
- 'tool_search',
71
+ 'load_tool',
72
72
  ]);
73
73
  const READONLY_TOOL_NAMES = new Set([
74
74
  'read',
@@ -120,7 +120,7 @@ export function toolSchemaBucket(tool) {
120
120
  if (['shell', 'apply_patch'].includes(name)) return 'mutation';
121
121
  if (name === 'agent' || name === 'delegate') return 'agents';
122
122
  if (name.includes('channel') || name.includes('discord') || name.includes('webhook')) return 'channels';
123
- if (name.includes('provider') || name === 'tool_search' || name === 'cwd') return 'setup';
123
+ if (name.includes('provider') || name === 'load_tool' || name === 'tool_search' || name === 'cwd') return 'setup';
124
124
  return 'other';
125
125
  }
126
126
 
@@ -227,6 +227,15 @@ export function compactToolSearchDescription(value, max = 220) {
227
227
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
228
228
  }
229
229
 
230
+ // Late-announcement lines stay skill-manifest shaped but much tighter than the
231
+ // BP1 pool (first sentence, hard 80-char cap): the reminder is transient
232
+ // discovery only — the full description/schema arrives when the tool loads.
233
+ function lateAnnouncementDescription(value) {
234
+ const text = clean(value).replace(/\s+/g, ' ');
235
+ const sentence = text.split(/(?<=[.!?])\s+/, 1)[0] || text;
236
+ return sentence.length > 80 ? `${sentence.slice(0, 79)}…` : sentence;
237
+ }
238
+
230
239
  export function toolRow(tool, activeNames = new Set()) {
231
240
  const name = clean(tool?.name);
232
241
  return {
@@ -345,6 +354,33 @@ export function deferredPoolToolNames(session) {
345
354
  return sortedNamesByMeasuredUsage(out);
346
355
  }
347
356
 
357
+ // Union of the boot-frozen deferred catalog and the late-connected MCP catalog.
358
+ // The boot catalog (session.deferredToolCatalog) is what the Anthropic providers
359
+ // serialize as defer_loading tools, so it MUST stay byte-identical after boot;
360
+ // tools whose MCP servers connected after boot live in
361
+ // session.deferredLateToolCatalog and are merged in ONLY for lookup/selection
362
+ // (never for provider serialization) so the request tools param — and its cache
363
+ // hash — is unchanged until a late tool is actually loaded.
364
+ export function deferredCatalogUnion(session) {
365
+ const boot = Array.isArray(session?.deferredToolCatalog) ? session.deferredToolCatalog : [];
366
+ const late = Array.isArray(session?.deferredLateToolCatalog) ? session.deferredLateToolCatalog : [];
367
+ if (!late.length) return boot;
368
+ const byName = new Map();
369
+ // On a same-name collision (a boot MCP tool whose server reconnected with a
370
+ // possibly fresher schema also lives in the late pool) prefer the LATE entry
371
+ // for lookup/load resolution. The boot-catalog ARRAY itself is never mutated,
372
+ // so provider defer_loading serialization stays byte-identical.
373
+ for (const tool of boot) {
374
+ const name = clean(tool?.name);
375
+ if (name && !byName.has(name)) byName.set(name, tool);
376
+ }
377
+ for (const tool of late) {
378
+ const name = clean(tool?.name);
379
+ if (name) byName.set(name, tool);
380
+ }
381
+ return [...byName.values()];
382
+ }
383
+
348
384
  export function isReadonlySelectable(tool) {
349
385
  const name = clean(tool?.name);
350
386
  if (READONLY_TOOL_NAMES.has(name)) return true;
@@ -400,13 +436,188 @@ export function applyDeferredToolSurface(session, mode, extraTools = [], options
400
436
  }
401
437
  applyInitialDeferredToolManifestToBp1(session, deferredPoolToolNames(session));
402
438
  }
439
+ if (!Array.isArray(session.deferredAnnouncedTools) && session.deferredToolBp1Applied) {
440
+ // Seed the announced set with everything already advertised in the BP1
441
+ // manifest, so the turn-boundary MCP delta (reconcileDeferredMcpToolCatalog)
442
+ // only announces genuinely new, late-connecting tools and never re-announces
443
+ // the startup pool.
444
+ session.deferredAnnouncedTools = deferredPoolToolNames(session);
445
+ }
403
446
  return session;
404
447
  }
405
448
 
449
+ /**
450
+ * Turn-boundary reconciliation (Codex-style snapshot + CC-style delta).
451
+ * Merge currently-connected MCP tools into session.deferredLateToolCatalog (a
452
+ * SEPARATE pool from the boot-frozen session.deferredToolCatalog) so tools from
453
+ * servers that finished their handshake AFTER this session was created become
454
+ * reachable (deferred-call-through / load_tool resolve against the union of both
455
+ * catalogs and auto-load them on first direct call). The boot catalog — the only
456
+ * one the Anthropic providers serialize as defer_loading tools — is never
457
+ * touched, so the tools request parameter (and its cache hash) is byte-identical
458
+ * until a late tool is actually loaded (promoted onto session.tools).
459
+ * When the late pool gains MCP names not yet advertised, deliver ONE persistent
460
+ * <system-reminder> through the pending-message queue (options.enqueue) so it
461
+ * rides inside the next real user turn; if that queue is unreachable, fall back
462
+ * to a tail append ONLY when the transcript tail is an assistant turn, else defer
463
+ * the announcement (names stay un-announced and are retried next turn). No filler
464
+ * assistant messages are ever appended.
465
+ * A disconnected server's unloaded tools leave the late pool; a loaded (active)
466
+ * tool stays on session.tools, is never announced as removed, and is re-linked to
467
+ * the fresh server tool on reconnect.
468
+ * Returns the announced names, or null when nothing was announced.
469
+ */
470
+ export function reconcileDeferredMcpToolCatalog(session, liveMcpTools, options = {}) {
471
+ if (!session || !Array.isArray(session.messages)) return null;
472
+ if (session.deferredProviderMode === 'full') return null;
473
+ const isMcp = (name) => typeof name === 'string' && name.startsWith('mcp__');
474
+ const live = Array.isArray(liveMcpTools) ? liveMcpTools : [];
475
+ const lateCatalog = Array.isArray(session.deferredLateToolCatalog) ? session.deferredLateToolCatalog : [];
476
+ const active = new Set((session.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
477
+
478
+ const liveMcpByName = new Map();
479
+ for (const tool of live) {
480
+ const name = clean(tool?.name);
481
+ if (!name || !isMcp(name) || liveMcpByName.has(name)) continue;
482
+ liveMcpByName.set(name, activeToolForSurface(tool));
483
+ }
484
+
485
+ // Rebuild the LATE pool only (boot catalog stays frozen). It holds live MCP
486
+ // tools — INCLUDING ones whose name also exists in the boot catalog, so a
487
+ // reconnect's fresher schema is reachable via deferredCatalogUnion (which
488
+ // prefers the late entry). Keep an entry only while its server is still
489
+ // connected OR the tool is already loaded (active). A disconnected server's
490
+ // unloaded tool drops out; a loaded one stays on session.tools.
491
+ const nextByName = new Map();
492
+ for (const tool of lateCatalog) {
493
+ const name = clean(tool?.name);
494
+ if (!name || nextByName.has(name)) continue;
495
+ if (!liveMcpByName.has(name) && !active.has(name)) continue;
496
+ nextByName.set(name, tool);
497
+ }
498
+ for (const [name, tool] of liveMcpByName) nextByName.set(name, tool);
499
+ session.deferredLateToolCatalog = sortedCatalogByMeasuredUsage([...nextByName.values()]);
500
+
501
+ // A promoted (active) MCP tool survives its server disconnecting; on reconnect
502
+ // re-link the active session.tools entry to the fresh server tool so its
503
+ // schema/handler track the live connection. Swap ONLY when the serialized
504
+ // surface actually changed, so a steady reconnect never perturbs the tools
505
+ // request param or its cache hash.
506
+ if (Array.isArray(session.tools)) {
507
+ for (let i = 0; i < session.tools.length; i += 1) {
508
+ const name = clean(session.tools[i]?.name);
509
+ if (!name || !isMcp(name) || !liveMcpByName.has(name)) continue;
510
+ const freshTool = liveMcpByName.get(name);
511
+ if (JSON.stringify(freshTool) !== JSON.stringify(session.tools[i])) session.tools[i] = freshTool;
512
+ }
513
+ }
514
+
515
+ const announced = new Set(Array.isArray(session.deferredAnnouncedTools) ? session.deferredAnnouncedTools : []);
516
+ // Commit-based dedupe: a late tool counts as announced only once its reminder
517
+ // actually LANDED in session.messages (the tail-append path lands immediately;
518
+ // the enqueue path lands when the pending queue drains into the next user
519
+ // turn). Fold those committed names in now and persist them so a later
520
+ // transcript trim can never resurrect an already-delivered announcement. A
521
+ // crash BETWEEN enqueue and drain leaves the name uncommitted, so the next
522
+ // reconcile re-announces instead of silently dropping it; the scan is
523
+ // idempotent, so a double-drain never double-marks.
524
+ const lateMcpNames = session.deferredLateToolCatalog
525
+ .map((tool) => clean(tool?.name))
526
+ .filter((name) => name && isMcp(name));
527
+ let announcedChanged = false;
528
+ for (const name of committedAnnouncedLateTools(session, lateMcpNames)) {
529
+ if (!announced.has(name)) { announced.add(name); announcedChanged = true; }
530
+ }
531
+ if (announcedChanged) session.deferredAnnouncedTools = sortedNamesByMeasuredUsage([...announced]);
532
+ const fresh = [];
533
+ for (const tool of session.deferredLateToolCatalog) {
534
+ const name = clean(tool?.name);
535
+ if (!name || !isMcp(name)) continue;
536
+ if (active.has(name) || announced.has(name)) continue;
537
+ fresh.push({ name, description: lateAnnouncementDescription(tool?.description) });
538
+ }
539
+ if (!fresh.length) return null;
540
+
541
+ const manifest = buildDeferredToolManifest(fresh);
542
+ if (!manifest) return null;
543
+ const reminder = `<system-reminder>\nTools from MCP servers that ${LATE_TOOL_ANNOUNCEMENT_SENTINEL} are now available. Call any tool listed below directly by name; it loads on first use.\n\n${manifest}\n</system-reminder>`;
544
+ const delivery = deliverDeferredAnnouncement(session, reminder, options);
545
+ if (!delivery) return null;
546
+ // Only the tail-append path commits into session.messages synchronously, so
547
+ // mark it announced now. Enqueued reminders are marked on the next reconcile's
548
+ // transcript scan (post-drain) — never at enqueue time (crash-safety above).
549
+ if (delivery === 'committed') {
550
+ for (const entry of fresh) announced.add(entry.name);
551
+ session.deferredAnnouncedTools = sortedNamesByMeasuredUsage([...announced]);
552
+ }
553
+ session.updatedAt = Date.now();
554
+ return fresh.map((entry) => entry.name);
555
+ }
556
+
557
+ // Sentinel phrase carried in every late-tool reminder; used to recognise a
558
+ // delivered (committed) announcement inside session.messages regardless of
559
+ // whether the pending drain merged it with other queued user content.
560
+ // Single source of truth lives in session-text.mjs (imported above) so the
561
+ // hide-from-UI detection can never drift from the emitted reminder text.
562
+ const LATE_TOOL_REMINDER_SENTINEL = LATE_TOOL_ANNOUNCEMENT_SENTINEL;
563
+
564
+ function reminderMessageText(content) {
565
+ if (typeof content === 'string') return content;
566
+ if (Array.isArray(content)) {
567
+ return content.map((b) => (typeof b === 'string' ? b : String(b?.text || ''))).join('\n');
568
+ }
569
+ return '';
570
+ }
571
+
572
+ // Late-tool names whose reminder is already present in session.messages (either
573
+ // tail-appended directly or drained in from the pending queue). Idempotent.
574
+ function committedAnnouncedLateTools(session, lateNames) {
575
+ const names = Array.isArray(lateNames) ? lateNames.filter(Boolean) : [];
576
+ if (!names.length) return [];
577
+ const msgs = Array.isArray(session?.messages) ? session.messages : [];
578
+ const blob = msgs
579
+ .filter((m) => m && m.role === 'user')
580
+ .map((m) => reminderMessageText(m.content))
581
+ .filter((t) => t.includes(LATE_TOOL_REMINDER_SENTINEL))
582
+ .join('\n');
583
+ if (!blob) return [];
584
+ return names.filter((name) => blob.includes(name));
585
+ }
586
+
587
+ // Deliver the late-tool <system-reminder> without any '.' filler turn. Primary
588
+ // path: the pending-message queue (options.enqueue) — the SAME mechanism that
589
+ // carries tool-completion notifications. The queue drains AFTER the current
590
+ // turn's assistant terminal response (ask-session.mjs), so the reminder rides a
591
+ // fresh follow-up user turn that always follows an assistant turn; strict
592
+ // role-alternation holds and no wire-level same-role merge is needed (the
593
+ // Anthropic/OpenAI lowering never merges two plain user turns, and none occurs
594
+ // here). Returns 'enqueued' (commit deferred to drain) on that path.
595
+ // Fallback (queue unreachable, e.g. unit tests): append a user reminder ONLY
596
+ // when the transcript tail is an assistant turn — that commits synchronously
597
+ // ('committed'); otherwise defer to the next turn (null).
598
+ function deliverDeferredAnnouncement(session, reminder, options) {
599
+ const enqueue = typeof options?.enqueue === 'function' ? options.enqueue : null;
600
+ if (enqueue) {
601
+ try { if (enqueue(reminder) === true) return 'enqueued'; }
602
+ catch { /* fall through to the tail-append fallback */ }
603
+ }
604
+ const messages = session.messages;
605
+ const tail = messages[messages.length - 1];
606
+ if (tail && tail.role === 'assistant') {
607
+ messages.push({ role: 'user', meta: 'hook', content: reminder });
608
+ return 'committed';
609
+ }
610
+ return null;
611
+ }
612
+
406
613
  export function selectDeferredTools(session, names, mode, options = {}) {
407
614
  const promoteToActive = options?.promoteToActive === true;
408
- const catalog = Array.isArray(session?.deferredToolCatalog)
409
- ? session.deferredToolCatalog
615
+ // Resolve against the union of the boot-frozen catalog and the late-connected
616
+ // MCP catalog so load_tool can load a late tool; loading promotes it onto
617
+ // session.tools where the providers serialize it as a real (non-deferred) tool.
618
+ const union = deferredCatalogUnion(session);
619
+ const catalog = union.length
620
+ ? union
410
621
  : (Array.isArray(session?.tools) ? session.tools : []);
411
622
  const active = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
412
623
  const native = session?.deferredProviderMode === 'native' || session?.deferredNativeTools === true;
@@ -459,44 +670,101 @@ export function selectDeferredTools(session, names, mode, options = {}) {
459
670
  return { added, already, blocked, missing, native };
460
671
  }
461
672
 
462
- export function renderToolSearch(args = {}, session, mode = 'full') {
673
+ // Collect the exact deferred-tool names to load, honoring back-compat inputs:
674
+ // names[] → primary loader input
675
+ // select / "select:" → legacy alias (parseToolSelection strips the prefix)
676
+ // query "select:a,b" → legacy query-side loader
677
+ // A free-text `query` is NOT a search anymore: it yields no names, and the
678
+ // caller is steered back to names[].
679
+ export function parseLoadToolNames(args = {}) {
680
+ const fromNames = parseToolSelection(args.names);
681
+ if (fromNames.length) return fromNames;
682
+ const fromSelect = parseToolSelection(args.select);
683
+ if (fromSelect.length) return fromSelect;
684
+ return parseToolSearchQuerySelection(args.query);
685
+ }
686
+
687
+ // Split live MCP servers into "still connecting" (pending) and "failed" so the
688
+ // loader can tell the model to retry next turn instead of treating a missing
689
+ // tool as a permanent zero result. `mcpStatus` is a getter plumbed from the
690
+ // runtime; absent in unit tests, where these lists are simply empty.
691
+ function pendingAndFailedMcpServers(mcpStatus) {
692
+ const empty = { pending: [], failed: [] };
693
+ let status = null;
694
+ try {
695
+ status = typeof mcpStatus === 'function' ? mcpStatus() : mcpStatus;
696
+ } catch {
697
+ return empty;
698
+ }
699
+ const servers = Array.isArray(status?.servers) ? status.servers : [];
700
+ const pending = [];
701
+ const failed = [];
702
+ for (const row of servers) {
703
+ const name = clean(row?.name);
704
+ if (!name) continue;
705
+ if (row?.status === 'disconnected') pending.push(name);
706
+ else if (row?.status === 'failed') failed.push(name);
707
+ }
708
+ return { pending: [...new Set(pending)].sort(), failed: [...new Set(failed)].sort() };
709
+ }
710
+
711
+ // Pure loader (formerly a keyword search). Input is exact deferred-tool
712
+ // names/aliases; output reports loaded / already-active / missing / blocked
713
+ // tools PLUS pending/failed MCP servers. No listing, no ranking, no substring
714
+ // filter. `options.mcpStatus` is the runtime getter for per-server status.
715
+ export function renderToolSearch(args = {}, session, mode = 'full', options = {}) {
463
716
  const catalog = Array.isArray(session?.deferredToolCatalog)
464
717
  ? session.deferredToolCatalog
465
718
  : (Array.isArray(session?.tools) ? session.tools : []);
466
- const rawQuery = clean(args.query);
467
- const explicitSelectedNames = parseToolSelection(args.select);
468
- const querySelectedNames = explicitSelectedNames.length ? [] : parseToolSearchQuerySelection(rawQuery);
469
- const forcedSelectedNames = explicitSelectedNames.length ? explicitSelectedNames : querySelectedNames;
470
- const query = querySelectedNames.length ? '' : rawQuery.toLowerCase();
471
- const limit = Math.max(1, Math.min(50, Number(args.limit) || 20));
472
- // Explicit loader only: select names/aliases (or query "select:a,b") load
473
- // deferred tools. A plain query is a case-insensitive substring filter over
474
- // name+description for listing it never loads. No scores, no ranking, no
475
- // category auto-load: deferred tools are called directly (they auto-promote
476
- // via deferred call-through) and are advertised in the system-prompt manifest.
477
- const toolSelection = forcedSelectedNames.length
478
- ? selectDeferredTools(session, forcedSelectedNames, mode)
479
- : null;
480
- const selectionMode = forcedSelectedNames.length ? 'select' : null;
719
+ const requestedNames = parseLoadToolNames(args);
720
+ const { pending: pendingMcpServers, failed: failedMcpServers } = pendingAndFailedMcpServers(options?.mcpStatus);
721
+ const mcpFields = {
722
+ ...(pendingMcpServers.length ? { pendingMcpServers } : {}),
723
+ ...(failedMcpServers.length ? { failedMcpServers } : {}),
724
+ };
725
+
726
+ if (!requestedNames.length) {
727
+ const strayQuery = clean(args.query || args.q || args.text);
728
+ return JSON.stringify({
729
+ error: strayQuery
730
+ ? `load_tool is a loader, not a search: "${strayQuery}" is not an exact tool name. Pass names:["exact_tool_name", ...] (deferred tool names/aliases). No keyword search.`
731
+ : 'load_tool requires names:["exact_tool_name", ...] (deferred tool names/aliases).',
732
+ loaded: [],
733
+ alreadyActive: [],
734
+ missing: [],
735
+ ...mcpFields,
736
+ activeTools: sortedNamesByMeasuredUsage((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean)),
737
+ discoveredTools: sortedNamesByMeasuredUsage(session?.deferredDiscoveredTools || []),
738
+ }, null, 2);
739
+ }
740
+
741
+ const toolSelection = selectDeferredTools(session, requestedNames, mode);
481
742
  const nextActiveNames = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
482
- const rows = catalog.map((tool) => toolRow(tool, nextActiveNames)).filter((row) => row.name);
483
- const matches = query ? rows.filter((row) => toolSearchMatches(row, query)) : rows;
484
- const selected = toolSelection
485
- ? {
486
- mode: selectionMode,
487
- tools: toolSelection,
488
- }
489
- : null;
490
- const nativeToolSearch = toolSelection?.native
491
- ? toolSearchNativePayload(catalog, [...new Set([...toolSelection.added, ...(toolSelection.already || [])])], session?.provider)
743
+ const loaded = toolSelection.added || [];
744
+ const alreadyActive = toolSelection.already || [];
745
+ const missing = toolSelection.missing || [];
746
+ const blocked = toolSelection.blocked || [];
747
+ const nativeToolSearch = toolSelection.native
748
+ ? toolSearchNativePayload(catalog, [...new Set([...loaded, ...alreadyActive])], session?.provider)
492
749
  : null;
750
+ const notes = [];
751
+ if (missing.length && pendingMcpServers.length) {
752
+ notes.push('Some requested names may belong to an MCP server still connecting — retry next turn.');
753
+ }
754
+ if (missing.length && failedMcpServers.length) {
755
+ notes.push('Some requested names may belong to a failed MCP server; those tools are unavailable.');
756
+ }
493
757
  return JSON.stringify({
494
- selected,
758
+ // `selected` retained for back-compat consumers (mode is always 'select').
759
+ selected: { mode: 'select', tools: toolSelection },
495
760
  ...(nativeToolSearch ? { nativeToolSearch } : {}),
496
- totalMatches: matches.length,
497
- matches: matches.slice(0, limit),
761
+ loaded,
762
+ alreadyActive,
763
+ missing,
764
+ ...(blocked.length ? { blocked } : {}),
765
+ ...mcpFields,
498
766
  activeTools: sortedNamesByMeasuredUsage(nextActiveNames),
499
767
  discoveredTools: sortedNamesByMeasuredUsage(session?.deferredDiscoveredTools || []),
500
- note: 'Deferred tools listed in the system-prompt manifest can be called directly by name (they auto-load on first call). This tool only lists (query = case-insensitive substring over name+description; never loads) or loads exact tools when you pass select names/aliases or query "select:a,b".',
768
+ ...(notes.length ? { note: notes.join(' ') } : {}),
501
769
  }, null, 2);
502
770
  }
@@ -3,23 +3,23 @@
3
3
  // from mixdog-session-runtime.mjs; no runtime closure dependencies.
4
4
 
5
5
  export const TOOL_SEARCH_TOOL = {
6
- name: 'tool_search',
7
- title: 'Tool Search',
6
+ name: 'load_tool',
7
+ title: 'load_tool',
8
8
  annotations: {
9
- title: 'Tool Search',
9
+ title: 'load_tool',
10
10
  readOnlyHint: true,
11
11
  destructiveHint: false,
12
12
  idempotentHint: true,
13
13
  openWorldHint: false,
14
14
  agentHidden: true,
15
15
  },
16
- description: 'List deferred tools by name+description substring, or load exact tools via select. Deferred tools can also be called directly by name.',
16
+ description: 'load_tool: pure loader for deferred tools. Pass names:["exact_tool_name", ...] (deferred tool names/aliases) to load them; returns loaded / already-active / missing plus any MCP servers still connecting (retry next turn) or failed. No keyword search, no ranking, no listing. Deferred tools can also be called directly by name (they auto-load on first call).',
17
17
  inputSchema: {
18
18
  type: 'object',
19
19
  properties: {
20
- query: { type: 'string', description: 'Case-insensitive substring filter over deferred tool name+description (lists only, no load). Use select:a,b to load exact names.' },
21
- select: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], description: 'Force exact tool names.' },
22
- limit: { type: 'number', description: 'Max matches.' },
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 "select:a,b").' },
22
+ query: { type: 'string', description: 'Legacy: only "select:a,b" is honored (mapped to names); free-text keyword queries are rejected — this tool does not search.' },
23
23
  },
24
24
  additionalProperties: false,
25
25
  },
@@ -101,6 +101,7 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
101
101
  name: clean(fm.name) || id,
102
102
  description: clean(fm.description),
103
103
  entry,
104
+ hidden: String(fm.hidden ?? '').trim().toLowerCase() === 'true',
104
105
  agentsConfigured,
105
106
  agents: agentsConfigured
106
107
  ? String(fm.agents || '')
@@ -124,7 +125,7 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
124
125
  const d = join(root, entry.name);
125
126
  if (!existsSync(join(d, 'WORKFLOW.md'))) continue;
126
127
  const pack = readWorkflowPackFromDir(d, source, entry.name);
127
- if (pack) byId.set(pack.id, pack);
128
+ if (pack && !pack.hidden) byId.set(pack.id, pack);
128
129
  }
129
130
  }
130
131
  return [...byId.values()].sort((a, b) => {