mixdog 0.9.10 → 0.9.12

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 (40) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-tag-reuse-smoke.mjs +12 -8
  3. package/scripts/bench/cache-probe-tasks.json +8 -0
  4. package/scripts/tool-smoke.mjs +109 -0
  5. package/src/defaults/mixdog-config.template.json +1 -0
  6. package/src/mixdog-session-runtime.mjs +46 -0
  7. package/src/runtime/agent/orchestrator/config.mjs +30 -0
  8. package/src/runtime/agent/orchestrator/context/collect.mjs +160 -0
  9. package/src/runtime/agent/orchestrator/mcp/client.mjs +25 -1
  10. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +24 -9
  11. package/src/runtime/agent/orchestrator/providers/gemini.mjs +18 -2
  12. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -0
  13. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +79 -0
  14. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
  15. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +9 -2
  16. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -3
  17. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +5 -5
  18. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +1 -22
  19. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +26 -3
  20. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +29 -1
  21. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +17 -16
  22. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -1
  23. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +4 -0
  24. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +30 -4
  25. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +333 -14
  26. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +129 -2
  27. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +58 -18
  28. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +56 -83
  29. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +26 -56
  30. package/src/runtime/channels/lib/webhook/deliveries.mjs +4 -1
  31. package/src/session-runtime/settings-api.mjs +13 -0
  32. package/src/session-runtime/tool-catalog.mjs +35 -7
  33. package/src/session-runtime/tool-defs.mjs +28 -0
  34. package/src/standalone/agent-tool/render.mjs +2 -0
  35. package/src/standalone/agent-tool.mjs +28 -6
  36. package/src/standalone/memory-runtime-proxy.mjs +85 -21
  37. package/src/tui/App.jsx +20 -1
  38. package/src/tui/app/extension-pickers.mjs +6 -3
  39. package/src/tui/dist/index.mjs +63 -16
  40. package/src/tui/engine.mjs +57 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -5,6 +5,7 @@
5
5
  // 1) live + idle -> spawn reuses the existing session (reused:true, send path)
6
6
  // 2) live + busy -> spawn queues the prompt instead of throwing
7
7
  // 3) lingering terminal trace (no live session) -> spawn/send error, no respawn
8
+ // 3) lingering terminal trace -> spawn reaps tag; dead-tag send auto-respawns
8
9
  // 4) genuinely new tag -> a fresh spawn (no reused/respawned flag)
9
10
  //
10
11
  // No network: a fake askSession drives status transitions. Uses the REAL
@@ -150,19 +151,22 @@ async function main() {
150
151
  holdBusy = false;
151
152
  await waitJob(busyStart, /ack: stay busy please/, 'busy worker finish');
152
153
 
153
- // --- tier 3: terminal trace only -> coldRespawn keeps the tag ---
154
- // --- tier 3: lingering trace without live session must not cold-respawn ---
154
+ // --- tier 3: terminal trace -> reap and reuse tag (spawn) or auto-respawn (send) ---
155
155
  closeSession(sid, 'smoke-session-closed-trace');
156
156
  const traceSpawnOut = await agent.execute({
157
- type: 'spawn', agent: 'reviewer', tag: 'reviewerA', prompt: 'must not respawn', cwd: root,
157
+ type: 'spawn', agent: 'reviewer', tag: 'reviewerA', prompt: 'trace reap respawn', cwd: root,
158
158
  }, ctx);
159
- assert(/^Error:/.test(traceSpawnOut), `terminal-trace spawn must error, got: ${traceSpawnOut}`);
160
- assert(/finished or closed worker/i.test(traceSpawnOut), `spawn error should mention trace: ${traceSpawnOut}`);
159
+ assert(!/^Error:/.test(traceSpawnOut), `terminal-trace spawn must reap and reuse tag: ${traceSpawnOut}`);
160
+ await waitJob(traceSpawnOut, /ack: trace reap respawn/, 'trace reap spawn');
161
+ const traceLive = await waitSessionForTag('reviewerA', 'trace reap spawn live');
162
+ closeSession(traceLive.id, 'smoke-kill-for-send-respawn');
161
163
  const traceSendOut = await agent.execute({
162
- type: 'send', tag: 'reviewerA', message: 'must not cold-respawn', cwd: root,
164
+ type: 'send', tag: 'reviewerA', message: 'auto-respawn follow-up', cwd: root,
163
165
  }, ctx);
164
- assert(/^Error:/.test(traceSendOut), `terminal-trace send must error, got: ${traceSendOut}`);
165
- assert(/not found|closed/i.test(traceSendOut), `send should surface prepareSend failure: ${traceSendOut}`);
166
+ assert(!/^Error:/.test(traceSendOut), `dead-tag send must auto-respawn: ${traceSendOut}`);
167
+ assert(/respawned: true/.test(traceSendOut), `send auto-respawn must flag respawned: ${traceSendOut}`);
168
+ assert(!/reused: true/.test(traceSendOut), `dead-tag send must not be a live reuse: ${traceSendOut}`);
169
+ await waitJob(traceSendOut, /ack: auto-respawn follow-up/, 'trace send respawn');
166
170
 
167
171
  // agent close clears the trace; same tag can spawn fresh again
168
172
  await agent.execute({ type: 'close', tag: 'reviewerA' }, ctx);
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "id": "cache-probe-multiturn",
4
+ "agent": "worker",
5
+ "cwd": "C:/Project/mixdog",
6
+ "prompt": "Investigation only, no edits. Step by step, one file per turn: 1) read README.md 2) read package.json 3) read scripts/smoke.mjs first 40 lines 4) read src/cli.mjs first 40 lines 5) read scripts/build-tui.mjs first 40 lines 6) read scripts/boot-smoke.mjs first 40 lines. After each read, state one sentence about the file. Finally give a 3-line synthesis. Use exactly one read tool call per step, sequentially."
7
+ }
8
+ ]
@@ -345,6 +345,23 @@ const grepOut = await executeBuiltinTool('grep', {
345
345
  }, root);
346
346
  assertOk('grep', grepOut, /smoke\.mjs/);
347
347
 
348
+ const grepBracketPathOut = await executeBuiltinTool('grep', {
349
+ pattern: 'tool-smoke',
350
+ path: '[]',
351
+ glob: '*.mjs',
352
+ head_limit: 5,
353
+ }, root);
354
+ assertOk('grep path [] coerces to cwd', grepBracketPathOut, /tool-smoke\.mjs/);
355
+
356
+ const grepRedirectOut = await executeBuiltinTool('grep', {
357
+ pattern: 'assertOk',
358
+ path: 'bogus/wrong/prefix/scripts/tool-smoke.mjs',
359
+ head_limit: 3,
360
+ }, root);
361
+ if (!/^\[redirected from/.test(grepRedirectOut) || !/assertOk/.test(grepRedirectOut)) {
362
+ throw new Error(`grep ENOENT should auto-redirect on unique suffix hit:\n${grepRedirectOut.slice(0, 800)}`);
363
+ }
364
+
348
365
  const redundantAllFilesGlobGrepOut = await executeBuiltinTool('grep', {
349
366
  pattern: 'standalone mixdog CLI/TUI coding agent',
350
367
  glob: '**/*',
@@ -367,6 +384,98 @@ const explicitSrcGlobOut = await executeBuiltinTool('glob', {
367
384
  }, root);
368
385
  assertOk('glob explicit src', explicitSrcGlobOut, /src[\\/].*engine\.mjs/i);
369
386
 
387
+ const globPathOnlyOut = await executeBuiltinTool('glob', {
388
+ path: 'scripts',
389
+ head_limit: 8,
390
+ }, root);
391
+ assertOk('glob path-only default *', globPathOnlyOut, /tool-smoke\.mjs/i);
392
+
393
+ const grepNoPatternGlobOut = await executeBuiltinTool('grep', {
394
+ path: 'scripts',
395
+ glob: 'tool-smoke.mjs',
396
+ head_limit: 5,
397
+ }, root);
398
+ assertOk('grep without pattern routes to glob', grepNoPatternGlobOut, /tool-smoke\.mjs/i);
399
+
400
+ const grepManyPatterns = [
401
+ ...Array.from({ length: 20 }, (_, i) => `__tool_smoke_miss_${i}__`),
402
+ 'tool-smoke',
403
+ ];
404
+ const grepManyPatternsOut = await executeBuiltinTool('grep', {
405
+ pattern: grepManyPatterns,
406
+ path: 'scripts',
407
+ glob: '*.mjs',
408
+ head_limit: 5,
409
+ }, root);
410
+ if (/exceeds the 20-pattern cap/i.test(String(grepManyPatternsOut))) {
411
+ throw new Error(`grep should chunk >20 patterns instead of error:\n${grepManyPatternsOut.slice(0, 400)}`);
412
+ }
413
+ assertOk('grep >20 pattern chunk merge', grepManyPatternsOut, /tool-smoke\.mjs/i);
414
+ if (!/\[pattern set split into 2 chunks\]/.test(String(grepManyPatternsOut))) {
415
+ throw new Error(`grep >20 patterns should emit chunk advisory:\n${grepManyPatternsOut.slice(0, 400)}`);
416
+ }
417
+
418
+ function grepCountTotalMatches(body) {
419
+ const m = String(body).match(/\[total (\d+) match/i);
420
+ return m ? Number(m[1]) : null;
421
+ }
422
+
423
+ const grepCountSingleOut = await executeBuiltinTool('grep', {
424
+ pattern: 'assertOk',
425
+ path: 'scripts/tool-smoke.mjs',
426
+ output_mode: 'count',
427
+ }, root);
428
+ const singleCountTotal = grepCountTotalMatches(grepCountSingleOut);
429
+ if (singleCountTotal == null || singleCountTotal < 1) {
430
+ throw new Error(`grep count baseline failed:\n${grepCountSingleOut.slice(0, 400)}`);
431
+ }
432
+
433
+ const grepCountOverlapPatterns = [
434
+ ...Array.from({ length: 19 }, (_, i) => `__count_overlap_a_${i}__`),
435
+ 'assertOk',
436
+ ...Array.from({ length: 19 }, (_, i) => `__count_overlap_b_${i}__`),
437
+ 'assertOk',
438
+ ];
439
+ const grepCountOverlapOut = await executeBuiltinTool('grep', {
440
+ pattern: grepCountOverlapPatterns,
441
+ path: 'scripts/tool-smoke.mjs',
442
+ output_mode: 'count',
443
+ }, root);
444
+ const overlapCountTotal = grepCountTotalMatches(grepCountOverlapOut);
445
+ if (overlapCountTotal !== singleCountTotal) {
446
+ throw new Error(
447
+ `chunked count must not double-count overlapping lines (single=${singleCountTotal} overlap=${overlapCountTotal}):\n${grepCountOverlapOut.slice(0, 600)}`,
448
+ );
449
+ }
450
+
451
+ const grepChunkContextPatterns = [
452
+ ...Array.from({ length: 20 }, (_, i) => `__ctx_chunk_miss_${i}__`),
453
+ 'grepCountTotalMatches',
454
+ ];
455
+ const grepChunkContextOut = await executeBuiltinTool('grep', {
456
+ pattern: grepChunkContextPatterns,
457
+ path: 'scripts/tool-smoke.mjs',
458
+ '-C': 1,
459
+ head_limit: 30,
460
+ }, root);
461
+ if (!/\[pattern set split into 2 chunks\]/.test(String(grepChunkContextOut))) {
462
+ throw new Error(`chunked -C grep should split patterns:\n${grepChunkContextOut.slice(0, 500)}`);
463
+ }
464
+ if (!/tool-smoke\.mjs:\d+:/.test(String(grepChunkContextOut))) {
465
+ throw new Error(`chunked -C must emit path-prefixed match lines:\n${grepChunkContextOut.slice(0, 800)}`);
466
+ }
467
+ if (!/tool-smoke\.mjs-\d+-/.test(String(grepChunkContextOut))) {
468
+ throw new Error(`chunked -C must keep path-prefixed context lines:\n${grepChunkContextOut.slice(0, 800)}`);
469
+ }
470
+ const ctxBodyLines = String(grepChunkContextOut).split('\n').filter((l) => l && !/^\[/.test(l) && !/^\(no matches\)/.test(l));
471
+ const orphanLineOnlyContext = ctxBodyLines.some((l) => /^\d+-/.test(l));
472
+ if (orphanLineOnlyContext) {
473
+ throw new Error(`chunked -C must not leave line-only context orphans:\n${grepChunkContextOut.slice(0, 800)}`);
474
+ }
475
+ if (!/function grepCountTotalMatches/.test(String(grepChunkContextOut))) {
476
+ throw new Error(`chunked -C should include match span:\n${grepChunkContextOut.slice(0, 800)}`);
477
+ }
478
+
370
479
  const findOut = await executeBuiltinTool('find', {
371
480
  query: 'tool smoke',
372
481
  path: '.',
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "outputStyle": "default",
3
+ "mcpServers": {},
3
4
  "channels": {
4
5
  "promptInjection": {
5
6
  "mode": "hook",
@@ -216,6 +216,7 @@ import {
216
216
  TOOL_SEARCH_TOOL,
217
217
  CWD_TOOL,
218
218
  SKILL_TOOL,
219
+ SESSION_MANAGE_TOOL,
219
220
  LEAD_DISALLOWED_TOOLS,
220
221
  applyStandaloneToolDefaults,
221
222
  } from './session-runtime/tool-defs.mjs';
@@ -480,6 +481,9 @@ export async function createMixdogSessionRuntime({
480
481
  let sessionCreatePromise = null;
481
482
  let currentCwd = cwd;
482
483
  let sessionNeedsCwdRefresh = false;
484
+ // session_manage tool: reset request scheduled by the model mid-turn,
485
+ // consumed by the TUI engine at turn end ('clear' | 'compact_clear').
486
+ let pendingSessionReset = null;
483
487
  let closeRequested = false;
484
488
  const warmupTimers = {
485
489
  providerSetupWarmupTimer: null,
@@ -818,6 +822,10 @@ export async function createMixdogSessionRuntime({
818
822
  }
819
823
 
820
824
  function skillToolContent(name) {
825
+ if (typeof contextMod.isSkillDisabled === 'function' && contextMod.isSkillDisabled(name)) {
826
+ const label = String(name || '').trim() || 'skill';
827
+ return `Error: skill "${label}" is disabled`;
828
+ }
821
829
  const skill = skillContent(name);
822
830
  // Return the general tool envelope so the main/Lead session behaves the
823
831
  // same as agent-loop sessions: the model-visible tool_result is the short
@@ -918,6 +926,7 @@ export async function createMixdogSessionRuntime({
918
926
  TOOL_SEARCH_TOOL,
919
927
  SKILL_TOOL,
920
928
  CWD_TOOL,
929
+ SESSION_MANAGE_TOOL,
921
930
  EXPLORE_TOOL,
922
931
  ...(searchToolDefs?.TOOL_DEFS || []).filter((tool) => tool?.name === 'search' || tool?.name === 'web_fetch'),
923
932
  ...(memoryToolDefs?.TOOL_DEFS || []).filter((tool) => tool?.name === 'recall' || tool?.name === 'memory'),
@@ -1014,6 +1023,29 @@ export async function createMixdogSessionRuntime({
1014
1023
  }
1015
1024
  return JSON.stringify({ cwd: currentCwd, sessionId: session?.id || null }, null, 2);
1016
1025
  }
1026
+ if (name === 'session_manage') {
1027
+ // Lead/owner sessions only: an agent worker resetting its own
1028
+ // transcript mid-task would corrupt the delegation contract, and it
1029
+ // must never reach the owner conversation either.
1030
+ const callerSessionId = callerCtx?.callerSessionId || null;
1031
+ if (callerSessionId && session?.id && callerSessionId !== session.id) {
1032
+ throw new Error('session_manage: only the lead session may reset the conversation');
1033
+ }
1034
+ if (!session?.id) throw new Error('session_manage: no active session');
1035
+ const action = clean(args?.action).toLowerCase();
1036
+ if (action !== 'clear' && action !== 'compact_clear') {
1037
+ throw new Error(`session_manage: unknown action "${action}" (use clear | compact_clear)`);
1038
+ }
1039
+ // Never clear mid-turn — the loop is still reading the transcript.
1040
+ // Schedule and let the TUI engine consume it at turn end (same
1041
+ // boundary the idle auto-clear uses).
1042
+ // Pin to the current session id so a resume/new-session between
1043
+ // scheduling and consumption can never clear the wrong conversation.
1044
+ pendingSessionReset = { action, sessionId: session.id };
1045
+ return action === 'clear'
1046
+ ? 'Session reset scheduled: full clear will run when this turn ends. All prior context will be gone.'
1047
+ : 'Session reset scheduled: the conversation will be summarized (compact) and cleared when this turn ends; key context carries forward in the summary.';
1048
+ }
1017
1049
  if (name === 'Skill') {
1018
1050
  return skillToolContent(args?.name);
1019
1051
  }
@@ -1596,6 +1628,9 @@ export async function createMixdogSessionRuntime({
1596
1628
  // Channels are opt-in: only boot the worker when this session started in (or
1597
1629
  // was toggled into) remote mode. Non-remote sessions never contend for the
1598
1630
  // channel; see startRemote()/stopRemote() and the `/remote` toggle.
1631
+ // `remote.autoStart` in mixdog-config.json makes every session claim remote
1632
+ // at boot (same force-takeover semantics as `mixdog --remote` / `/remote`).
1633
+ if (!remoteEnabled && config?.remote?.autoStart === true) remoteEnabled = true;
1599
1634
  if (remoteEnabled) startRemote();
1600
1635
 
1601
1636
  function contextStatusCacheKeyFor({ messages, tools }) {
@@ -1657,6 +1692,7 @@ export async function createMixdogSessionRuntime({
1657
1692
  getRoute: () => route,
1658
1693
  getSession: () => session,
1659
1694
  getRemoteEnabled: () => remoteEnabled,
1695
+ adoptConfig,
1660
1696
  saveConfigAndAdopt,
1661
1697
  scheduleBackendSave,
1662
1698
  cfgMod,
@@ -2510,6 +2546,16 @@ export async function createMixdogSessionRuntime({
2510
2546
  invalidateContextStatusCache();
2511
2547
  return true;
2512
2548
  },
2549
+ // session_manage tool handoff: the engine polls this at turn end and, if
2550
+ // set, runs the same clear path the idle auto-clear uses. One-shot read.
2551
+ consumePendingSessionReset() {
2552
+ const pending = pendingSessionReset;
2553
+ pendingSessionReset = null;
2554
+ if (!pending) return null;
2555
+ // Session changed since scheduling (resume / new session) — drop it.
2556
+ if (!session?.id || pending.sessionId !== session.id) return null;
2557
+ return pending.action;
2558
+ },
2513
2559
  async compact(options = {}) {
2514
2560
  if (!session?.id) return null;
2515
2561
  if (activeTurnCount > 0) {
@@ -81,6 +81,16 @@ export function normalizeProfileConfig(value = {}) {
81
81
  return { title, language };
82
82
  }
83
83
 
84
+ /** Persisted `skills.disabled` name list (deduped, trimmed, sorted). */
85
+ export function normalizeSkillsConfig(value = {}) {
86
+ const raw = value && typeof value === 'object' ? value : {};
87
+ const disabled = Array.isArray(raw.disabled)
88
+ ? [...new Set(raw.disabled.map((n) => String(n).trim()).filter(Boolean))]
89
+ : [];
90
+ disabled.sort((a, b) => a.localeCompare(b));
91
+ return { disabled };
92
+ }
93
+
84
94
  // Look up the catalog entry for a stored language id (defaults to 'system').
85
95
  export function profileLanguageEntry(languageId) {
86
96
  const id = String(languageId || 'system');
@@ -435,6 +445,7 @@ export function loadConfig(options = {}) {
435
445
  workflow: raw.workflow && typeof raw.workflow === 'object' ? { active: String(raw.workflow.active || 'default') } : { active: 'default' },
436
446
  agentMaintenance: { enabled: true, interval: '1h', ...raw.agentMaintenance },
437
447
  profile: normalizeProfileConfig(raw.profile),
448
+ skills: normalizeSkillsConfig(raw.skills),
438
449
  // No idleMs default here: absent idleMs means "provider default"
439
450
  // (config-helpers normalizeAutoClearConfig custom:false path).
440
451
  // Injecting a fixed 1h would mark every config custom:true and
@@ -467,6 +478,7 @@ export function loadConfig(options = {}) {
467
478
  workflow: { active: 'default' },
468
479
  agentMaintenance: { enabled: true, interval: '1h' },
469
480
  profile: normalizeProfileConfig(null),
481
+ skills: normalizeSkillsConfig(null),
470
482
  autoClear: { enabled: true },
471
483
  compaction: {},
472
484
  trajectory: { enabled: true },
@@ -493,6 +505,23 @@ export function loadConfig(options = {}) {
493
505
  * via persistAgentConfig((current) => ({ ...current, <field> })) so a
494
506
  * concurrent instance's edits are not reverted.
495
507
  */
508
+ /** In-lock patch of `skills.disabled` only (avoids whole-config lost-update). */
509
+ export function patchSkillsDisabled(disabledNames) {
510
+ const names = disabledNames instanceof Set
511
+ ? [...disabledNames]
512
+ : (Array.isArray(disabledNames) ? disabledNames : []);
513
+ const nextSkills = normalizeSkillsConfig({ disabled: names });
514
+ persistAgentConfig((current) => {
515
+ const cur = { ...current };
516
+ const target = (cur.agent && cur.agent.providers)
517
+ ? (cur.agent = { ...cur.agent })
518
+ : cur;
519
+ target.skills = nextSkills;
520
+ return cur;
521
+ });
522
+ return nextSkills;
523
+ }
524
+
496
525
  export function saveConfig(config) {
497
526
  // Strip ephemeral defaults from providers but preserve any unknown
498
527
  // per-provider subkey so future schema additions round-trip through the
@@ -544,6 +573,7 @@ export function saveConfig(config) {
544
573
  workflow: config.workflow || { active: 'default' },
545
574
  agentMaintenance: config.agentMaintenance || {},
546
575
  profile: normalizeProfileConfig(config.profile),
576
+ skills: normalizeSkillsConfig(config.skills),
547
577
  autoClear: config.autoClear || {},
548
578
  compaction: config.compaction || {},
549
579
  trajectory: config.trajectory || {},
@@ -7,6 +7,7 @@ import {
7
7
  parseMarkdownFrontmatter,
8
8
  readMarkdownDocument,
9
9
  } from '../../../shared/markdown-frontmatter.mjs';
10
+ import { loadConfig, normalizeSkillsConfig } from '../config.mjs';
10
11
 
11
12
  // --- mixdog asset roots (standalone CLI owns its own paths; never .claude) ---
12
13
  // Project-local: <cwd>/.mixdog/<kind>
@@ -123,6 +124,37 @@ export function collectSkills(cwd) {
123
124
  }
124
125
  return skills;
125
126
  }
127
+
128
+ function normalizeSkillNameKey(name) {
129
+ return String(name || '').trim().toLowerCase();
130
+ }
131
+
132
+ export function getDisabledSkillNameSet(config = null) {
133
+ const cfg = config || loadConfig({ secrets: false });
134
+ const keys = normalizeSkillsConfig(cfg.skills).disabled
135
+ .map((n) => normalizeSkillNameKey(n))
136
+ .filter(Boolean);
137
+ return new Set(keys);
138
+ }
139
+
140
+ export function isSkillDisabled(name, config = null) {
141
+ const n = normalizeSkillNameKey(name);
142
+ if (!n) return false;
143
+ return getDisabledSkillNameSet(config).has(n);
144
+ }
145
+
146
+ export function filterSkillsExcludingDisabled(skills, config = null) {
147
+ const disabled = getDisabledSkillNameSet(config);
148
+ if (!disabled.size) return Array.isArray(skills) ? skills : [];
149
+ return (Array.isArray(skills) ? skills : []).filter((s) => {
150
+ const key = normalizeSkillNameKey(s?.name);
151
+ return key && !disabled.has(key);
152
+ });
153
+ }
154
+
155
+ export function collectPromptSkillsCached(cwd, config = null) {
156
+ return filterSkillsExcludingDisabled(collectSkillsCached(cwd), config);
157
+ }
126
158
  // --- Skill cache (mtime-based, keyed by cwd) ---
127
159
  const _skillsCache = new Map();
128
160
  const _mtimeCache = new Map();
@@ -267,6 +299,130 @@ export function buildSkillManifest(skills, { limit = 80 } = {}) {
267
299
  return lines.join('\n');
268
300
  }
269
301
 
302
+ const DEFERRED_TOOLS_BLOCK_RE = /(\n\n---\n*)?<available-deferred-tools>[\s\S]*?<\/available-deferred-tools>\s*/gi;
303
+ const MCP_INSTRUCTIONS_BLOCK_RE = /(\n\n---\n*)?<mcp-instructions>[\s\S]*?<\/mcp-instructions>\s*/gi;
304
+ const DEFERRED_TOOL_NAME_SAFE_RE = /^[A-Za-z0-9_.:-]+$/;
305
+ const MCP_SERVER_NAME_SAFE_RE = /^[A-Za-z0-9_.:-]+$/;
306
+ const MCP_INSTRUCTION_MAX_CHARS = 600;
307
+
308
+ function sanitizeDeferredToolManifestName(name) {
309
+ const text = String(name || '').trim();
310
+ if (!text || text.includes('<') || text.includes('>')) return '';
311
+ if (!DEFERRED_TOOL_NAME_SAFE_RE.test(text)) return '';
312
+ return text;
313
+ }
314
+
315
+ export function bp1HasDeferredToolManifestBlock(text) {
316
+ const raw = String(text || '');
317
+ return /<available-deferred-tools>[\s\S]*?<\/available-deferred-tools>/i.test(raw)
318
+ || /<mcp-instructions>[\s\S]*?<\/mcp-instructions>/i.test(raw);
319
+ }
320
+
321
+ /**
322
+ * Names-only manifest for tools in the deferred pool (catalog minus active wire tools).
323
+ * Empty pool → '' (caller omits the block).
324
+ */
325
+ export function buildDeferredToolManifest(names) {
326
+ const list = [...new Set((Array.isArray(names) ? names : [])
327
+ .map((name) => sanitizeDeferredToolManifestName(name))
328
+ .filter(Boolean))]
329
+ .sort((a, b) => a.localeCompare(b));
330
+ if (!list.length) return '';
331
+ return ['<available-deferred-tools>', ...list, '</available-deferred-tools>'].join('\n');
332
+ }
333
+
334
+ function sanitizeMcpManifestServerName(name) {
335
+ const text = String(name || '').trim();
336
+ if (!text || text.includes('<') || text.includes('>')) return '';
337
+ if (!MCP_SERVER_NAME_SAFE_RE.test(text)) return '';
338
+ return text;
339
+ }
340
+
341
+ function sanitizeMcpInstructionText(text, max = MCP_INSTRUCTION_MAX_CHARS) {
342
+ const stripped = String(text || '').replace(/[<>]/g, '').trim();
343
+ if (!stripped) return '';
344
+ const cap = Math.max(1, Number(max) || MCP_INSTRUCTION_MAX_CHARS);
345
+ return stripped.length > cap ? `${stripped.slice(0, Math.max(1, cap - 3))}...` : stripped;
346
+ }
347
+
348
+ /**
349
+ * Per-server MCP initialize instructions for deferred-pool tools only.
350
+ * Empty when no instructions or no matching deferred MCP tools → omit block.
351
+ */
352
+ export function buildMcpInstructionsManifest(mcpServerInstructions, poolNames) {
353
+ const map = mcpServerInstructions && typeof mcpServerInstructions === 'object'
354
+ ? mcpServerInstructions
355
+ : {};
356
+ const pool = [...new Set((Array.isArray(poolNames) ? poolNames : [])
357
+ .map((name) => sanitizeDeferredToolManifestName(name))
358
+ .filter(Boolean))];
359
+ const toolsByServer = new Map();
360
+ for (const name of pool) {
361
+ const match = name.match(/^mcp__(.+?)__(.+)$/);
362
+ if (!match) continue;
363
+ const server = match[1];
364
+ if (!toolsByServer.has(server)) toolsByServer.set(server, []);
365
+ toolsByServer.get(server).push(name);
366
+ }
367
+ const servers = [...toolsByServer.keys()]
368
+ .filter((server) => sanitizeMcpManifestServerName(server)
369
+ && sanitizeMcpInstructionText(map[server])
370
+ && toolsByServer.get(server).length)
371
+ .sort((a, b) => a.localeCompare(b));
372
+ if (!servers.length) return '';
373
+ const lines = ['<mcp-instructions>'];
374
+ for (const server of servers) {
375
+ const safeServer = sanitizeMcpManifestServerName(server);
376
+ const body = sanitizeMcpInstructionText(map[server]);
377
+ const tools = [...toolsByServer.get(server)].sort((a, b) => a.localeCompare(b));
378
+ lines.push(`## ${safeServer}`, body, ...tools.map((tool) => `- ${tool}`));
379
+ }
380
+ lines.push('</mcp-instructions>');
381
+ return lines.join('\n');
382
+ }
383
+
384
+ export function stripDeferredToolManifestBlock(text) {
385
+ return String(text || '')
386
+ .replace(DEFERRED_TOOLS_BLOCK_RE, '')
387
+ .replace(MCP_INSTRUCTIONS_BLOCK_RE, '')
388
+ .replace(/\n\n---\n*$/,'')
389
+ .trimEnd();
390
+ }
391
+
392
+ /** Inject names-only deferred pool into BP1 once at session start; never rewrite BP1 after. */
393
+ export function applyInitialDeferredToolManifestToBp1(session, poolNames) {
394
+ if (!session || !Array.isArray(session.messages) || session.deferredToolBp1Applied) return false;
395
+ const pool = Array.isArray(poolNames) ? poolNames : [];
396
+ const parts = [];
397
+ const deferredManifest = buildDeferredToolManifest(pool);
398
+ if (deferredManifest) parts.push(deferredManifest);
399
+ const mcpManifest = buildMcpInstructionsManifest(session.mcpServerInstructions, pool);
400
+ if (mcpManifest) parts.push(mcpManifest);
401
+ const manifest = parts.join('\n\n');
402
+ let idx = -1;
403
+ for (let i = 0; i < session.messages.length; i++) {
404
+ if (session.messages[i]?.role === 'system') {
405
+ idx = i;
406
+ break;
407
+ }
408
+ }
409
+ if (idx === -1) return false;
410
+ const raw = typeof session.messages[idx].content === 'string' ? session.messages[idx].content : '';
411
+ if (bp1HasDeferredToolManifestBlock(raw)) {
412
+ session.deferredToolBp1Applied = true;
413
+ return true;
414
+ }
415
+ if (manifest) {
416
+ const base = stripDeferredToolManifestBlock(raw);
417
+ session.messages[idx].content = base
418
+ ? `${base}\n\n---\n\n${manifest}`
419
+ : manifest;
420
+ }
421
+ session.deferredToolBp1Applied = true;
422
+ session.updatedAt = Date.now();
423
+ return true;
424
+ }
425
+
270
426
  /**
271
427
  * Build the fixed skill loader meta-tool.
272
428
  * A tiny stable schema keeps provider cache keys steady; concrete skill
@@ -558,6 +714,10 @@ export function composeSystemPrompt(opts) {
558
714
  if (!_skip.skills && opts.skillManifest && typeof opts.skillManifest === 'string' && opts.skillManifest.trim()) {
559
715
  baseParts.push(opts.skillManifest.trim());
560
716
  }
717
+ // deferredToolManifest: optional BP1 slice; production path is applyInitialDeferredToolManifestToBp1 once after applyDeferredToolSurface.
718
+ if (opts.deferredToolManifest && typeof opts.deferredToolManifest === 'string' && opts.deferredToolManifest.trim()) {
719
+ baseParts.push(opts.deferredToolManifest.trim());
720
+ }
561
721
  const baseRules = baseParts.join('\n\n---\n\n');
562
722
 
563
723
  // ── BP2: role/system layer ─────────────────────────────────────────
@@ -147,6 +147,16 @@ export function getMcpServerStatus() {
147
147
  })(),
148
148
  }));
149
149
  }
150
+
151
+ /** Snapshot of MCP initialize `instructions` per connected server (handshake time). */
152
+ export function getMcpServerInstructionsMap() {
153
+ const out = {};
154
+ for (const server of servers.values()) {
155
+ const text = typeof server.instructions === 'string' ? server.instructions.trim() : '';
156
+ if (text) out[server.name] = text;
157
+ }
158
+ return out;
159
+ }
150
160
  /**
151
161
  * Execute an MCP tool call.
152
162
  * Name format: `mcp__{serverName}__{toolName}`
@@ -290,6 +300,16 @@ function capMcpOutput(content) {
290
300
  export function isMcpTool(name) {
291
301
  return name.startsWith('mcp__');
292
302
  }
303
+ /** True when the prefixed name exists on a connected MCP server. */
304
+ export function isRegisteredMcpTool(name) {
305
+ if (!isMcpTool(name)) return false;
306
+ const match = name.match(/^mcp__(.+?)__(.+)$/);
307
+ if (!match) return false;
308
+ const [, serverName] = match;
309
+ const server = servers.get(serverName);
310
+ if (!server || !Array.isArray(server.tools)) return false;
311
+ return server.tools.some((t) => t?.name === name);
312
+ }
293
313
  /**
294
314
  * Check whether the inputSchema for an MCP tool declares the given top-level
295
315
  * property. Used to decide if the orchestrator should auto-inject context
@@ -416,6 +436,10 @@ async function connectServer(name, cfg) {
416
436
  throw new Error(`Invalid config for "${name}": need autoDetect, type (stdio/http/sse/ws), url (http), or command (stdio)`);
417
437
  }
418
438
  await client.connect(transport);
439
+ const instructionsRaw = typeof client.getInstructions === 'function'
440
+ ? client.getInstructions()
441
+ : undefined;
442
+ const instructions = typeof instructionsRaw === 'string' ? instructionsRaw.trim() : '';
419
443
  const toolsResult = await client.listTools();
420
444
  if (!toolsResult || !Array.isArray(toolsResult.tools)) {
421
445
  throw new Error(`[mcp-client] ListTools returned invalid shape for "${name}": missing or non-array tools field`);
@@ -427,7 +451,7 @@ async function connectServer(name, cfg) {
427
451
  ...(t.annotations && typeof t.annotations === 'object' ? { annotations: t.annotations } : {}),
428
452
  }));
429
453
  const toolNames = tools.map(t => t.name);
430
- servers.set(name, { name, client, transport, tools, cfg });
454
+ servers.set(name, { name, client, transport, tools, cfg, instructions });
431
455
  _invalidateMcpToolFieldMemo();
432
456
  mcpLog(`[mcp] connected: ${tools.length} tools — ${toolNames.join(', ')}\n`);
433
457
  }
@@ -425,6 +425,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
425
425
  try {
426
426
  armFirstByteTimer();
427
427
  resetIdleTimer();
428
+ var collectedChunks = [];
428
429
  while (true) {
429
430
  if (idleTimedOut) {
430
431
  throw geminiTimeoutError(`${label} SSE idle`, PROVIDER_SSE_IDLE_TIMEOUT_MS);
@@ -463,6 +464,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
463
464
  clearFirstByteTimer();
464
465
  }
465
466
  resetIdleTimer();
467
+ if (step.value) collectedChunks.push(step.value);
466
468
  try { onStreamDelta?.(); } catch {}
467
469
  if (onTextDelta || textLeakGuard) {
468
470
  const t = geminiChunkText(step.value);
@@ -488,17 +490,30 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
488
490
  try { textLeakGuard?.finalize(); } catch {}
489
491
  }
490
492
 
491
- let response;
492
- try {
493
- response = await streamResult.response;
494
- } catch (err) {
495
- if (signal?.aborted) {
496
- const reason = signal.reason;
497
- throw reason instanceof Error ? reason : new Error(`${label} aborted`);
493
+ // Aggregate the raw wire chunks locally instead of awaiting the SDK's
494
+ // streamResult.response: @google/generative-ai 0.24.1 aggregateResponses()
495
+ // predates Gemini 3 thinking and silently drops part.thoughtSignature.
496
+ // Losing the signature breaks the mandatory echo-back on the next turn
497
+ // (400 "Function call is missing a thought_signature in functionCall
498
+ // parts"). aggregateGeminiStreamChunks() preserves it (see part copy
499
+ // above). Fall back to the SDK aggregate only if we somehow collected no
500
+ // usable chunks.
501
+ let raw;
502
+ if (collectedChunks.length > 0) {
503
+ raw = aggregateGeminiStreamChunks(collectedChunks);
504
+ } else {
505
+ let response;
506
+ try {
507
+ response = await streamResult.response;
508
+ } catch (err) {
509
+ if (signal?.aborted) {
510
+ const reason = signal.reason;
511
+ throw reason instanceof Error ? reason : new Error(`${label} aborted`);
512
+ }
513
+ throw err;
498
514
  }
499
- throw err;
515
+ raw = response?.candidates ? response : (response?.response || response);
500
516
  }
501
- const raw = response?.candidates ? response : (response?.response || response);
502
517
  const finishReason = raw?.candidates?.[0]?.finishReason || null;
503
518
  assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label });
504
519
  return raw;