mixdog 0.9.0 → 0.9.2

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 (240) hide show
  1. package/package.json +10 -3
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -2,27 +2,34 @@ import { classifyResultKind } from './result-classification.mjs';
2
2
  import { executeMcpTool, isMcpTool, mcpToolHasField } from '../mcp/client.mjs';
3
3
  import { canonicalizeBuiltinToolName, executeBuiltinTool, formatUnknownBuiltinToolMessage, isBuiltinTool } from '../tools/builtin.mjs';
4
4
  import { executeBashSessionTool } from '../tools/bash-session.mjs';
5
- import { executePatchTool } from '../tools/patch.mjs';
5
+ import { executePatchTool, takeApplyPatchUiDiff } from '../tools/patch.mjs';
6
6
  import { executeInternalTool, isInternalTool } from '../internal-tools.mjs';
7
- import { collectSkillsCached, loadSkillContent } from '../context/collect.mjs';
8
- import { traceAgentLoop, traceAgentTool, traceAgentToolFailure, traceAgentCompact, estimateProviderPayloadBytes, messagePrefixHash } from '../agent-trace.mjs';
7
+ import { collectSkillsCached, loadSkillResource, buildSkillToolEnvelope } from '../context/collect.mjs';
8
+ import { normalizeToolEnvelope, makeToolEnvelope } from './tool-envelope.mjs';
9
+ import { traceAgentLoop, traceAgentTool, traceAgentToolFailure, traceAgentCompact, estimateProviderPayloadBytes, messagePrefixHash, appendAgentTrace } from '../agent-trace.mjs';
10
+ import { resolveSessionMaxLoopIterations } from '../agent-runtime/agent-loop-policy.mjs';
9
11
  import { isAgentOwner } from '../agent-owner.mjs';
10
12
  import { markSessionToolCall, updateSessionStage, SessionClosedError, getSessionAbortSignal, enqueuePendingMessage, bumpUsageMetricsEpoch } from './manager.mjs';
11
- import { estimateMessagesTokens, estimateRequestReserveTokens, sanitizeToolPairs } from './context-utils.mjs';
13
+ import {
14
+ estimateMessagesTokens,
15
+ estimateRequestReserveTokens,
16
+ sanitizeToolPairs,
17
+ resolveCompactBufferRatio,
18
+ resolveCompactBufferTokens,
19
+ } from './context-utils.mjs';
12
20
  import {
13
21
  recallFastTrackCompactMessages,
14
22
  pruneToolOutputs,
23
+ pruneToolOutputsUnanchored,
15
24
  semanticCompactMessages,
25
+ effectiveBudget as compactEffectiveBudget,
16
26
  compactTypeIsRecallFastTrack,
17
27
  compactTypeIsSemantic,
18
28
  normalizeCompactType,
19
29
  DEFAULT_COMPACT_TYPE,
20
- DEFAULT_COMPACTION_BUFFER_TOKENS,
21
- DEFAULT_COMPACTION_BUFFER_RATIO,
22
30
  DEFAULT_COMPACTION_KEEP_TOKENS,
23
- compactionBufferTokensForBoundary,
24
- normalizeCompactionBufferRatio,
25
31
  drainSessionCycle1,
32
+ countRawPendingRows,
26
33
  } from './compact.mjs';
27
34
  import { isContextOverflowError } from '../providers/retry-classifier.mjs';
28
35
  import { stripSoftWarns } from '../tool-loop-guard.mjs';
@@ -126,9 +133,9 @@ function _preDispatchDeny(call, toolKind, sessionRef) {
126
133
  if (_agentOwned && _controlPlaneTool) {
127
134
  return `Error: control-plane tool "${name}" is Lead-only and not available to agent workers.`;
128
135
  }
129
- const noToolRole = sessionRef?.role === 'cycle1-agent' || sessionRef?.role === 'cycle2-agent';
130
- if (noToolRole) {
131
- return `Error: tool "${name}" is not available in role "${sessionRef.role}". Re-emit the answer as pipe-separated text per the role's output format (first character a digit, NO tool_use blocks, NO JSON, NO prose, NO apology).`;
136
+ const noToolAgent = sessionRef?.agent === 'cycle1-agent' || sessionRef?.agent === 'cycle2-agent';
137
+ if (noToolAgent) {
138
+ return `Error: tool "${name}" is not available in agent "${sessionRef.agent}". Re-emit the answer as pipe-separated text per the agent's output format (first character a digit, NO tool_use blocks, NO JSON, NO prose, NO apology).`;
132
139
  }
133
140
  return null;
134
141
  }
@@ -151,6 +158,27 @@ function estimateMessagesTokensSafe(messages) {
151
158
  catch { return null; }
152
159
  }
153
160
 
161
+ function compactDebugEnabled() {
162
+ return String(process.env.MIXDOG_COMPACT_DEBUG || '').trim() === '1';
163
+ }
164
+
165
+ function compactDiagnosticError(err) {
166
+ if (!err) return null;
167
+ const text = String(err?.message || err);
168
+ return text.length > 500 ? `${text.slice(0, 499)}…` : text;
169
+ }
170
+
171
+ function compactByteLength(text) {
172
+ try { return Buffer.byteLength(String(text || ''), 'utf8'); }
173
+ catch { return String(text || '').length; }
174
+ }
175
+
176
+ function compactDebugLog(scope, details = {}) {
177
+ if (!compactDebugEnabled()) return;
178
+ try { process.stderr.write(`[compact] ${scope} ${JSON.stringify(details)}\n`); }
179
+ catch { /* best-effort diagnostics only */ }
180
+ }
181
+
154
182
  function steeringContentText(content) {
155
183
  if (typeof content === 'string') return content;
156
184
  if (Array.isArray(content)) {
@@ -255,21 +283,20 @@ function agentContextOverflowError({ stage, sessionId, sessionRef, model, budget
255
283
  // transcript is compacted (see the trim block below): a long task that keeps
256
284
  // compacting can proceed past this count, while a tight NON-compacting loop
257
285
  // still stops here and returns the accumulated transcript.
258
- const MAX_LOOP_ITERATIONS = 200;
259
286
  // Consecutive identical-AND-failing tool calls (same name+args, error result)
260
287
  // tolerated across iterations before the loop refuses to re-execute and steers
261
288
  // the model to change approach. Distinct from the hard iteration cap above:
262
289
  // this catches tight deterministic-failure loops (e.g. a command that errors
263
290
  // the same way every time) far earlier than 100 iterations.
264
291
  const REPEAT_FAIL_LIMIT = 3;
265
- const _HIDDEN_ROLES_JSON = resolvePath(dirname(fileURLToPath(import.meta.url)), '../../../../defaults/hidden-roles.json');
266
- let _hiddenRolesCache = null;
267
- function _getHiddenRoles() {
268
- if (_hiddenRolesCache) return _hiddenRolesCache;
292
+ const _AGENTS_JSON = resolvePath(dirname(fileURLToPath(import.meta.url)), '../../../../defaults/agents.json');
293
+ let _hiddenAgentsCache = null;
294
+ function _getHiddenAgents() {
295
+ if (_hiddenAgentsCache) return _hiddenAgentsCache;
269
296
  try {
270
- _hiddenRolesCache = JSON.parse(_readFileSync(_HIDDEN_ROLES_JSON, 'utf8'));
271
- } catch { _hiddenRolesCache = { roles: [] }; }
272
- return _hiddenRolesCache;
297
+ _hiddenAgentsCache = JSON.parse(_readFileSync(_AGENTS_JSON, 'utf8'));
298
+ } catch { _hiddenAgentsCache = { agents: [] }; }
299
+ return _hiddenAgentsCache;
273
300
  }
274
301
  // Transcript pairing guard. Anthropic 400-rejects when an assistant message
275
302
  // ends with tool_use blocks and the next message isn't tool results for
@@ -443,6 +470,27 @@ function resolveCompactTypeSetting(_sessionRef, cfg = {}) {
443
470
 
444
471
  async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTokens, compactPolicy, sessionId, signal }) {
445
472
  if (!sessionId) throw new Error('recall-fasttrack requires a session id');
473
+ const startedAt = Date.now();
474
+ const diagnostics = {
475
+ hydrateLimit: null,
476
+ ingestMs: null,
477
+ ingestSkipped: false,
478
+ ingestError: null,
479
+ initialDumpMs: null,
480
+ initialDumpBytes: null,
481
+ initialDumpChars: null,
482
+ initialRawPending: null,
483
+ cycle1Ms: null,
484
+ cycle1Skipped: false,
485
+ cycle1SkipReason: null,
486
+ cycle1Passes: null,
487
+ cycle1RawRemaining: null,
488
+ cycle1TextBytes: null,
489
+ cycle1Error: null,
490
+ finalRecallBytes: null,
491
+ finalRecallChars: null,
492
+ totalMs: null,
493
+ };
446
494
  const query = `session:${sessionId}:all-chunks`;
447
495
  const querySha = createHash('sha256').update(query).digest('hex').slice(0, 16);
448
496
  const callerCtx = {
@@ -454,6 +502,8 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
454
502
  };
455
503
  const hydrateLimit = positiveTokenInt(sessionRef?.compaction?.recallIngestLimit)
456
504
  || Math.max(500, Math.min(5000, messages.length || 0));
505
+ diagnostics.hydrateLimit = hydrateLimit;
506
+ let t0 = Date.now();
457
507
  try {
458
508
  await executeInternalTool('memory', {
459
509
  action: 'ingest_session',
@@ -463,7 +513,11 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
463
513
  limit: hydrateLimit,
464
514
  }, callerCtx);
465
515
  } catch (err) {
516
+ diagnostics.ingestSkipped = true;
517
+ diagnostics.ingestError = compactDiagnosticError(err);
466
518
  try { process.stderr.write(`[loop] recall-fasttrack ingest skipped (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
519
+ } finally {
520
+ diagnostics.ingestMs = Date.now() - t0;
467
521
  }
468
522
  const dumpArgs = {
469
523
  action: 'dump_session_roots',
@@ -472,10 +526,16 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
472
526
  limit: positiveTokenInt(sessionRef?.compaction?.recallChunkLimit ?? sessionRef?.compaction?.recallLimit) || hydrateLimit,
473
527
  };
474
528
  const runTool = (name, args) => executeInternalTool(name, args, callerCtx);
529
+ t0 = Date.now();
475
530
  let recallText = await executeInternalTool('memory', dumpArgs, callerCtx);
531
+ diagnostics.initialDumpMs = Date.now() - t0;
532
+ diagnostics.initialDumpChars = String(recallText || '').length;
533
+ diagnostics.initialDumpBytes = compactByteLength(recallText);
534
+ diagnostics.initialRawPending = countRawPendingRows(recallText);
476
535
  let cycle1Text = '';
477
536
  const hasRawRows = /(?:^|\n)# raw_pending\s+\d+\s+id=/i.test(String(recallText || ''));
478
537
  if (hasRawRows) {
538
+ t0 = Date.now();
479
539
  try {
480
540
  // Drain this session's cycle1 in window×concurrency units until no
481
541
  // raw rows remain, so the injected root is fully chunked rather than
@@ -496,19 +556,32 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
496
556
  });
497
557
  recallText = drained.recallText;
498
558
  cycle1Text = drained.cycle1Text;
559
+ diagnostics.cycle1Passes = drained.passes;
560
+ diagnostics.cycle1RawRemaining = drained.rawRemaining;
561
+ diagnostics.cycle1TextBytes = compactByteLength(cycle1Text);
499
562
  if (drained.rawRemaining > 0) {
500
563
  try { process.stderr.write(`[loop] recall-fasttrack drained passes=${drained.passes} rawRemaining=${drained.rawRemaining} (sess=${sessionId || 'unknown'})\n`); } catch {}
501
564
  }
502
565
  } catch (err) {
566
+ diagnostics.cycle1Error = compactDiagnosticError(err);
503
567
  try { process.stderr.write(`[loop] recall-fasttrack cycle1 skipped (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
568
+ } finally {
569
+ diagnostics.cycle1Ms = Date.now() - t0;
504
570
  }
505
571
  } else {
572
+ diagnostics.cycle1Skipped = true;
573
+ diagnostics.cycle1SkipReason = 'session chunks already hydrated';
574
+ diagnostics.cycle1Passes = 0;
575
+ diagnostics.cycle1RawRemaining = 0;
506
576
  cycle1Text = 'cycle1: skipped (session chunks already hydrated)';
507
577
  }
508
- return recallFastTrackCompactMessages(messages, compactBudgetTokens, {
578
+ const combinedRecallText = [`session_id=${sessionId}`, cycle1Text, recallText].map(v => String(v || '').trim()).filter(Boolean).join('\n\n');
579
+ diagnostics.finalRecallChars = combinedRecallText.length;
580
+ diagnostics.finalRecallBytes = compactByteLength(combinedRecallText);
581
+ const result = recallFastTrackCompactMessages(messages, compactBudgetTokens, {
509
582
  reserveTokens: compactPolicy.reserveTokens,
510
583
  force: true,
511
- recallText: [`session_id=${sessionId}`, cycle1Text, recallText].map(v => String(v || '').trim()).filter(Boolean).join('\n\n'),
584
+ recallText: combinedRecallText,
512
585
  query,
513
586
  querySha,
514
587
  allowEmptyRecall: true,
@@ -516,97 +589,15 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
516
589
  keepTokens: compactPolicy.keepTokens,
517
590
  preserveRecentTokens: compactPolicy.preserveRecentTokens,
518
591
  });
519
- }
520
- // Percent-named inputs (bufferPercent / bufferPct / *_BUFFER_PERCENT) carry a
521
- // PERCENT: 1 means 1% (0.01). Ratio-named inputs (bufferRatio / bufferFraction)
522
- // carry a fraction: 0.01 means 1%, and a legacy value > 1 is read as a percent
523
- // (10 -> 10%). The shared normalizeCompactionBufferRatio only handles the ratio
524
- // convention, so resolve percent-named values to a fraction first.
525
- function _resolveBufferRatioCandidate(percentInputs, ratioInputs) {
526
- for (const raw of percentInputs) {
527
- const n = Number(raw);
528
- if (Number.isFinite(n) && n > 0) return Math.min(1, n / 100);
529
- }
530
- for (const raw of ratioInputs) {
531
- const n = Number(raw);
532
- if (Number.isFinite(n) && n > 0) return n > 1 ? n / 100 : n;
533
- }
534
- return null;
535
- }
536
- function resolveCompactBufferRatio(cfg = {}) {
537
- const resolved = _resolveBufferRatioCandidate(
538
- [cfg.bufferPercent, cfg.bufferPct, process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT],
539
- [cfg.bufferRatio, cfg.bufferFraction, process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO],
540
- );
541
- return normalizeCompactionBufferRatio(resolved, DEFAULT_COMPACTION_BUFFER_RATIO);
542
- }
543
- const LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
544
- function isPersistedZeroBufferTelemetry(cfg = {}, boundaryTokens = 0) {
545
- const boundary = positiveTokenInt(boundaryTokens);
546
- if (!boundary) return false;
547
- if (envTokenInt('MIXDOG_AGENT_COMPACT_BUFFER_TOKENS')) return false;
548
- for (const envName of ['MIXDOG_AGENT_COMPACT_BUFFER_PERCENT', 'MIXDOG_AGENT_COMPACT_BUFFER_RATIO']) {
549
- const n = Number(process.env[envName]);
550
- if (Number.isFinite(n) && n > 0) return false;
551
- }
552
- for (const key of ['bufferPercent', 'bufferPct', 'bufferFraction']) {
553
- const n = Number(cfg?.[key]);
554
- if (Number.isFinite(n) && n > 0) return false;
555
- }
556
- const ratio = Number(cfg?.bufferRatio);
557
- if (Number.isFinite(ratio) && ratio > 0) return false;
558
- const explicitTokens = Number(cfg?.bufferTokens ?? cfg?.buffer);
559
- if (!Number.isFinite(explicitTokens) || explicitTokens !== 0) return false;
560
- return true;
561
- }
562
- function isLegacyDefaultBufferTelemetry(cfg = {}, boundaryTokens = 0) {
563
- const boundary = positiveTokenInt(boundaryTokens);
564
- if (!boundary) return false;
565
- if (envTokenInt('MIXDOG_AGENT_COMPACT_BUFFER_TOKENS')) return false;
566
- for (const envName of ['MIXDOG_AGENT_COMPACT_BUFFER_PERCENT', 'MIXDOG_AGENT_COMPACT_BUFFER_RATIO']) {
567
- const n = Number(process.env[envName]);
568
- if (Number.isFinite(n) && n > 0) return false;
569
- }
570
- // Percent/fraction-named fields are operator config. The legacy default
571
- // telemetry persisted bufferTokens + bufferRatio after a check/compact pass.
572
- for (const key of ['bufferPercent', 'bufferPct', 'bufferFraction']) {
573
- const n = Number(cfg?.[key]);
574
- if (Number.isFinite(n) && n > 0) return false;
575
- }
576
- const explicitTokens = positiveTokenInt(cfg?.bufferTokens ?? cfg?.buffer);
577
- const ratio = Number(cfg?.bufferRatio);
578
- if (!explicitTokens || !Number.isFinite(ratio) || Math.abs(ratio - LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO) > 1e-9) return false;
579
- const expectedTokens = Math.floor(boundary * LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO);
580
- const cfgBoundary = positiveTokenInt(cfg?.boundaryTokens);
581
- const cfgTrigger = positiveTokenInt(cfg?.triggerTokens);
582
- return explicitTokens === expectedTokens
583
- || (cfgBoundary === boundary && cfgTrigger > 0 && explicitTokens === Math.max(0, boundary - cfgTrigger));
584
- }
585
- function compactBufferConfigForBoundary(cfg = {}, boundaryTokens = 0) {
586
- const base = cfg || {};
587
- if (!isLegacyDefaultBufferTelemetry(base, boundaryTokens)
588
- && !isPersistedZeroBufferTelemetry(base, boundaryTokens)) {
589
- return base;
592
+ diagnostics.totalMs = Date.now() - startedAt;
593
+ if (result && typeof result === 'object') {
594
+ result.diagnostics = {
595
+ ...(result.diagnostics || {}),
596
+ pipeline: diagnostics,
597
+ };
590
598
  }
591
- return {
592
- ...base,
593
- bufferTokens: null,
594
- buffer: null,
595
- bufferRatio: null,
596
- };
597
- }
598
- function resolveCompactBufferTokens(boundaryTokens, cfg = {}) {
599
- const boundary = positiveTokenInt(boundaryTokens);
600
- const effectiveCfg = compactBufferConfigForBoundary(cfg, boundary);
601
- const configured = positiveTokenInt(effectiveCfg.bufferTokens ?? effectiveCfg.buffer)
602
- || envTokenInt('MIXDOG_AGENT_COMPACT_BUFFER_TOKENS')
603
- || 0;
604
- if (!boundary) return configured || DEFAULT_COMPACTION_BUFFER_TOKENS;
605
- return compactionBufferTokensForBoundary(boundary, {
606
- explicitTokens: configured,
607
- ratio: resolveCompactBufferRatio(effectiveCfg),
608
- maxRatio: COMPACT_BUFFER_MAX_WINDOW_FRACTION,
609
- });
599
+ compactDebugLog('recall-fasttrack pipeline', diagnostics);
600
+ return result;
610
601
  }
611
602
  const COMPACT_TARGET_RATIO = 0.02;
612
603
  const COMPACT_TARGET_MIN_TOKENS = 4_000;
@@ -947,9 +938,12 @@ function buildSkillsListResponse(cwd) {
947
938
  }
948
939
  function viewSkill(cwd, name) {
949
940
  if (!name) return 'Error: skill name is required';
950
- const content = loadSkillContent(name, cwd);
951
- if (!content) return `Error: skill "${name}" not found`;
952
- return `<skill>\n<name>${String(name).replace(/[<>&]/g, (ch) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[ch]))}</name>\n${content}\n</skill>`;
941
+ const res = loadSkillResource(name, cwd);
942
+ if (!res) return `Error: skill "${name}" not found`;
943
+ // Return the general tool envelope: the model-visible tool_result is the
944
+ // short stub (`Loaded skill: <name>`) and the full SKILL.md body is
945
+ // delivered ONCE as a separate injected role:'user' message (newMessages).
946
+ return buildSkillToolEnvelope(name, res.content, res.dir);
953
947
  }
954
948
 
955
949
  /** Normalize PostToolUse hook override values (legacy MCP text envelopes only). */
@@ -1238,7 +1232,7 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1238
1232
  }
1239
1233
  if (name === 'apply_patch') {
1240
1234
  const patchArgs = typeof args === 'string' ? { patch: args } : args;
1241
- return executePatchTool(name, patchArgs, cwd, { sessionId: callerSessionId });
1235
+ return executePatchTool(name, patchArgs, cwd, { sessionId: callerSessionId, toolCallId: executeOpts.toolCallId || null });
1242
1236
  }
1243
1237
  if (isBuiltinTool(name)) {
1244
1238
  // clientHostPid threaded for the same per-terminal job-scope reason as
@@ -1257,7 +1251,14 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1257
1251
  toolCallId: executeOpts.toolCallId || null,
1258
1252
  result: __result,
1259
1253
  });
1260
- return resolveToolResultAfterHook(__result, hookResult);
1254
+ // Envelope-aware hook override: a PostToolUse hook may override the
1255
+ // model-VISIBLE tool output (the envelope's `result` / stub), but it
1256
+ // must NEVER drop the `newMessages` channel. Split first, apply the
1257
+ // override to `result` only, then re-wrap so newMessages survive.
1258
+ const { result: __res, newMessages: __nm } = normalizeToolEnvelope(__result);
1259
+ const __overridden = resolveToolResultAfterHook(__res, hookResult);
1260
+ if (__nm.length) return makeToolEnvelope(__overridden, __nm);
1261
+ return __overridden;
1261
1262
  } catch {
1262
1263
  // PostToolUse hooks are best-effort; never let one break the tool result.
1263
1264
  }
@@ -1275,11 +1276,11 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1275
1276
  * wrapper can propagate a clean cancellation.
1276
1277
  * - `onStageChange(stage)` / `onStreamDelta()` — forwarded to provider.send for heartbeats
1277
1278
  */
1278
- // Source of truth: defaults/hidden-roles.json (loaded via _getHiddenRoles
1279
- // above). Build the name Set eagerly at module load so HIDDEN_ROLE_NAMES
1279
+ // Source of truth: defaults/agents.json (loaded via _getHiddenAgents
1280
+ // above). Build the name Set eagerly at module load so HIDDEN_AGENT_NAMES
1280
1281
  // stays in sync with the declarative registry — no hardcoded duplicate.
1281
- const HIDDEN_ROLE_NAMES = new Set(
1282
- (_getHiddenRoles().roles || []).map((r) => r && r.name).filter((n) => typeof n === 'string' && n.length > 0)
1282
+ const HIDDEN_AGENT_NAMES = new Set(
1283
+ (_getHiddenAgents().agents || []).map((r) => r && r.agent).filter((n) => typeof n === 'string' && n.length > 0)
1283
1284
  );
1284
1285
 
1285
1286
  // Stop reasons that signal the turn was cut short mid-synthesis (token cap,
@@ -1315,6 +1316,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1315
1316
  let response;
1316
1317
  let contractNudges = 0;
1317
1318
  let contextOverflowRetryUsed = false;
1319
+ // Set when the hard iteration-cap break below fires. Consumed at the final
1320
+ // return to tag terminationReason='iteration_cap' so a worker that exhausts
1321
+ // the loop without a final answer surfaces to Lead as an explicit error
1322
+ // instead of a silent empty "completed".
1323
+ let terminatedByCap = false;
1318
1324
  // Set when a provider context-overflow refusal triggers the in-turn
1319
1325
  // reactive compact retry below; consumed by the next pre-send compact pass
1320
1326
  // so its telemetry/events carry trigger:'reactive' (distinct from the
@@ -1323,7 +1329,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1323
1329
  const opts = sendOpts || {};
1324
1330
  const sessionId = opts.sessionId || null;
1325
1331
  const signal = opts.signal || null;
1326
- const sessionRole = opts.session?.role;
1332
+ const sessionAgent = opts.session?.agent;
1327
1333
  const forcedFirstTool = opts.forcedFirstTool ?? null;
1328
1334
  const forcedFirstToolDef = forcedFirstTool
1329
1335
  ? tools.find(tool => tool?.name === forcedFirstTool)
@@ -1396,9 +1402,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1396
1402
  });
1397
1403
  return true;
1398
1404
  };
1399
- const maxLoopIterations = Number.isFinite(sessionRef?.maxLoopIterations)
1400
- ? sessionRef.maxLoopIterations
1401
- : MAX_LOOP_ITERATIONS;
1405
+ const maxLoopIterations = resolveSessionMaxLoopIterations(sessionRef);
1402
1406
  // Tool execution must use the session cwd even when the caller omitted the
1403
1407
  // legacy positional cwd argument. Agent workers always carry their cwd on
1404
1408
  // sessionRef; falling through to pwd()/process.cwd() resolves relatives
@@ -1408,6 +1412,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1408
1412
  throwIfAborted();
1409
1413
  if (iterations >= maxLoopIterations) {
1410
1414
  process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
1415
+ terminatedByCap = true;
1411
1416
  break;
1412
1417
  }
1413
1418
  // Drain queued steering/prompts BEFORE the
@@ -1570,6 +1575,51 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1570
1575
  }
1571
1576
  summaryChanged = messagesArrayChanged(compactInputMessages, compacted);
1572
1577
  } catch (compactErr) {
1578
+ // Anchor-independent prune safety net. When SEMANTIC compact
1579
+ // throws (e.g. a degenerate single-turn transcript, or a
1580
+ // summary that cannot fit), attempt one non-LLM prune that
1581
+ // needs no user anchor: middle-truncate the oldest oversized
1582
+ // tool_result bodies until the transcript fits the budget.
1583
+ // If it shrinks the transcript we continue with that result
1584
+ // instead of escalating to overflow. Structure/pairing is
1585
+ // preserved (only string content shrinks) and the result is
1586
+ // re-reconciled inside the helper.
1587
+ //
1588
+ // GATED to the non-recall path: a recall-fasttrack failure
1589
+ // must NOT be silently recovered by this prune (that would
1590
+ // change the type-2 path's contract by shipping a pruned
1591
+ // transcript with no recall output). When recallFastTrackError
1592
+ // is set the fallback is skipped and the original overflow
1593
+ // escalation runs unchanged.
1594
+ if (!recallFastTrackError) {
1595
+ try {
1596
+ // Accept only if the pruned transcript fits the SAME
1597
+ // effective budget the prune targets (compactBudgetTokens
1598
+ // minus the request reserve) — comparing against the raw
1599
+ // compactBudgetTokens would accept a result with no
1600
+ // reserve headroom and overflow on the very next send.
1601
+ const acceptThreshold = compactEffectiveBudget(compactBudgetTokens, {
1602
+ reserveTokens: compactPolicy.reserveTokens,
1603
+ });
1604
+ const salvaged = pruneToolOutputsUnanchored(messages, compactBudgetTokens, {
1605
+ reserveTokens: compactPolicy.reserveTokens,
1606
+ });
1607
+ if (messagesArrayChanged(messages, salvaged)
1608
+ && estimateMessagesTokensSafe(salvaged) <= acceptThreshold) {
1609
+ compacted = salvaged;
1610
+ pruneCount = countPrunedToolOutputs(messages, salvaged);
1611
+ summaryChanged = true;
1612
+ }
1613
+ } catch { /* fall through to overflow escalation */ }
1614
+ }
1615
+ if (compacted !== undefined) {
1616
+ try {
1617
+ process.stderr.write(
1618
+ `[loop] compact fallback prune recovered (sess=${sessionId || 'unknown'}): ` +
1619
+ `${compactErr?.message || compactErr}\n`,
1620
+ );
1621
+ } catch { /* best-effort */ }
1622
+ } else {
1573
1623
  const compactFailMsg = compactErr && compactErr.message ? compactErr.message : String(compactErr);
1574
1624
  const semanticFailMsg = semanticCompactError?.message || null;
1575
1625
  const recallFailMsg = recallFastTrackError?.message || null;
@@ -1593,6 +1643,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1593
1643
  iteration: iterations + 1,
1594
1644
  stage: 'pre_send',
1595
1645
  trigger: compactTrigger,
1646
+ compact_type: compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE,
1596
1647
  prune_count: pruneCount,
1597
1648
  compact_changed: false,
1598
1649
  input_prefix_hash: messagePrefixHash(messages),
@@ -1602,13 +1653,23 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1602
1653
  after_bytes: getBeforeBytes(),
1603
1654
  context_window: compactPolicy.contextWindow,
1604
1655
  budget_tokens: compactPolicy.boundaryTokens,
1656
+ boundary_tokens: compactPolicy.boundaryTokens,
1605
1657
  target_budget_tokens: compactBudgetTokens,
1606
1658
  reserve_tokens: compactPolicy.reserveTokens,
1659
+ pressure_tokens: pressureTokens,
1660
+ trigger_tokens: compactPolicy.triggerTokens,
1607
1661
  message_tokens_est: messageTokensEst,
1662
+ duration_ms: Date.now() - compactStartedAt,
1608
1663
  provider: sessionRef.provider,
1609
1664
  model: sessionRef.model || model,
1610
1665
  error: compactFailMsg,
1611
1666
  error_code: compactFailCode,
1667
+ details: {
1668
+ semantic: semanticCompactResult?.diagnostics || null,
1669
+ recallFastTrack: recallFastTrackResult?.diagnostics || null,
1670
+ semanticError: semanticFailMsg,
1671
+ recallFastTrackError: recallFailMsg,
1672
+ },
1612
1673
  });
1613
1674
  emitCompactEvent(opts, {
1614
1675
  sessionId,
@@ -1640,6 +1701,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1640
1701
  reserveTokens: compactPolicy.reserveTokens,
1641
1702
  messageTokensEst,
1642
1703
  }, compactErr);
1704
+ }
1643
1705
  }
1644
1706
  try { opts.onStageChange?.('requesting'); } catch { /* best-effort */ }
1645
1707
  const compactChanged = messagesArrayChanged(messages, compacted);
@@ -1684,6 +1746,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1684
1746
  iteration: iterations + 1,
1685
1747
  stage: 'pre_send',
1686
1748
  trigger: compactTrigger,
1749
+ compact_type: compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE,
1687
1750
  prune_count: pruneCount,
1688
1751
  compact_changed: compactChanged || summaryChanged,
1689
1752
  input_prefix_hash: messagePrefixHash(messages),
@@ -1693,11 +1756,19 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1693
1756
  after_bytes: afterBytes,
1694
1757
  context_window: compactPolicy.contextWindow,
1695
1758
  budget_tokens: compactPolicy.boundaryTokens,
1759
+ boundary_tokens: compactPolicy.boundaryTokens,
1696
1760
  target_budget_tokens: compactBudgetTokens,
1697
1761
  reserve_tokens: compactPolicy.reserveTokens,
1762
+ pressure_tokens: pressureTokens,
1763
+ trigger_tokens: compactPolicy.triggerTokens,
1698
1764
  message_tokens_est: messageTokensEst,
1765
+ duration_ms: compactDurationMs,
1699
1766
  provider: sessionRef.provider,
1700
1767
  model: sessionRef.model || model,
1768
+ details: {
1769
+ semantic: semanticCompactResult?.diagnostics || null,
1770
+ recallFastTrack: recallFastTrackResult?.diagnostics || null,
1771
+ },
1701
1772
  });
1702
1773
  emitCompactEvent(opts, {
1703
1774
  sessionId,
@@ -1780,7 +1851,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1780
1851
  _eagerInFlightSigs.set(_sig, call.id);
1781
1852
  entry.promise = (async () => {
1782
1853
  try {
1783
- return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval }) };
1854
+ return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: nextIteration }) };
1784
1855
  } catch (error) {
1785
1856
  return { ok: false, error };
1786
1857
  }
@@ -1810,10 +1881,17 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1810
1881
  // calls (they return null above), so those `continue`-before-
1811
1882
  // execution stub paths can never early-notify (contract #5).
1812
1883
  try {
1884
+ // UI-only: surface the model-VISIBLE result (envelope
1885
+ // stub for envelope returns), never the envelope object
1886
+ // or its injected newMessages body — no [object Object],
1887
+ // no full skill body in the tool card.
1888
+ const _earlyVisible = settled && settled.ok
1889
+ ? normalizeToolEnvelope(settled.value).result
1890
+ : null;
1813
1891
  const _earlyContent = settled && settled.ok
1814
- ? (typeof settled.value === 'string'
1815
- ? settled.value
1816
- : (settled.value == null ? '' : String(settled.value)))
1892
+ ? (typeof _earlyVisible === 'string'
1893
+ ? _earlyVisible
1894
+ : (_earlyVisible == null ? '' : String(_earlyVisible)))
1817
1895
  : `Error: ${settled && settled.error instanceof Error ? settled.error.message : String(settled && settled.error)}`;
1818
1896
  opts.onToolResult?.({
1819
1897
  role: 'tool',
@@ -1870,6 +1948,88 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1870
1948
  try {
1871
1949
  response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
1872
1950
  } catch (sendErr) {
1951
+ // Partial-final recovery (owner-notify fix): the recurring "worker
1952
+ // finished but the task hung / no result delivered" case is a FINAL,
1953
+ // no-tool summary stream that wedges (ping-only) AFTER all real tool
1954
+ // work completed in earlier iterations. The provider attaches its
1955
+ // partial stream state to the StreamStalledError. When the stall
1956
+ // carries streamed assistant text, has NO pending tool_use, and did
1957
+ // NOT emit a tool call this iteration, accept the partial as a
1958
+ // successful terminal response (deliver the summary we have) instead
1959
+ // of throwing — which would strand/notify-as-failure a turn whose
1960
+ // work actually succeeded. A stall WITH a pending/emitted tool call
1961
+ // is NOT recoverable (a tool whose input never completed must never
1962
+ // look done) and falls through to the normal error path.
1963
+ if (
1964
+ sendErr?.streamStalled === true
1965
+ && sendErr.pendingToolUse !== true
1966
+ && sendErr.unsafeToRetry !== true
1967
+ && typeof sendErr.partialContent === 'string'
1968
+ && sendErr.partialContent.trim().length > 0
1969
+ && !(Array.isArray(sendErr.partialToolCalls) && sendErr.partialToolCalls.length > 0)
1970
+ ) {
1971
+ try {
1972
+ process.stderr.write(
1973
+ `[loop] final stream stalled with partial text (sess=${sessionId || 'unknown'} `
1974
+ + `iter=${nextIteration} len=${sendErr.partialContent.length}); `
1975
+ + `accepting as partial-final success\n`,
1976
+ );
1977
+ } catch { /* best-effort */ }
1978
+ response = {
1979
+ content: sendErr.partialContent,
1980
+ model: sendErr.partialModel || model,
1981
+ toolCalls: undefined,
1982
+ usage: sendErr.partialUsage || undefined,
1983
+ stopReason: sendErr.partialStopReason || 'end_turn',
1984
+ hasThinkingContent: sendErr.partialHasThinking === true,
1985
+ partialFinal: true,
1986
+ };
1987
+ } else
1988
+ // Partial tool-call recovery (agent-hang fix): a stream that stalls
1989
+ // AFTER fully-parsed tool calls were emitted used to lose the whole
1990
+ // turn — unsafeToRetry blocks the mid-stream replay (correct: a
1991
+ // replay would re-run side-effecting tools) and the old code threw,
1992
+ // discarding tool work that had ALREADY completed via eager dispatch.
1993
+ // But the parsed calls are complete (pendingToolUse false ⇒ no
1994
+ // half-streamed tool input), so instead of replaying the request we
1995
+ // accept the partial as a normal tool-call turn and fall through to
1996
+ // the standard execution path: eager-dispatched (read-only) calls
1997
+ // resolve from the pending map without re-running, side-effecting
1998
+ // calls were never started during streaming and execute exactly
1999
+ // once. providerState stays undefined so the next iteration resends
2000
+ // a full frame on a fresh stream.
2001
+ if (
2002
+ sendErr?.streamStalled === true
2003
+ && sendErr.pendingToolUse !== true
2004
+ && Array.isArray(sendErr.partialToolCalls)
2005
+ && sendErr.partialToolCalls.length > 0
2006
+ ) {
2007
+ try {
2008
+ process.stderr.write(
2009
+ `[loop] stream stalled after ${sendErr.partialToolCalls.length} complete tool call(s) `
2010
+ + `(sess=${sessionId || 'unknown'} iter=${nextIteration}); `
2011
+ + `recovering as tool-call turn instead of failing\n`,
2012
+ );
2013
+ } catch { /* best-effort */ }
2014
+ try {
2015
+ appendAgentTrace({
2016
+ kind: 'stall_tool_recovery',
2017
+ sessionId: sessionId || null,
2018
+ iteration: nextIteration,
2019
+ toolCalls: sendErr.partialToolCalls.length,
2020
+ partialContentLen: typeof sendErr.partialContent === 'string' ? sendErr.partialContent.length : 0,
2021
+ });
2022
+ } catch { /* best-effort */ }
2023
+ response = {
2024
+ content: typeof sendErr.partialContent === 'string' ? sendErr.partialContent : '',
2025
+ model: sendErr.partialModel || model,
2026
+ toolCalls: sendErr.partialToolCalls.slice(),
2027
+ usage: sendErr.partialUsage || undefined,
2028
+ stopReason: 'tool_use',
2029
+ hasThinkingContent: sendErr.partialHasThinking === true,
2030
+ partialToolRecovery: true,
2031
+ };
2032
+ } else
1873
2033
  // Context-window-exceeded is a deterministic refusal from the API.
1874
2034
  // Recover context overflow reactively by compacting and retrying
1875
2035
  // in the same active turn. MixDog's proactive estimator can miss a
@@ -1948,6 +2108,37 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1948
2108
  // Provider may have returned despite an abort (SDKs that don't honour
1949
2109
  // signal) — bail before processing any of its output.
1950
2110
  throwIfAborted();
2111
+ // P1 audit fix (Step4): a text-only turn truncated by the provider's
2112
+ // max-output limit (response.truncated, set by the provider layer
2113
+ // when stopReason==='length' AND content is non-empty) used to look
2114
+ // identical to a clean completion — the model's answer could be
2115
+ // silently cut mid-sentence with zero signal to the operator. Surface
2116
+ // it as a one-line stderr warning + trace event WITHOUT failing the
2117
+ // turn (the partial content is still usable and the loop's own
2118
+ // isIncompleteStop nudge below already re-prompts when content is
2119
+ // empty).
2120
+ if (response?.truncated === true) {
2121
+ try {
2122
+ process.stderr.write(
2123
+ `[loop] provider output truncated at max-output limit (sess=${sessionId || 'unknown'} `
2124
+ + `iter=${iterations} stopReason=${response.stopReason ?? response.stop_reason ?? 'length'} `
2125
+ + `contentLen=${typeof response.content === 'string' ? response.content.length : 0}); `
2126
+ + `answer may be cut off mid-sentence.\n`,
2127
+ );
2128
+ } catch { /* best-effort */ }
2129
+ try {
2130
+ appendAgentTrace({
2131
+ sessionId,
2132
+ iteration: iterations,
2133
+ kind: 'output_truncated',
2134
+ payload: {
2135
+ stop_reason: response.stopReason ?? response.stop_reason ?? 'length',
2136
+ content_len: typeof response.content === 'string' ? response.content.length : 0,
2137
+ agent: sessionAgent || null,
2138
+ },
2139
+ });
2140
+ } catch { /* best-effort */ }
2141
+ }
1951
2142
  // Incremental metric persistence (fix A): push per-iteration token delta
1952
2143
  // immediately so watchdog / agent type=list sees live totals mid-turn.
1953
2144
  if (sessionId && opts.onUsageDelta && response.usage) {
@@ -1994,7 +2185,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1994
2185
  // reminders waste turns and fragment the working context, so
1995
2186
  // the second empty turn is accepted as terminal.
1996
2187
  const hasContent = typeof response.content === 'string' && response.content.trim().length > 0;
1997
- const isHidden = HIDDEN_ROLE_NAMES.has(sessionRole);
2188
+ const isHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
1998
2189
  const stopReason = response.stopReason ?? response.stop_reason ?? null;
1999
2190
  const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
2000
2191
  // A user/schedule notification can arrive while provider.send() is
@@ -2091,6 +2282,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2091
2282
  }
2092
2283
  // R15: per-turn scalar read-count Map. Lifetime = this turn's tool-call batch.
2093
2284
  // Declared between the duplicate-detection block and the for-loop so it resets
2285
+ // Per-batch buffer for the general `newMessages` tool-result channel.
2286
+ // A tool MAY return a `{ __toolEnvelope, result, newMessages }` envelope;
2287
+ // its newMessages (e.g. the Skill SKILL.md body as a role:'user' message)
2288
+ // are collected here across EVERY call in this assistant turn and flushed
2289
+ // ONCE, AFTER the batch's last tool_result is pushed — never interleaved
2290
+ // between two tool results of the same multi-tool turn (which would put a
2291
+ // user message between tool(A) and tool(B) and break provider pairing).
2292
+ const _batchNewMessages = [];
2094
2293
  for (let callIndex = 0; callIndex < calls.length; callIndex += 1) {
2095
2294
  const call = calls[callIndex];
2096
2295
  if (isBuiltinTool(call.name)) {
@@ -2227,7 +2426,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2227
2426
  toolEndedAt = Date.now();
2228
2427
  _resultKind = 'error';
2229
2428
  } else {
2230
- result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval });
2429
+ result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: iterations });
2231
2430
  toolEndedAt = Date.now();
2232
2431
  // Boundary: tool-return string convention → structural kind.
2233
2432
  // The only prefix check in this codebase; downstream layers
@@ -2249,6 +2448,18 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2249
2448
  result = `Error: ${err instanceof Error ? err.message : String(err)}`;
2250
2449
  _resultKind = 'error';
2251
2450
  }
2451
+ // CENTRAL ENVELOPE NORMALIZE (general newMessages channel).
2452
+ // executeTool (serial + eager) and cache/error paths above all
2453
+ // funnel into `result`. Split ONCE here: downstream post-processing
2454
+ // (classifyResultKind / maybeOffloadToolResult / compressToolResult /
2455
+ // traceAgentTool / cache writes / messages.push) sees ONLY the
2456
+ // model-visible `result`; the `newMessages` ride a per-batch buffer
2457
+ // flushed after the batch's last tool_result (never interleaved).
2458
+ {
2459
+ const _env = normalizeToolEnvelope(result);
2460
+ result = _env.result;
2461
+ if (_env.newMessages.length) _batchNewMessages.push(..._env.newMessages);
2462
+ }
2252
2463
  // Update the cross-iteration repeat-failure guard with this call's
2253
2464
  // outcome: bump the consecutive-failure count for an identical
2254
2465
  // signature, or clear it the moment the same call succeeds.
@@ -2400,7 +2611,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2400
2611
  toolKind,
2401
2612
  toolMs: toolEndedAt - toolStartedAt,
2402
2613
  toolArgs: call.arguments,
2403
- role: sessionRef?.role || null,
2614
+ agent: sessionRef?.agent || null,
2404
2615
  model: sessionRef?.model || null,
2405
2616
  resultKind: _resultKind,
2406
2617
  resultText: result,
@@ -2432,12 +2643,22 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2432
2643
  setReadCached({ sessionId, args: call.arguments, cwd, content: result, toolUseId: call.id });
2433
2644
  }
2434
2645
  }
2646
+ // UI-only: apply_patch stashes the standard unified diff keyed
2647
+ // by tool_use id (never in the model-visible result). Attach it
2648
+ // here as a side-channel field so the TUI's expanded (ctrl+o)
2649
+ // raw view renders a colored +/- diff. The provider lowering
2650
+ // (anthropic/openai/etc.) never reads `uiDiff`, so the model
2651
+ // sees only `content` (the compact summary) — no token bloat.
2652
+ const _applyPatchUiDiff = _stripMcpPrefix(call.name) === 'apply_patch'
2653
+ ? takeApplyPatchUiDiff(call.id)
2654
+ : null;
2435
2655
  pushToolResultMessage({
2436
2656
  role: 'tool',
2437
2657
  content: result,
2438
2658
  toolCallId: call.id,
2439
2659
  toolKind: _resultKind,
2440
2660
  ...(_nativeToolSearch ? { nativeToolSearch: _nativeToolSearch } : {}),
2661
+ ...(_applyPatchUiDiff ? { uiDiff: _applyPatchUiDiff } : {}),
2441
2662
  });
2442
2663
  } catch (postErr) {
2443
2664
  _postProcessOk = false;
@@ -2453,7 +2674,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2453
2674
  toolKind,
2454
2675
  toolMs: toolEndedAt && toolStartedAt ? toolEndedAt - toolStartedAt : null,
2455
2676
  toolArgs: call.arguments,
2456
- role: sessionRef?.role || null,
2677
+ agent: sessionRef?.agent || null,
2457
2678
  model: sessionRef?.model || null,
2458
2679
  cwd,
2459
2680
  resultText: _postMsg,
@@ -2477,6 +2698,18 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2477
2698
  // discard the rest of the batch and skip the next provider.send.
2478
2699
  throwIfAborted();
2479
2700
  }
2701
+ // Flush the per-batch newMessages channel. All tool_results for this
2702
+ // assistant turn are now pushed; appending the injected role:'user'
2703
+ // messages here (AFTER the last tool_result, BEFORE the next provider
2704
+ // send) keeps provider pairing valid — no user message is interleaved
2705
+ // between tool(A) and tool(B). pre-send repairTranscriptBeforeProviderSend
2706
+ // normalizes any residual ordering. The injected messages carry their
2707
+ // own meta flag (e.g. meta:'skill') so compaction's latest-human-prompt
2708
+ // selection does not mistake them for the user's request.
2709
+ for (const _nm of _batchNewMessages) {
2710
+ if (!_nm || _nm.role !== 'user' || typeof _nm.content !== 'string' || !_nm.content) continue;
2711
+ messages.push({ role: 'user', content: _nm.content, ...(_nm.meta ? { meta: _nm.meta } : {}) });
2712
+ }
2480
2713
  // Mid-turn steering is drained at the next loop's pre-send point,
2481
2714
  // AFTER any auto-compact pass. Draining here would put the steering
2482
2715
  // user turn after the fresh tool results before compaction runs; then
@@ -2485,6 +2718,33 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2485
2718
  // About to re-send with tool results — transition back to connecting for the next turn.
2486
2719
  if (sessionId) updateSessionStage(sessionId, 'connecting');
2487
2720
  }
2721
+ // Classify WHY the loop ended so agent-tool can promote an empty/abnormal
2722
+ // finish to an explicit Lead-facing error instead of a silent empty
2723
+ // "completed". Determine "has content" exactly the way the no-tool-call
2724
+ // branch above does (trimmed string content, or any reasoning content).
2725
+ const _finalHasContent = (typeof response?.content === 'string' && response.content.trim().length > 0)
2726
+ || (typeof response?.reasoningContent === 'string' && response.reasoningContent.trim().length > 0);
2727
+ const _finalStopReason = response?.stopReason ?? response?.stop_reason ?? null;
2728
+ const _finalIncompleteStop = _finalStopReason && INCOMPLETE_STOP_REASONS.has(_finalStopReason);
2729
+ const _finalIsHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
2730
+ let terminationReason;
2731
+ if (terminatedByCap) {
2732
+ // Real problem regardless of hidden/public: the loop never terminated
2733
+ // on its own contract.
2734
+ terminationReason = 'iteration_cap';
2735
+ } else if (!_finalHasContent && _finalIncompleteStop) {
2736
+ // Cut short mid-synthesis (token cap / provider pause). Real problem
2737
+ // for hidden agents too.
2738
+ terminationReason = 'truncated';
2739
+ } else if (!_finalHasContent && !_finalIsHidden) {
2740
+ // Empty terminal turn. Only public agents violate their contract by
2741
+ // finishing empty — hidden agents (explorer/cycle/…) legitimately emit
2742
+ // text-only/empty terminal turns per their own role contract, so leave
2743
+ // terminationReason undefined for them.
2744
+ terminationReason = 'empty';
2745
+ } else {
2746
+ terminationReason = undefined;
2747
+ }
2488
2748
  return {
2489
2749
  ...response,
2490
2750
  usage: lastUsage || response.usage,
@@ -2493,5 +2753,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2493
2753
  iterations,
2494
2754
  toolCallsTotal,
2495
2755
  providerState,
2756
+ terminationReason,
2757
+ maxLoopIterations,
2496
2758
  };
2497
2759
  }