mixdog 0.9.23 → 0.9.25

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 (89) hide show
  1. package/package.json +2 -1
  2. package/scripts/boot-smoke.mjs +1 -1
  3. package/scripts/channel-daemon-smoke.mjs +327 -9
  4. package/scripts/channel-daemon-stub.mjs +12 -1
  5. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  6. package/scripts/explore-bench-tmp.mjs +17 -0
  7. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  8. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  9. package/scripts/tool-smoke.mjs +38 -30
  10. package/src/defaults/cycle3-review-prompt.md +11 -4
  11. package/src/defaults/memory-promote-prompt.md +9 -0
  12. package/src/rules/agent/30-explorer.md +6 -0
  13. package/src/rules/shared/01-tool.md +11 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  15. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  17. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  20. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  21. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  22. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  23. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
  24. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  25. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  26. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  27. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  28. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  29. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  31. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  32. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
  34. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  35. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  36. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  37. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  38. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
  39. package/src/runtime/channels/backends/discord.mjs +10 -3
  40. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  41. package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
  42. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  43. package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
  44. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
  45. package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
  46. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
  47. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  48. package/src/runtime/channels/lib/worker-main.mjs +9 -28
  49. package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -1
  50. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
  51. package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
  52. package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
  53. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  54. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  55. package/src/runtime/memory/tool-defs.mjs +1 -1
  56. package/src/runtime/shared/atomic-file.mjs +130 -2
  57. package/src/runtime/shared/background-tasks.mjs +1 -1
  58. package/src/runtime/shared/config.mjs +53 -1
  59. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  60. package/src/runtime/shared/tool-surface.mjs +19 -0
  61. package/src/runtime/shared/update-checker.mjs +3 -0
  62. package/src/runtime/shared/user-data-guard.mjs +66 -0
  63. package/src/session-runtime/config-lifecycle.mjs +175 -15
  64. package/src/session-runtime/mcp-glue.mjs +30 -0
  65. package/src/session-runtime/runtime-core.mjs +91 -7
  66. package/src/session-runtime/session-turn-api.mjs +42 -16
  67. package/src/session-runtime/tool-catalog.mjs +44 -0
  68. package/src/standalone/channel-admin.mjs +32 -3
  69. package/src/standalone/channel-daemon-client.mjs +3 -1
  70. package/src/standalone/channel-daemon-transport.mjs +202 -8
  71. package/src/standalone/channel-daemon.mjs +54 -17
  72. package/src/standalone/channel-worker.mjs +18 -7
  73. package/src/standalone/explore-tool.mjs +87 -15
  74. package/src/tui/App.jsx +2 -2
  75. package/src/tui/components/StatusLine.jsx +3 -3
  76. package/src/tui/components/ToolExecution.jsx +14 -2
  77. package/src/tui/components/TranscriptItem.jsx +1 -1
  78. package/src/tui/dist/index.mjs +246 -47
  79. package/src/tui/engine/agent-job-feed.mjs +47 -3
  80. package/src/tui/engine/notification-plan.mjs +5 -0
  81. package/src/tui/engine/session-api.mjs +6 -1
  82. package/src/tui/engine/tool-card-results.mjs +14 -5
  83. package/src/tui/engine/turn.mjs +9 -2
  84. package/src/tui/engine.mjs +31 -12
  85. package/src/ui/statusline-agents.mjs +36 -0
  86. package/src/ui/statusline.mjs +15 -5
  87. package/src/workflows/default/WORKFLOW.md +29 -38
  88. package/src/workflows/solo/WORKFLOW.md +15 -20
  89. package/src/runtime/channels/lib/seat-lock.mjs +0 -196
@@ -0,0 +1,149 @@
1
+ // Regression tests for turn-time deferred-manifest construction (claude-code
2
+ // style). An MCP server that finishes its handshake BETWEEN session-create and
3
+ // the user's FIRST send is folded — on the first turn, synchronously, with no
4
+ // boot await — into the INITIAL <available-deferred-tools> manifest (BP1) and
5
+ // pre-marked announced, so the first-turn tool-catalog reconcile emits NO late
6
+ // <system-reminder> for it. A server that connects AFTER the first turn keeps
7
+ // the append-only late-reminder path. Unit-style: exercise the tool-catalog
8
+ // exports directly (no runtime, no spawn) the way session-turn-api/runtime-core
9
+ // wire them.
10
+ import test from 'node:test';
11
+ import assert from 'node:assert/strict';
12
+ import {
13
+ applyDeferredToolSurface,
14
+ refreshInitialDeferredMcpSurface,
15
+ reconcileDeferredMcpToolCatalog,
16
+ } from '../src/session-runtime/tool-catalog.mjs';
17
+
18
+ function baseSession() {
19
+ return {
20
+ provider: 'legacy',
21
+ tools: [{ name: 'read', description: 'read a file' }, { name: 'grep', description: 'search' }],
22
+ messages: [{ role: 'system', content: 'BASE PROMPT' }],
23
+ };
24
+ }
25
+
26
+ // A freshly-created session: the create-time surface is baked WITHOUT any MCP
27
+ // (server still mid-handshake at create). `shell` is a deferred (non-active)
28
+ // standalone tool so BP1 carries a manifest block even before MCP arrives.
29
+ function createdSession() {
30
+ const session = baseSession();
31
+ applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }], { provider: 'legacy' });
32
+ return session;
33
+ }
34
+
35
+ function systemContent(session) {
36
+ return session.messages.find((m) => m.role === 'system').content;
37
+ }
38
+
39
+ const mcpTool = { name: 'mcp__unity__get_scene', description: 'Return the active Unity scene graph.' };
40
+ const mcpTool2 = { name: 'mcp__unity__run_tests', description: 'Run the Unity test runner.' };
41
+
42
+ // Mirror the session-turn-api first-turn gate: refresh runs ONCE, only for a
43
+ // session flagged fresh (deferredInitialRefreshPending) at create; a resumed
44
+ // session (reloaded transcript, flag absent) takes the late-reconcile path and
45
+ // its already-baked BP1 is never rebuilt or re-announced.
46
+ function firstTurnGate(session, liveMcp) {
47
+ if (session.deferredInitialRefreshPending) {
48
+ session.deferredInitialRefreshPending = false;
49
+ return refreshInitialDeferredMcpSurface(session, liveMcp) ? 'refreshed' : 'refresh-noop';
50
+ }
51
+ return 'late';
52
+ }
53
+
54
+ test('MCP connecting between session-create and first send lands in the INITIAL manifest, not a late reminder', () => {
55
+ const session = createdSession();
56
+ assert.ok(!systemContent(session).includes('mcp__unity__get_scene'), 'MCP absent from create-time BP1');
57
+
58
+ // First-turn refresh: the live registry now includes the connected server.
59
+ const changed = refreshInitialDeferredMcpSurface(session, [mcpTool]);
60
+ assert.equal(changed, true, 'first-turn refresh folded the newly-connected MCP tool');
61
+
62
+ const sys = systemContent(session);
63
+ assert.ok(sys.includes('<available-deferred-tools>'), 'manifest block present');
64
+ assert.ok(sys.includes('mcp__unity__get_scene'), 'MCP tool in the INITIAL manifest');
65
+ assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'MCP tool pre-marked announced');
66
+
67
+ let enqueued = null;
68
+ const result = reconcileDeferredMcpToolCatalog(session, [mcpTool], {
69
+ enqueue: (text) => { enqueued = text; return true; },
70
+ });
71
+ assert.equal(result, null, 'no late-tool announcement for a first-turn-folded tool');
72
+ assert.equal(enqueued, null, 'nothing enqueued for a first-turn-folded tool');
73
+ });
74
+
75
+ test('an MCP server connecting AFTER the first turn is still announced via the late reminder', () => {
76
+ const session = createdSession();
77
+ // First turn: no MCP connected yet — nothing to fold.
78
+ assert.equal(refreshInitialDeferredMcpSurface(session, []), false, 'no live MCP => first-turn no-op');
79
+ assert.ok(!(session.deferredAnnouncedTools || []).includes('mcp__unity__get_scene'), 'MCP not pre-announced');
80
+
81
+ // A later turn: the server has since connected → late path fires.
82
+ let enqueued = null;
83
+ const result = reconcileDeferredMcpToolCatalog(session, [mcpTool], {
84
+ enqueue: (text) => { enqueued = text; return true; },
85
+ });
86
+ assert.deepEqual(result, ['mcp__unity__get_scene'], 'late tool announced');
87
+ assert.ok(enqueued && enqueued.includes('mcp__unity__get_scene'), 'late reminder enqueued');
88
+ assert.ok(enqueued.includes('connected after this session started'), 'reminder carries the late-tool sentinel');
89
+ });
90
+
91
+ test('recreated session (MCP already connected at create) seeds its manifest, no late re-announce', () => {
92
+ // Recreate/reset path: createCurrentSession folds the live MCP tools into the
93
+ // surface at create time, so a cwd-change recreate seeds its BP1 directly and
94
+ // never needs the first-turn refresh.
95
+ const session = baseSession();
96
+ applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: 'legacy' });
97
+ assert.ok(systemContent(session).includes('mcp__unity__get_scene'), 'MCP in recreated BP1');
98
+ assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'recreated MCP pre-marked announced');
99
+
100
+ let enqueued = null;
101
+ const result = reconcileDeferredMcpToolCatalog(session, [mcpTool], {
102
+ enqueue: (text) => { enqueued = text; return true; },
103
+ });
104
+ assert.equal(result, null, 'no late announcement on recreate');
105
+ assert.equal(enqueued, null, 'nothing enqueued on recreate');
106
+ });
107
+
108
+ test('first-turn refresh is idempotent: a re-render with no new MCP is a no-op and keeps ONE manifest block', () => {
109
+ const session = createdSession();
110
+ assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool]), true, 'first fold applies');
111
+ const once = systemContent(session);
112
+ assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool]), false, 'no genuinely-new MCP => no-op');
113
+ const twice = systemContent(session);
114
+ assert.equal(once, twice, 'BP1 byte-identical on re-render');
115
+ assert.equal((twice.match(/<available-deferred-tools>/g) || []).length, 1, 'exactly one manifest block (no duplicate)');
116
+ });
117
+
118
+ test('a second newly-connected MCP tool re-renders BP1 in place (both listed, still one block)', () => {
119
+ const session = createdSession();
120
+ refreshInitialDeferredMcpSurface(session, [mcpTool]);
121
+ assert.equal(refreshInitialDeferredMcpSurface(session, [mcpTool, mcpTool2]), true, 're-fold applies the new tool');
122
+ const sys = systemContent(session);
123
+ assert.ok(sys.includes('mcp__unity__get_scene') && sys.includes('mcp__unity__run_tests'), 'both MCP tools listed');
124
+ assert.equal((sys.match(/<available-deferred-tools>/g) || []).length, 1, 'still exactly one block (rebuilt in place)');
125
+ assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__run_tests'), 'the new tool is pre-announced too');
126
+ });
127
+
128
+ test('a resumed session (no fresh flag, prior baked BP1) is NOT refreshed on its next turn', () => {
129
+ // A prior run baked BP1 with the MCP tool; resume reloads that transcript.
130
+ const session = baseSession();
131
+ applyDeferredToolSurface(session, 'full', [{ name: 'shell', description: 'run a command' }, mcpTool], { provider: 'legacy' });
132
+ const before = systemContent(session);
133
+ const announcedBefore = [...session.deferredAnnouncedTools];
134
+ // Resume path never sets the per-session fresh flag.
135
+ assert.equal(session.deferredInitialRefreshPending, undefined, 'resumed session carries no fresh flag');
136
+ const route = firstTurnGate(session, [mcpTool, mcpTool2]);
137
+ assert.equal(route, 'late', 'resumed session takes the late-reconcile path, not the initial refresh');
138
+ assert.equal(systemContent(session), before, 'BP1 untouched on resume (no rebuild)');
139
+ assert.deepEqual([...session.deferredAnnouncedTools], announcedBefore, 'announced set unchanged on resume');
140
+ });
141
+
142
+ test('a fresh session (flagged) is refreshed exactly once, then falls to the late path', () => {
143
+ const session = createdSession();
144
+ session.deferredInitialRefreshPending = true;
145
+ assert.equal(firstTurnGate(session, [mcpTool]), 'refreshed', 'fresh session refreshes on its first turn');
146
+ assert.equal(session.deferredInitialRefreshPending, false, 'fresh flag consumed (one-shot)');
147
+ assert.ok(session.deferredAnnouncedTools.includes('mcp__unity__get_scene'), 'first-turn MCP pre-announced');
148
+ assert.equal(firstTurnGate(session, [mcpTool2]), 'late', 'second turn no longer refreshes');
149
+ });
@@ -1050,32 +1050,28 @@ const prevChannelSingleton = process.env.MIXDOG_CHANNEL_SINGLETON;
1050
1050
  const prevChannelWorkerProcess = process.env.MIXDOG_CHANNEL_WORKER_PROCESS;
1051
1051
  const prevRuntimeRoot = process.env.MIXDOG_RUNTIME_ROOT;
1052
1052
  const prevEnvOut = process.env.SMOKE_CHANNEL_ENV_OUT;
1053
+ const prevDaemonEntry = process.env.MIXDOG_CHANNEL_DAEMON_ENTRY;
1053
1054
  try {
1054
- const entry = join(channelWorkerTmp, 'entry.mjs');
1055
+ // Daemon-mode worker env coverage: start() spawn-or-attaches the machine
1056
+ // -global daemon (the stub daemon entry — no Discord token) instead of
1057
+ // forking `entry`, so assert the flags on the SPAWNED DAEMON's env (the stub
1058
+ // dumps them to SMOKE_CHANNEL_ENV_OUT). The old fork-path env assertion died
1059
+ // with the fork path itself; full flip/attach coverage lives in
1060
+ // scripts/channel-daemon-smoke.mjs.
1061
+ const stubEntry = join(root, 'scripts', 'channel-daemon-stub.mjs');
1055
1062
  const dataDir = join(channelWorkerTmp, 'data');
1056
1063
  const runtimeDir = join(channelWorkerTmp, 'runtime');
1057
1064
  const envOut = join(channelWorkerTmp, 'env.json');
1058
1065
  mkdirSync(dataDir, { recursive: true });
1059
1066
  mkdirSync(runtimeDir, { recursive: true });
1060
- writeFileSync(entry, `
1061
- import { writeFileSync } from 'node:fs';
1062
- writeFileSync(process.env.SMOKE_CHANNEL_ENV_OUT, JSON.stringify({
1063
- cliOwned: process.env.MIXDOG_CLI_OWNED,
1064
- daemon: process.env.MIXDOG_CHANNEL_DAEMON,
1065
- }));
1066
- process.send?.({ type: 'ready' });
1067
- process.on('message', (msg) => {
1068
- if (msg?.type === 'shutdown') process.exit(0);
1069
- });
1070
- setInterval(() => {}, 10000);
1071
- `);
1072
1067
  process.env.MIXDOG_CHANNEL_DAEMON = '1';
1073
1068
  process.env.MIXDOG_CHANNEL_SINGLETON = '1';
1074
1069
  process.env.MIXDOG_CHANNEL_WORKER_PROCESS = '1';
1075
1070
  process.env.MIXDOG_RUNTIME_ROOT = runtimeDir;
1076
1071
  process.env.SMOKE_CHANNEL_ENV_OUT = envOut;
1072
+ process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = stubEntry;
1077
1073
  channelEnvWorker = createStandaloneChannelWorker({
1078
- entry,
1074
+ entry: stubEntry,
1079
1075
  rootDir: root,
1080
1076
  dataDir,
1081
1077
  cwd: root,
@@ -1100,6 +1096,11 @@ setInterval(() => {}, 10000);
1100
1096
  else process.env.MIXDOG_RUNTIME_ROOT = prevRuntimeRoot;
1101
1097
  if (prevEnvOut == null) delete process.env.SMOKE_CHANNEL_ENV_OUT;
1102
1098
  else process.env.SMOKE_CHANNEL_ENV_OUT = prevEnvOut;
1099
+ if (prevDaemonEntry == null) delete process.env.MIXDOG_CHANNEL_DAEMON_ENTRY;
1100
+ else process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = prevDaemonEntry;
1101
+ // Detach only ends OUR attachment; the stub daemon self-shuts after its
1102
+ // client-grace window. Give it that window before deleting its tmp root.
1103
+ await new Promise((resolveWait) => setTimeout(resolveWait, 700));
1103
1104
  rmSync(channelWorkerTmp, { recursive: true, force: true });
1104
1105
  }
1105
1106
 
@@ -1191,10 +1192,10 @@ if (EXPLORE_TOOL.annotations?.agentHidden === true) {
1191
1192
  }
1192
1193
  }
1193
1194
  const exploreProps = EXPLORE_TOOL.inputSchema?.properties || {};
1194
- if (!/Repo anchor locator/i.test(EXPLORE_TOOL.description || '') || !/broad\/uncertain/i.test(EXPLORE_TOOL.description || '') || !/independent targets/i.test(EXPLORE_TOOL.description || '') || (EXPLORE_TOOL.description || '').length > 90) {
1195
- throw new Error('explore description must stay compact and anchor-oriented');
1195
+ if (!/broad\/uncertain/i.test(EXPLORE_TOOL.description || '') || !/machine-wide/i.test(EXPLORE_TOOL.description || '') || !/independent targets/i.test(EXPLORE_TOOL.description || '') || (EXPLORE_TOOL.description || '').length > 600) {
1196
+ throw new Error('explore description must keep the locator + facet fan-out contract');
1196
1197
  }
1197
- if (!/Narrow locator query/i.test(exploreProps.query?.description || '') || !/independent targets/i.test(exploreProps.query?.description || '') || !/Project\/root/i.test(exploreProps.cwd?.description || '')) {
1198
+ if (!/Narrow locator query/i.test(exploreProps.query?.description || '') || !/independent facets/i.test(exploreProps.query?.description || '') || !/Project\/root/i.test(exploreProps.cwd?.description || '')) {
1198
1199
  throw new Error('explore schema must stay compact and preserve query/cwd shape');
1199
1200
  }
1200
1201
  const normalizedExplore = normalizeExploreQueries('["where is model selection?"," ","which file owns agent async?"]');
@@ -1412,7 +1413,11 @@ setInternalToolsProvider({
1412
1413
  const writeTools = (writeAgentSession.tools || []).map((tool) => tool?.name).filter(Boolean);
1413
1414
  const fullTools = (fullAgentSession.tools || []).map((tool) => tool?.name).filter(Boolean);
1414
1415
  const publicExploreTools = (publicExploreSession.tools || []).map((tool) => tool?.name).filter(Boolean);
1415
- const expectedReadTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'explore', 'search', 'web_fetch', 'Skill'];
1416
+ // Read-role AGENT sessions carry shell/task so review/debug agents can run
1417
+ // their own verification (build/test); the plain readonly preset (public
1418
+ // explore role) still omits them.
1419
+ const expectedReadTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'shell', 'task', 'explore', 'search', 'web_fetch', 'Skill'];
1420
+ const expectedPublicReadTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'explore', 'search', 'web_fetch', 'Skill'];
1416
1421
  const expectedWriteTools = ['code_graph', 'find', 'glob', 'list', 'grep', 'read', 'apply_patch', 'shell', 'task', 'explore', 'search', 'web_fetch', 'Skill'];
1417
1422
  if (JSON.stringify(readTools) !== JSON.stringify(expectedReadTools)) {
1418
1423
  throw new Error(`read agent schema must be fixed allow-list: expected=${expectedReadTools.join(', ')} actual=${readTools.join(', ')}`);
@@ -1423,12 +1428,15 @@ setInternalToolsProvider({
1423
1428
  if (readTools.includes('load_tool') || writeTools.includes('load_tool')) {
1424
1429
  throw new Error(`agent session fixed schemas must omit load_tool: read=${readTools.join(', ')} write=${writeTools.join(', ')}`);
1425
1430
  }
1426
- if (readTools.includes('shell')) {
1427
- throw new Error(`read agent schema must omit shell: read=${readTools.join(', ')}`);
1431
+ if (readTools.includes('apply_patch')) {
1432
+ throw new Error(`read agent schema must omit apply_patch: read=${readTools.join(', ')}`);
1428
1433
  }
1429
- for (const name of ['shell', 'apply_patch', 'task']) {
1430
- if (readTools.includes(name)) {
1431
- throw new Error(`read agent schema must omit non-read tool ${name}: read=${readTools.join(', ')}`);
1434
+ for (const name of ['shell', 'task']) {
1435
+ if (!readTools.includes(name)) {
1436
+ throw new Error(`read agent schema must carry verification tool ${name}: read=${readTools.join(', ')}`);
1437
+ }
1438
+ if (publicExploreTools.includes(name)) {
1439
+ throw new Error(`public explore role must omit ${name}: explore=${publicExploreTools.join(', ')}`);
1432
1440
  }
1433
1441
  }
1434
1442
  for (const name of ['apply_patch', 'shell', 'task']) {
@@ -1450,13 +1458,13 @@ setInternalToolsProvider({
1450
1458
  if (!fullTools.includes('explore')) {
1451
1459
  throw new Error(`full agent schema must expose explore: full=${fullTools.join(', ')}`);
1452
1460
  }
1453
- // Unified-shard policy (v0.9.13): the explore wrapper stays IN the schema
1454
- // for every read role including the explore agent itself — so the
1455
- // read-only bundle is bit-identical across roles (one cache group).
1456
- // Recursion is broken at call time in pre-dispatch-deny.mjs via
1457
- // recursiveWrapperToolNameForPublicAgent, not by schema stripping.
1458
- if (JSON.stringify(publicExploreTools) !== JSON.stringify(expectedReadTools)) {
1459
- throw new Error(`public explore role must ship the shared read-only bundle (incl. explore): expected=${expectedReadTools.join(', ')} actual=${publicExploreTools.join(', ')}`);
1461
+ // The explore wrapper stays IN the schema for every read role — including
1462
+ // the explore agent itself. Recursion is broken at call time in
1463
+ // pre-dispatch-deny.mjs via recursiveWrapperToolNameForPublicAgent, not by
1464
+ // schema stripping. (Read AGENT sessions add shell/task on top, so the
1465
+ // public explore bundle is its own cache group now.)
1466
+ if (JSON.stringify(publicExploreTools) !== JSON.stringify(expectedPublicReadTools)) {
1467
+ throw new Error(`public explore role must ship the readonly bundle (incl. explore): expected=${expectedPublicReadTools.join(', ')} actual=${publicExploreTools.join(', ')}`);
1460
1468
  }
1461
1469
  if (recursiveWrapperToolNameForPublicAgent('explore') !== 'explore') {
1462
1470
  throw new Error('call-time anti-recursion must map public explore agent to its own wrapper tool');
@@ -3,8 +3,10 @@
3
3
  You review user-curated CORE memory against the current project memory. Each
4
4
  entry is shown with its most-related current memory. Emit ONE verdict line per
5
5
  entry id. The system may conservatively apply safe compression updates and
6
- strict duplicate merges automatically; deletes and broad rewrites require
7
- explicit user confirmation. Extracting a lesson from a narrative is a BROAD
6
+ strict duplicate merges automatically, and now also auto-applies deletes you
7
+ tag as clear junk (see the delete reasons below), capped per run for safety;
8
+ non-junk or unreasoned deletes and broad rewrites still require explicit user
9
+ confirmation. Extracting a lesson from a narrative is a BROAD
8
10
  rewrite — emit it only as a proposal (proposal mode), never as an auto-applied
9
11
  conservative "safe compression" update; conservative mode must not lossy-rewrite
10
12
  user-curated core. The first character of your response is a digit.
@@ -64,7 +66,12 @@ or inconclusive, keep the CORE entry.
64
66
  so a CORE copy is redundant); OR is sourced from code, rules files, or skill
65
67
  docs, or is an implementation spec, constant, measurement, resolved-bug
66
68
  story, or status snapshot that fits no L1/L2/L3 layer. Do not delete an entry
67
- that adds durable specifics the rule itself does not state.
69
+ that adds durable specifics the rule itself does not state. ALWAYS append a
70
+ reason tag: `<id>|delete|<reason>` where `<reason>` is one of `duplicate`,
71
+ `default` (restates a built-in/default rule), `restatement`, `obsolete`,
72
+ `implemented`, `resolved`, `stale`, `past_event`, or `log`. A bare
73
+ `<id>|delete` with no reason, or any reason outside that set, is treated as
74
+ "needs confirmation" and only removed on an explicit APPLY CYCLE3 run.
68
75
 
69
76
  A verbose durable entry is always `update`, never `keep`.
70
77
  Delete is the rarest verdict. Prefer `keep` for durable rules/preferences and
@@ -97,7 +104,7 @@ One line per entry id, any order:
97
104
  <id>|update|<element>|<summary>
98
105
  <id>|merge|<target_id>|<source_ids_csv>
99
106
  <id>|superseded|<newer_id>
100
- <id>|delete
107
+ <id>|delete|<reason>
101
108
  ```
102
109
 
103
110
  `summary` ≤120 chars, one clause. No literal `|` or newline inside a field
@@ -112,6 +112,15 @@ A/B pending rows that encode lasting behavior or map anchors; transient
112
112
  When `Active > cap`, contract strictly: any active entry without a concrete
113
113
  A/B reason must archive.
114
114
 
115
+ **Structural block (enforced, not advisory).** Pending `task`/`issue` rows and
116
+ any row that reads as status/review churn or a benchmark/measurement result
117
+ snapshot are blocked from promotion by the pipeline itself — an `active` verdict
118
+ on them is dropped and the row is **held pending and re-judged on a later
119
+ cycle** (it is neither promoted nor force-archived here). Do NOT archive such a
120
+ row just because it cannot promote now: leave it to be re-confirmed, or promote
121
+ the durable L2 lesson distilled from it under a durable category. Durable
122
+ rules/preferences/constraints promote normally.
123
+
115
124
  If useful content is buried inside work narrative, keep only the durable L2
116
125
  behavior lesson (via `update` on an active row, or `active` for a pending row);
117
126
  archive the surrounding story.
@@ -22,6 +22,12 @@ synonyms, library/domain names), plus `find` with name fragments, plus `code_gra
22
22
  symbol_search when the query names an identifier. A single-pattern,
23
23
  single-tool first turn is a defect.
24
24
 
25
+ A turn-1 broad grep MUST set `output_mode:"files_with_matches"` — an
26
+ unscoped `content`/`content_with_context` scan reads every match body and is
27
+ a defect (a full-content scan across the tree costs seconds). Use
28
+ `content_with_context` ONLY against a `path` that appeared in an earlier
29
+ result THIS session, and always with `head_limit`.
30
+
25
31
  Search tokens are CODE tokens: first translate natural-language or
26
32
  non-English queries into probable English identifiers (e.g. "최대 루프 반복
27
33
  횟수" → maxLoop, loop-policy, iterations). Grep non-ASCII text only when
@@ -4,12 +4,19 @@
4
4
  prior result. Merge equivalent variants/scopes into ONE call wherever the
5
5
  schema accepts arrays (`pattern[]`, `path[]`, `symbols[]`, `query[]`).
6
6
  - Route by what is already known: known symbol/relation → `code_graph`;
7
- exact text in a known scope → `grep`; unknown location or concept-level
8
- question `explore`; name fragment → `find`; exact name pattern → `glob`;
9
- known directory → `list`; known file/region → `read`.
7
+ exact text in a known scope → `grep`; unknown location, machine-wide/
8
+ out-of-repo whereabouts, or concept-level question → `explore` (which uses
9
+ the hardened `find` internally); name fragment → `find`; exact name pattern
10
+ → `glob`; known directory → `list`; known file/region → `read`.
11
+ - `explore` fan-out: at task start, decompose what the task needs to know
12
+ into independent facets (implementation site, config/load path, tests,
13
+ error origin, ...) and send them as ONE `query[]` call — facets run in
14
+ parallel. Never fan out rephrasings of the same target; on
15
+ EXPLORATION_FAILED, retry once with changed tokens.
10
16
  - Valid anchors come from user input or tool output in this session; locate
11
17
  anything else with `find` before `grep`/`read`. On ENOENT the next call is
12
- `find` on the basename — never a retried guess.
18
+ `find` on the basename — never a retried guess; and never guess an absolute
19
+ path outside the project — `find` from a verified broad root instead.
13
20
  - Retrieval stops when evidence covers the deliverable: single-answer tasks
14
21
  end at the first sufficient anchor; enumeration tasks (review, audit) end
15
22
  when the stated scope is covered. Never re-verify a hit already on screen;
@@ -33,6 +33,62 @@ export function isAgentProgressWatchdogAbortError(err) {
33
33
  return typeof msg === 'string' && WATCHDOG_ABORT_RE.test(msg);
34
34
  }
35
35
 
36
+ // Tools that enforce their own execution deadline: 'shell' kills the process
37
+ // at its configured timeout; 'task' wait is bounded by its own wait budget.
38
+ // These are NOT blanket-exempted from the tool-running watchdog — if their own
39
+ // deadline timer dies the session would otherwise hang forever. Instead the
40
+ // watchdog raises the tool-running ceiling to their self-deadline + a grace
41
+ // window (below), so normal long runs are allowed but a dead timer is still
42
+ // caught. Unknown/missing self-deadline falls back to the plain toolRunningMs.
43
+ const SELF_DEADLINE_TOOLS = new Set(['shell', 'task']);
44
+ // Grace added on top of a tool's own deadline before the watchdog steps in, so
45
+ // the tool's in-process kill always fires first under normal operation.
46
+ const TOOL_SELF_DEADLINE_GRACE_MS = 60_000;
47
+ // Fallback deadlines matching the tool implementations (bash-tool.mjs default
48
+ // 120s foreground timeout; task/shell-job wait default 30s budget).
49
+ const SHELL_DEFAULT_TIMEOUT_MS = 120_000;
50
+ const TASK_DEFAULT_WAIT_MS = 30_000;
51
+
52
+ function bareToolName(toolName) {
53
+ if (typeof toolName !== 'string' || !toolName) return '';
54
+ // Strip any MCP/server prefix (e.g. 'server__shell' or 'server.shell').
55
+ return toolName.split(/[.]|__/).pop();
56
+ }
57
+
58
+ function isSelfDeadlineTool(toolName) {
59
+ const bare = bareToolName(toolName);
60
+ return SELF_DEADLINE_TOOLS.has(bare) || SELF_DEADLINE_TOOLS.has(toolName);
61
+ }
62
+
63
+ /**
64
+ * Resolve the self-enforced deadline (ms) for a tool call from its arguments,
65
+ * recorded into the progress snapshot at dispatch time. Returns a positive
66
+ * number when the tool enforces its own deadline, or null when unknown/missing
67
+ * (caller then falls back to the plain toolRunningMs ceiling).
68
+ * - shell: explicit `timeout` (ms) if positive, else the 120s default.
69
+ * - task: explicit `timeout_ms` if positive, else 30s for the `wait` action
70
+ * (other actions carry no wait budget -> null).
71
+ */
72
+ export function resolveToolSelfDeadlineMs(toolName, args) {
73
+ if (!isSelfDeadlineTool(toolName)) return null;
74
+ const bare = bareToolName(toolName);
75
+ const a = (args && typeof args === 'object') ? args : {};
76
+ if (bare === 'shell') {
77
+ const t = Number(a.timeout);
78
+ if (Number.isFinite(t) && t > 0) return t;
79
+ const envDefault = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
80
+ return envDefault > 0 ? envDefault : SHELL_DEFAULT_TIMEOUT_MS;
81
+ }
82
+ if (bare === 'task') {
83
+ const t = Number(a.timeout_ms);
84
+ if (Number.isFinite(t) && t > 0) return t;
85
+ const action = typeof a.action === 'string' ? a.action : '';
86
+ if (action === 'wait') return TASK_DEFAULT_WAIT_MS;
87
+ return null;
88
+ }
89
+ return null;
90
+ }
91
+
36
92
  function assistantMessageText(content) {
37
93
  if (typeof content === 'string') return content;
38
94
  if (!Array.isArray(content)) return '';
@@ -209,7 +265,26 @@ export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
209
265
  && policy.toolRunningMs > 0
210
266
  && now - snapshot.toolStartedAt > policy.toolRunningMs
211
267
  ) {
212
- return new AgentStallAbortError(`agent tool running stale (${policy.toolRunningMs}ms)`);
268
+ // Deadline-aware ceiling for tools that self-enforce their own deadline
269
+ // ('shell'/'task'). Rather than a blanket exemption (which would hang
270
+ // forever if the tool's own timer died), raise the tool-running ceiling
271
+ // to max(toolRunningMs, selfDeadlineMs + grace): normal long runs are
272
+ // allowed because the tool kills itself first, but a dead deadline timer
273
+ // is still caught after the grace window. Unknown/missing self-deadline
274
+ // (selfDeadlineMs <= 0) keeps the plain toolRunningMs behavior. All
275
+ // other tools are unaffected.
276
+ let ceilingMs = policy.toolRunningMs;
277
+ const selfDeadlineMs = Number(snapshot.toolSelfDeadlineMs);
278
+ if (
279
+ isSelfDeadlineTool(snapshot.currentTool)
280
+ && Number.isFinite(selfDeadlineMs)
281
+ && selfDeadlineMs > 0
282
+ ) {
283
+ ceilingMs = Math.max(policy.toolRunningMs, selfDeadlineMs + TOOL_SELF_DEADLINE_GRACE_MS);
284
+ }
285
+ if (now - snapshot.toolStartedAt > ceilingMs) {
286
+ return new AgentStallAbortError(`agent tool running stale (${ceilingMs}ms)`);
287
+ }
213
288
  }
214
289
 
215
290
  return null;
@@ -1,5 +1,5 @@
1
1
  import { resolvePluginData } from '../../shared/plugin-paths.mjs';
2
- import { readSection, updateSection, getAgentApiKey, AGENT_PROVIDER_ENV } from '../../shared/config.mjs';
2
+ import { readSection, updateSection, updateSectionAsync, getAgentApiKey, AGENT_PROVIDER_ENV } from '../../shared/config.mjs';
3
3
  import { OPENAI_COMPAT_PRESETS } from './providers/openai-compat-presets.mjs';
4
4
  import {
5
5
  hasAnthropicOAuthCredentials,
@@ -241,6 +241,14 @@ function persistAgentConfig(build) {
241
241
  updateSection('agent', (current) => build(hasKeys(current) ? current : {}));
242
242
  }
243
243
 
244
+ // Async twin of persistAgentConfig: same in-lock rebase-on-current semantics,
245
+ // but the whole-section RMW runs through updateSectionAsync so a debounced
246
+ // timer flush never blocks the event loop on icacls/backup. Reuses the same
247
+ // config lock file, so it stays linearizable with the sync writers.
248
+ async function persistAgentConfigAsync(build) {
249
+ await updateSectionAsync('agent', (current) => build(hasKeys(current) ? current : {}));
250
+ }
251
+
244
252
  // Recap toggle (recap.enabled, default true) gates ONLY the background memory
245
253
  // cycles. The memory module itself is always-on. On load we fold a legacy
246
254
  // `modules.memory === false` flag into recap.enabled=false one time; the caller
@@ -506,23 +514,34 @@ export function loadConfig(options = {}) {
506
514
  * concurrent instance's edits are not reverted.
507
515
  */
508
516
  /** In-lock patch of `skills.disabled` only (avoids whole-config lost-update). */
509
- export function patchSkillsDisabled(disabledNames) {
517
+ function buildSkillsDisabledPatch(disabledNames) {
510
518
  const names = disabledNames instanceof Set
511
519
  ? [...disabledNames]
512
520
  : (Array.isArray(disabledNames) ? disabledNames : []);
513
521
  const nextSkills = normalizeSkillsConfig({ disabled: names });
514
- persistAgentConfig((current) => {
522
+ const build = (current) => {
515
523
  const cur = { ...current };
516
524
  const target = (cur.agent && cur.agent.providers)
517
525
  ? (cur.agent = { ...cur.agent })
518
526
  : cur;
519
527
  target.skills = nextSkills;
520
528
  return cur;
521
- });
529
+ };
530
+ return { build, nextSkills };
531
+ }
532
+ export function patchSkillsDisabled(disabledNames) {
533
+ const { build, nextSkills } = buildSkillsDisabledPatch(disabledNames);
534
+ persistAgentConfig(build);
535
+ return nextSkills;
536
+ }
537
+ // Async twin used by the debounced skills flush timer.
538
+ export async function patchSkillsDisabledAsync(disabledNames) {
539
+ const { build, nextSkills } = buildSkillsDisabledPatch(disabledNames);
540
+ await persistAgentConfigAsync(build);
522
541
  return nextSkills;
523
542
  }
524
543
 
525
- export function saveConfig(config) {
544
+ function buildAgentSaveBuilder(config) {
526
545
  // Strip ephemeral defaults from providers but preserve any unknown
527
546
  // per-provider subkey so future schema additions round-trip through the
528
547
  // setup UI without changes here. apiKey is intentionally omitted —
@@ -556,7 +575,7 @@ export function saveConfig(config) {
556
575
  // Build the replacement from `existingRaw` — the section read INSIDE the
557
576
  // file lock — not a snapshot taken before it, so unmanaged keys written by
558
577
  // a concurrent instance survive the save (lost-update guard).
559
- persistAgentConfig((existingRaw) => ({
578
+ return (existingRaw) => ({
560
579
  ...existingRaw,
561
580
  guide: config.guide || existingRaw.guide || undefined,
562
581
  providers: persistedProviders,
@@ -582,7 +601,14 @@ export function saveConfig(config) {
582
601
  update: config.update || {},
583
602
  recap: config.recap || {},
584
603
  modules: config.modules || existingRaw.modules || {},
585
- }));
604
+ });
605
+ }
606
+ export function saveConfig(config) {
607
+ persistAgentConfig(buildAgentSaveBuilder(config));
608
+ }
609
+ // Async twin used by the debounced config-save flush timer.
610
+ export async function saveConfigAsync(config) {
611
+ await persistAgentConfigAsync(buildAgentSaveBuilder(config));
586
612
  }
587
613
  // --- Preset helpers ---
588
614
  // preset shape: { id, name, type: 'agent', provider, model, effort?, fast?, tools? }
@@ -407,9 +407,38 @@ export function stripDeferredToolManifestBlock(text) {
407
407
  .trimEnd();
408
408
  }
409
409
 
410
- /** Inject the skill-style deferred pool (name + description) into BP1 once at session start; never rewrite BP1 after. */
411
- export function applyInitialDeferredToolManifestToBp1(session, poolNames) {
412
- if (!session || !Array.isArray(session.messages) || session.deferredToolBp1Applied) return false;
410
+ // Rebuild path: replace the FIRST previously-injected <available-deferred-tools>
411
+ // block (with its leading `---` separator) with the fresh manifest IN PLACE, so
412
+ // the block keeps its original position and no sibling BP1 block (skills
413
+ // manifest, agent rules, …) is reordered or dropped. The fresh manifest already
414
+ // carries the mcp-instructions companion, so any pre-existing standalone one is
415
+ // removed first to avoid duplication.
416
+ export function rebuildDeferredToolManifestBlock(text, manifest) {
417
+ let out = String(text || '').replace(MCP_INSTRUCTIONS_BLOCK_RE, '');
418
+ let replaced = false;
419
+ out = out.replace(DEFERRED_TOOLS_BLOCK_RE, (match, sep) => {
420
+ if (replaced) return '';
421
+ replaced = true;
422
+ return `${sep || ''}${manifest}`;
423
+ });
424
+ if (!replaced) {
425
+ const base = out.trimEnd();
426
+ out = base ? `${base}\n\n---\n\n${manifest}` : manifest;
427
+ }
428
+ return out;
429
+ }
430
+
431
+ /**
432
+ * Inject the skill-style deferred pool (name + description) into BP1 at session
433
+ * start. Normally once; with `{ rebuild: true }` it strips any existing
434
+ * <available-deferred-tools>/<mcp-instructions> block and re-injects the fresh
435
+ * pool in place (used by the first-turn MCP refresh, before the prompt renders,
436
+ * so late-connected MCP tools land in the INITIAL manifest — never duplicated).
437
+ */
438
+ export function applyInitialDeferredToolManifestToBp1(session, poolNames, options = {}) {
439
+ const rebuild = options?.rebuild === true;
440
+ if (!session || !Array.isArray(session.messages)) return false;
441
+ if (session.deferredToolBp1Applied && !rebuild) return false;
413
442
  const pool = Array.isArray(poolNames) ? poolNames : [];
414
443
  const descByName = new Map();
415
444
  for (const tool of Array.isArray(session?.deferredToolCatalog) ? session.deferredToolCatalog : []) {
@@ -432,15 +461,21 @@ export function applyInitialDeferredToolManifestToBp1(session, poolNames) {
432
461
  }
433
462
  if (idx === -1) return false;
434
463
  const raw = typeof session.messages[idx].content === 'string' ? session.messages[idx].content : '';
435
- if (bp1HasDeferredToolManifestBlock(raw)) {
464
+ if (bp1HasDeferredToolManifestBlock(raw) && !rebuild) {
436
465
  session.deferredToolBp1Applied = true;
437
466
  return true;
438
467
  }
439
468
  if (manifest) {
440
- const base = stripDeferredToolManifestBlock(raw);
441
- session.messages[idx].content = base
442
- ? `${base}\n\n---\n\n${manifest}`
443
- : manifest;
469
+ if (rebuild && bp1HasDeferredToolManifestBlock(raw)) {
470
+ // Anchored in-place rebuild: swap the previously injected manifest
471
+ // block for the fresh one at its EXISTING position.
472
+ session.messages[idx].content = rebuildDeferredToolManifestBlock(raw, manifest);
473
+ } else {
474
+ const base = stripDeferredToolManifestBlock(raw);
475
+ session.messages[idx].content = base
476
+ ? `${base}\n\n---\n\n${manifest}`
477
+ : manifest;
478
+ }
444
479
  }
445
480
  session.deferredToolBp1Applied = true;
446
481
  session.updatedAt = Date.now();