mixdog 0.8.1 → 0.9.1

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 (299) hide show
  1. package/README.md +47 -23
  2. package/package.json +34 -28
  3. package/scripts/_test-folder-dialog.mjs +30 -0
  4. package/scripts/agent-parallel-smoke.mjs +388 -0
  5. package/scripts/agent-tag-reuse-smoke.mjs +183 -0
  6. package/scripts/background-task-meta-smoke.mjs +38 -0
  7. package/scripts/boot-smoke.mjs +52 -9
  8. package/scripts/build-runtime-linux.sh +348 -0
  9. package/scripts/build-runtime-macos.sh +217 -0
  10. package/scripts/build-runtime-windows.ps1 +242 -0
  11. package/scripts/compact-active-turn-test.mjs +68 -0
  12. package/scripts/compact-smoke.mjs +859 -129
  13. package/scripts/compact-trigger-migration-smoke.mjs +187 -0
  14. package/scripts/fix-brief-fn.mjs +35 -0
  15. package/scripts/fix-format-tool-surface.mjs +24 -0
  16. package/scripts/fix-tool-exec-visible.mjs +42 -0
  17. package/scripts/generate-runtime-manifest.mjs +166 -0
  18. package/scripts/hook-bus-test.mjs +330 -0
  19. package/scripts/lead-workflow-smoke.mjs +33 -39
  20. package/scripts/live-worker-smoke.mjs +43 -37
  21. package/scripts/llm-trace-summary.mjs +315 -0
  22. package/scripts/memory-meta-concurrency-test.mjs +20 -0
  23. package/scripts/output-style-smoke.mjs +56 -15
  24. package/scripts/parent-abort-link-test.mjs +44 -0
  25. package/scripts/patch-agent-brief.mjs +48 -0
  26. package/scripts/patch-app.mjs +21 -0
  27. package/scripts/patch-app2.mjs +18 -0
  28. package/scripts/patch-dist-brief.mjs +96 -0
  29. package/scripts/patch-tool-exec.mjs +70 -0
  30. package/scripts/pretool-ask-runtime-test.mjs +54 -0
  31. package/scripts/provider-toolcall-test.mjs +376 -0
  32. package/scripts/reactive-compact-persist-smoke.mjs +124 -0
  33. package/scripts/sanitize-tool-pairs-test.mjs +260 -0
  34. package/scripts/session-context-bench.mjs +205 -33
  35. package/scripts/session-ingest-smoke.mjs +177 -0
  36. package/scripts/set-effort-config-test.mjs +41 -0
  37. package/scripts/smoke-runtime-negative.ps1 +106 -0
  38. package/scripts/smoke-runtime-negative.sh +97 -0
  39. package/scripts/smoke.mjs +25 -0
  40. package/scripts/tool-result-hook-test.mjs +48 -0
  41. package/scripts/tool-smoke.mjs +1222 -90
  42. package/scripts/toolcall-args-test.mjs +150 -0
  43. package/scripts/tui-background-failure-smoke.mjs +73 -0
  44. package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
  45. package/src/agents/debugger/AGENT.md +8 -0
  46. package/src/agents/explore/AGENT.md +4 -0
  47. package/src/agents/heavy-worker/AGENT.md +9 -3
  48. package/src/agents/maintainer/AGENT.md +4 -0
  49. package/src/agents/reviewer/AGENT.md +8 -0
  50. package/src/agents/scheduler-task/AGENT.md +12 -0
  51. package/src/agents/scheduler-task/agent.json +6 -0
  52. package/src/agents/webhook-handler/AGENT.md +12 -0
  53. package/src/agents/webhook-handler/agent.json +6 -0
  54. package/src/agents/worker/AGENT.md +9 -3
  55. package/src/app.mjs +77 -3
  56. package/src/defaults/hidden-roles.json +17 -12
  57. package/src/headless-role.mjs +117 -0
  58. package/src/help.mjs +30 -0
  59. package/src/hooks/lib/permission-evaluator.cjs +11 -475
  60. package/src/lib/keychain-cjs.cjs +9 -1
  61. package/src/lib/mixdog-debug.cjs +0 -29
  62. package/src/lib/plugin-paths.cjs +1 -7
  63. package/src/lib/rules-builder.cjs +242 -98
  64. package/src/lib/text-utils.cjs +1 -1
  65. package/src/mixdog-session-runtime.mjs +2211 -446
  66. package/src/output-styles/default.md +12 -28
  67. package/src/output-styles/extreme-simple.md +9 -6
  68. package/src/output-styles/simple.md +22 -9
  69. package/src/repl.mjs +108 -59
  70. package/src/rules/agent/00-common.md +15 -0
  71. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  72. package/src/rules/agent/30-explorer.md +22 -0
  73. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  75. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  76. package/src/rules/lead/01-general.md +9 -5
  77. package/src/rules/lead/04-workflow.md +51 -12
  78. package/src/rules/lead/lead-tool.md +6 -0
  79. package/src/rules/shared/01-tool.md +12 -1
  80. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  81. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  82. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  85. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  86. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  87. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  88. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  89. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  90. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  92. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  93. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  94. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  95. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +380 -268
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +131 -93
  97. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  98. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  100. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  101. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  102. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  103. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  104. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  107. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +203 -71
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +512 -312
  109. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -518
  110. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  111. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +289 -11
  113. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  114. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  115. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  116. package/src/runtime/agent/orchestrator/session/loop.mjs +840 -612
  117. package/src/runtime/agent/orchestrator/session/manager.mjs +1574 -459
  118. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  119. package/src/runtime/agent/orchestrator/session/store.mjs +67 -29
  120. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  121. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  122. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  123. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +35 -8
  124. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -8
  125. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -53
  126. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  127. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  130. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  133. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  138. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  139. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  140. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  141. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  142. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  143. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  146. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  147. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  148. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  149. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  150. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  151. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  152. package/src/runtime/channels/index.mjs +149 -51
  153. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  154. package/src/runtime/channels/lib/config.mjs +0 -1
  155. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  156. package/src/runtime/channels/lib/executor.mjs +0 -3
  157. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  158. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  159. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  160. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  161. package/src/runtime/channels/lib/session-discovery.mjs +3 -6
  162. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -12
  164. package/src/runtime/channels/lib/webhook.mjs +1 -1
  165. package/src/runtime/channels/tool-defs.mjs +29 -29
  166. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  167. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  168. package/src/runtime/memory/index.mjs +519 -118
  169. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  170. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  171. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  172. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  173. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  174. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  175. package/src/runtime/memory/lib/memory-cycle1.mjs +28 -7
  176. package/src/runtime/memory/lib/memory-cycle2.mjs +4 -5
  177. package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
  178. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -2
  179. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  180. package/src/runtime/memory/lib/memory.mjs +88 -9
  181. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  182. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  183. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  184. package/src/runtime/memory/lib/runtime-fetcher.mjs +80 -21
  185. package/src/runtime/memory/lib/session-ingest.mjs +196 -0
  186. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  187. package/src/runtime/memory/tool-defs.mjs +46 -37
  188. package/src/runtime/search/index.mjs +104 -473
  189. package/src/runtime/search/lib/config.mjs +0 -4
  190. package/src/runtime/search/lib/state.mjs +1 -15
  191. package/src/runtime/search/lib/web-tools.mjs +44 -25
  192. package/src/runtime/search/tool-defs.mjs +16 -23
  193. package/src/runtime/shared/abort-controller.mjs +1 -1
  194. package/src/runtime/shared/atomic-file.mjs +4 -3
  195. package/src/runtime/shared/background-tasks.mjs +122 -11
  196. package/src/runtime/shared/child-spawn-gate.mjs +139 -0
  197. package/src/runtime/shared/config.mjs +7 -4
  198. package/src/runtime/shared/err-text.mjs +131 -4
  199. package/src/runtime/shared/llm/cost.mjs +2 -2
  200. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  201. package/src/runtime/shared/llm/index.mjs +34 -11
  202. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  203. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  204. package/src/runtime/shared/singleton-owner.mjs +104 -0
  205. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  206. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  207. package/src/runtime/shared/tool-surface.mjs +624 -95
  208. package/src/runtime/shared/user-data-guard.mjs +0 -2
  209. package/src/standalone/agent-task-status.mjs +203 -0
  210. package/src/standalone/agent-task-status.test.mjs +76 -0
  211. package/src/standalone/agent-tool.mjs +1913 -0
  212. package/src/standalone/channel-worker.mjs +370 -14
  213. package/src/standalone/explore-tool.mjs +165 -70
  214. package/src/standalone/folder-dialog.mjs +314 -0
  215. package/src/standalone/hook-bus.mjs +898 -22
  216. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  217. package/src/standalone/projects.mjs +226 -0
  218. package/src/standalone/provider-admin.mjs +41 -24
  219. package/src/standalone/seeds.mjs +1 -78
  220. package/src/standalone/usage-dashboard.mjs +96 -8
  221. package/src/tui/App.jsx +4800 -2140
  222. package/src/tui/components/AnsiText.jsx +39 -28
  223. package/src/tui/components/ContextPanel.jsx +14 -4
  224. package/src/tui/components/Markdown.jsx +43 -77
  225. package/src/tui/components/MarkdownTable.jsx +9 -184
  226. package/src/tui/components/Message.jsx +28 -11
  227. package/src/tui/components/Picker.jsx +95 -56
  228. package/src/tui/components/PromptInput.jsx +428 -239
  229. package/src/tui/components/QueuedCommands.jsx +1 -1
  230. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  231. package/src/tui/components/Spinner.jsx +67 -38
  232. package/src/tui/components/StatusLine.jsx +606 -38
  233. package/src/tui/components/TextEntryPanel.jsx +128 -9
  234. package/src/tui/components/ToolExecution.jsx +597 -362
  235. package/src/tui/components/TurnDone.jsx +3 -3
  236. package/src/tui/components/UsagePanel.jsx +3 -5
  237. package/src/tui/components/tool-output-format.mjs +499 -0
  238. package/src/tui/components/tool-output-format.test.mjs +312 -0
  239. package/src/tui/dist/index.mjs +9192 -2399
  240. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  241. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  242. package/src/tui/engine.mjs +1455 -279
  243. package/src/tui/figures.mjs +21 -40
  244. package/src/tui/index.jsx +75 -31
  245. package/src/tui/input-editing.mjs +25 -0
  246. package/src/tui/markdown/format-token.mjs +671 -69
  247. package/src/tui/markdown/format-token.test.mjs +312 -0
  248. package/src/tui/markdown/render-ansi.mjs +94 -0
  249. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  250. package/src/tui/markdown/stream-fence.mjs +34 -0
  251. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  252. package/src/tui/markdown/table-layout.mjs +250 -0
  253. package/src/tui/paste-attachments.mjs +0 -7
  254. package/src/tui/spinner-verbs.mjs +1 -2
  255. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  256. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  257. package/src/tui/theme.mjs +756 -24
  258. package/src/tui/time-format.mjs +1 -1
  259. package/src/tui/transcript-tool-failures.mjs +67 -0
  260. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  261. package/src/ui/ansi.mjs +1 -2
  262. package/src/ui/markdown.mjs +85 -26
  263. package/src/ui/markdown.test.mjs +70 -0
  264. package/src/ui/model-display.mjs +121 -0
  265. package/src/ui/session-stats.mjs +44 -0
  266. package/src/ui/statusline-context-label.test.mjs +15 -0
  267. package/src/ui/statusline.mjs +386 -178
  268. package/src/ui/tool-card.mjs +2 -13
  269. package/src/vendor/statusline/bin/statusline-lib.mjs +1 -620
  270. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  271. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  272. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  273. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  274. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  275. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  276. package/src/workflows/default/WORKFLOW.md +12 -5
  277. package/src/workflows/default/workflow.json +0 -1
  278. package/src/workflows/solo/WORKFLOW.md +15 -0
  279. package/src/workflows/solo/workflow.json +7 -0
  280. package/vendor/ink/build/ink.js +54 -8
  281. package/vendor/ink/build/output.js +6 -1
  282. package/src/agents/scheduler-task.md +0 -3
  283. package/src/agents/web-researcher/AGENT.md +0 -3
  284. package/src/agents/web-researcher/agent.json +0 -6
  285. package/src/agents/webhook-handler.md +0 -3
  286. package/src/hooks/lib/permission-rules.cjs +0 -170
  287. package/src/hooks/lib/settings-loader.cjs +0 -112
  288. package/src/lib/hook-pipe-path.cjs +0 -10
  289. package/src/rules/bridge/00-common.md +0 -5
  290. package/src/rules/bridge/30-explorer.md +0 -4
  291. package/src/rules/lead/00-tool-lead.md +0 -5
  292. package/src/rules/shared/00-language.md +0 -3
  293. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  294. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  295. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  296. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  297. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  298. package/src/standalone/bridge-tool.mjs +0 -1414
  299. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -5,35 +5,44 @@ import { join } from 'path';
5
5
  import { getProvider, providerInputExcludesCache } from '../providers/registry.mjs';
6
6
  import { getModelMetadataSync } from '../providers/model-catalog.mjs';
7
7
  import { fetchOAuthUsageSnapshot } from '../providers/oauth-usage.mjs';
8
- import { agentLoop } from './loop.mjs';
8
+ // Image content is kept in-memory and in the model-visible history so multi-turn
9
+ // recognition matches reference agent behavior (live transcript always retains images). The
10
+ // stored-history placeholder swap now happens only at disk-serialization time
11
+ // inside the session store, so it is no longer imported here.
9
12
  import {
10
- compactActiveTurn,
11
- compactMessages,
13
+ recallFastTrackCompactMessages,
12
14
  semanticCompactMessages,
15
+ compactTypeIsRecallFastTrack,
16
+ compactTypeIsSemantic,
17
+ normalizeCompactType,
18
+ DEFAULT_COMPACT_TYPE,
19
+ SUMMARY_PREFIX,
13
20
  DEFAULT_COMPACTION_BUFFER_RATIO,
14
21
  compactionBufferTokensForBoundary,
15
22
  normalizeCompactionBufferRatio,
23
+ drainSessionCycle1,
16
24
  } from './compact.mjs';
17
- import { estimateMessagesTokens, estimateRequestReserveTokens } from './context-utils.mjs';
25
+ import { estimateMessagesTokens, estimateRequestReserveTokens, estimateTranscriptContextUsage } from './context-utils.mjs';
18
26
  import { getMcpTools } from '../mcp/client.mjs';
19
27
  import { getInternalTools, executeInternalTool } from '../internal-tools.mjs';
20
- import { BUILTIN_TOOLS } from '../tools/builtin.mjs';
28
+ import { BUILTIN_TOOLS } from '../tools/builtin/builtin-tools.mjs';
21
29
  import { PATCH_TOOL_DEFS } from '../tools/patch-tool-defs.mjs';
22
30
  import { CODE_GRAPH_TOOL_DEFS } from '../tools/code-graph-tool-defs.mjs';
23
- import { executeCodeGraphTool } from '../tools/code-graph.mjs';
24
- import { closeBashSession } from '../tools/bash-session.mjs';
25
- import { collectSkillsCached, buildSkillToolDefs, loadAgentTemplate, loadRoleTemplate, composeSystemPrompt, collectMixdogMd } from '../context/collect.mjs';
31
+ import { collectSkillsCached, buildSkillManifest, buildSkillToolDefs, composeSystemPrompt } from '../context/collect.mjs';
26
32
  import { saveSession, saveSessionAsync, loadSession, listStoredSessionSummaries, sweepStaleSessions, markSessionClosed, publishHeartbeat, deleteHeartbeat, setLiveSession } from './store.mjs';
27
33
  import { clearReadDedupSession, tryPrefetchCached, setPrefetchCached } from './read-dedup.mjs';
28
34
  import { clearOffloadSession } from './tool-result-offload.mjs';
29
35
  import { classifyResultKind } from './result-classification.mjs';
30
36
  import { createAbortController } from '../../../shared/abort-controller.mjs';
37
+ import { isInternalRuntimeNotificationText as contractIsInternalRuntimeNotificationText } from '../../../shared/tool-execution-contract.mjs';
31
38
  import { logLlmCall } from '../../../shared/llm/usage-log.mjs';
32
39
  import { resolvePluginData, mixdogRoot } from '../../../shared/plugin-paths.mjs';
33
40
  import { updateJsonAtomicSync } from '../../../shared/atomic-file.mjs';
34
- import { appendBridgeTrace } from '../bridge-trace.mjs';
35
- import { maxMtime, maxMtimeRecursive } from '../cache-mtime.mjs';
36
- import { getRoleInstructionDir } from '../internal-roles.mjs';
41
+ import { appendAgentTrace } from '../agent-trace.mjs';
42
+ import { isAgentOwner } from '../agent-owner.mjs';
43
+ import { maxMtimeRecursive } from '../cache-mtime.mjs';
44
+ import { getHiddenRole, getRoleInstructionDir, listHiddenRoleNames } from '../internal-roles.mjs';
45
+ import { DEFAULT_ACTIVITY_HEARTBEAT_MS } from '../stall-policy.mjs';
37
46
  import {
38
47
  buildGatewayLimits,
39
48
  recordGatewayUsageEvent,
@@ -53,80 +62,134 @@ const _rulesBuilder = (() => {
53
62
  try { return _require('../../../../lib/rules-builder.cjs'); } catch { return null; }
54
63
  })();
55
64
 
56
- // bridgeRules is the bridge shared prefix (shared rules + bridge common rules +
57
- // user agent configs). It's rebuilt from disk
58
- // by rules-builder.cjs on every call; since createSession fires on every
59
- // Pool B/C bridge turn, that's a lot of redundant readFileSync + concat.
60
- // BP1/BP3 cache — invalidated by source file mtime, not a timer.
61
- // Cheap: O(sentinel-count) stat calls on each bridge turn, no I/O otherwise.
62
- // BP1 cache — single shared entry. buildBridgeInjectionContent is
63
- // role-agnostic (true cross-role common), so every bridge role reuses the
64
- // same prefix bytes.
65
- let _bridgeRulesCache = null;
66
- let _bridgeRulesMtime = 0;
67
- function _buildBridgeRules() {
68
- if (!_rulesBuilder || typeof _rulesBuilder.buildBridgeInjectionContent !== 'function') return '';
65
+ // BP1/BP2/BP3 prompt-layer caches invalidated by source file mtime, not a
66
+ // timer. Cheap: O(sentinel-count) stat calls on each session creation, no file
67
+ // I/O when warm.
68
+ let _sharedRulesCache = null;
69
+ let _sharedRulesMtime = 0;
70
+ const _agentRulesCacheByProfile = new Map();
71
+ let _leadRulesCache = null;
72
+ let _leadRulesMtime = 0;
73
+ let _leadMetaCache = null;
74
+ let _leadMetaMtime = 0;
75
+ let _codeGraphRuntimePromise = null;
76
+ let _agentLoopPromise = null;
77
+ let _bashSessionRuntimePromise = null;
78
+ async function _executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
79
+ _codeGraphRuntimePromise ??= import('../tools/code-graph.mjs');
80
+ const mod = await _codeGraphRuntimePromise;
81
+ if (typeof mod.executeCodeGraphTool !== 'function') throw new Error('code_graph runtime is not available');
82
+ return mod.executeCodeGraphTool(name, args, cwd, signal, options);
83
+ }
84
+ async function _getAgentLoop() {
85
+ _agentLoopPromise ??= import('./loop.mjs');
86
+ const mod = await _agentLoopPromise;
87
+ if (typeof mod.agentLoop !== 'function') throw new Error('agent loop runtime is not available');
88
+ return mod.agentLoop;
89
+ }
90
+ function _closeBashSessionLazy(sessionId, reason) {
91
+ if (!sessionId) return;
92
+ _bashSessionRuntimePromise ??= import('../tools/bash-session.mjs');
93
+ _bashSessionRuntimePromise
94
+ .then((mod) => { if (typeof mod.closeBashSession === 'function') mod.closeBashSession(sessionId, reason); })
95
+ .catch(() => {});
96
+ }
97
+ function _buildSharedRules() {
98
+ if (!_rulesBuilder || typeof _rulesBuilder.buildSharedToolContent !== 'function') return '';
69
99
  const PLUGIN_ROOT = mixdogRoot();
70
- const DATA_DIR = resolvePluginData();
71
100
  const RULES_DIR = join(PLUGIN_ROOT, 'rules');
72
101
  const mtime = maxMtimeRecursive([
73
102
  join(RULES_DIR, 'shared'),
74
- join(RULES_DIR, 'bridge'),
75
- join(DATA_DIR, 'roles'),
103
+ ]);
104
+ if (_sharedRulesCache !== null && mtime <= _sharedRulesMtime) {
105
+ return _sharedRulesCache;
106
+ }
107
+ try {
108
+ const built = _rulesBuilder.buildSharedToolContent({ PLUGIN_ROOT, DATA_DIR: resolvePluginData() });
109
+ _sharedRulesCache = built;
110
+ _sharedRulesMtime = mtime;
111
+ return built;
112
+ } catch (e) {
113
+ throw new Error(`[session] shared tool rules build failed: ${e.message}`);
114
+ }
115
+ }
116
+
117
+ function _buildAgentRules(profile = 'full') {
118
+ if (!_rulesBuilder || typeof _rulesBuilder.buildAgentRoleContent !== 'function') return '';
119
+ const key = String(profile || 'full');
120
+ const PLUGIN_ROOT = mixdogRoot();
121
+ const DATA_DIR = resolvePluginData();
122
+ const RULES_DIR = join(PLUGIN_ROOT, 'rules');
123
+ const mtime = maxMtimeRecursive([
124
+ join(RULES_DIR, 'agent'),
76
125
  join(DATA_DIR, 'mixdog-config.json'),
77
126
  ]);
78
- if (_bridgeRulesCache !== null && mtime <= _bridgeRulesMtime) {
79
- return _bridgeRulesCache;
127
+ const cached = _agentRulesCacheByProfile.get(key);
128
+ if (cached && mtime <= cached.mtime) {
129
+ return cached.value;
80
130
  }
81
131
  try {
82
- const built = _rulesBuilder.buildBridgeInjectionContent({ PLUGIN_ROOT, DATA_DIR });
83
- _bridgeRulesCache = built;
84
- _bridgeRulesMtime = mtime;
132
+ const built = _rulesBuilder.buildAgentRoleContent({ PLUGIN_ROOT, DATA_DIR, profile: key });
133
+ _agentRulesCacheByProfile.set(key, { mtime, value: built });
85
134
  return built;
86
135
  } catch (e) {
87
- throw new Error(`[session] bridge common rules build failed: ${e.message}`);
136
+ throw new Error(`[session] agent role rules build failed: ${e.message}`);
88
137
  }
89
138
  }
90
139
 
91
- let _leadRulesCache = null;
92
- let _leadRulesMtime = 0;
93
140
  function _buildLeadRules() {
94
- if (!_rulesBuilder || typeof _rulesBuilder.buildInjectionContent !== 'function') return '';
141
+ if (!_rulesBuilder || typeof _rulesBuilder.buildLeadRoleContent !== 'function') return '';
95
142
  const PLUGIN_ROOT = mixdogRoot();
96
143
  const DATA_DIR = resolvePluginData();
97
144
  const RULES_DIR = join(PLUGIN_ROOT, 'rules');
98
- const mtime = Math.max(maxMtime([
99
- PLUGIN_ROOT,
100
- DATA_DIR,
101
- ]), maxMtimeRecursive([
102
- join(RULES_DIR, 'shared'),
145
+ const mtime = maxMtimeRecursive([
103
146
  join(RULES_DIR, 'lead'),
104
- join(DATA_DIR, 'history'),
105
147
  join(DATA_DIR, 'mixdog-config.json'),
106
- join(DATA_DIR, 'user-workflow.md'),
107
- join(DATA_DIR, 'user-workflow.json'),
108
- join(PLUGIN_ROOT, 'output-styles'),
109
- join(DATA_DIR, 'output-styles'),
110
- ]));
148
+ ]);
111
149
  if (_leadRulesCache !== null && mtime <= _leadRulesMtime) {
112
150
  return _leadRulesCache;
113
151
  }
114
152
  try {
115
- const built = _rulesBuilder.buildInjectionContent({ PLUGIN_ROOT, DATA_DIR });
153
+ const built = _rulesBuilder.buildLeadRoleContent({ PLUGIN_ROOT, DATA_DIR });
116
154
  _leadRulesCache = built;
117
155
  _leadRulesMtime = mtime;
118
156
  return built;
119
157
  } catch (e) {
120
- throw new Error(`[session] lead rules build failed: ${e.message}`);
158
+ throw new Error(`[session] lead role rules build failed: ${e.message}`);
159
+ }
160
+ }
161
+
162
+ function _buildLeadMetaContext() {
163
+ if (!_rulesBuilder || typeof _rulesBuilder.buildLeadMetaContent !== 'function') return '';
164
+ const PLUGIN_ROOT = mixdogRoot();
165
+ const DATA_DIR = resolvePluginData();
166
+ const RULES_DIR = join(PLUGIN_ROOT, 'rules');
167
+ const mtime = maxMtimeRecursive([
168
+ join(RULES_DIR, 'lead'),
169
+ join(DATA_DIR, 'history'),
170
+ join(DATA_DIR, 'mixdog-config.json'),
171
+ join(DATA_DIR, 'user-workflow.md'),
172
+ join(PLUGIN_ROOT, 'output-styles'),
173
+ join(DATA_DIR, 'output-styles'),
174
+ ]);
175
+ if (_leadMetaCache !== null && mtime <= _leadMetaMtime) {
176
+ return _leadMetaCache;
177
+ }
178
+ try {
179
+ const built = _rulesBuilder.buildLeadMetaContent({ PLUGIN_ROOT, DATA_DIR });
180
+ _leadMetaCache = built;
181
+ _leadMetaMtime = mtime;
182
+ return built;
183
+ } catch (e) {
184
+ throw new Error(`[session] lead meta context build failed: ${e.message}`);
121
185
  }
122
186
  }
123
187
 
124
- // BP3 role-specific cache — keyed by role. webhook / schedule / hidden
125
- // retrieval roles each have their own scoped instruction set; other roles
126
- // return ''.
188
+ // BP4-adjacent role-specific data cache — keyed by role. webhook / schedule
189
+ // roles each have their own scoped instruction set; other roles return ''.
127
190
  const _roleSpecificCache = new Map(); // role → { value, mtime }
128
191
  function _buildRoleSpecific(currentRole) {
129
- if (!_rulesBuilder || typeof _rulesBuilder.buildBridgeRoleSpecificContent !== 'function') return '';
192
+ if (!_rulesBuilder || typeof _rulesBuilder.buildAgentRoleSpecificContent !== 'function') return '';
130
193
  if (!currentRole) return '';
131
194
  const PLUGIN_ROOT = mixdogRoot();
132
195
  const DATA_DIR = resolvePluginData();
@@ -145,7 +208,7 @@ function _buildRoleSpecific(currentRole) {
145
208
  return entry.value;
146
209
  }
147
210
  try {
148
- const built = _rulesBuilder.buildBridgeRoleSpecificContent({ PLUGIN_ROOT, DATA_DIR, currentRole });
211
+ const built = _rulesBuilder.buildAgentRoleSpecificContent({ PLUGIN_ROOT, DATA_DIR, currentRole });
149
212
  _roleSpecificCache.set(currentRole, { mtime, value: built });
150
213
  return built;
151
214
  } catch (e) {
@@ -153,27 +216,27 @@ function _buildRoleSpecific(currentRole) {
153
216
  }
154
217
  }
155
218
 
156
- // Smart Bridge is optional — injected via setSmartBridge() during plugin init
219
+ // Agent Runtime is optional — injected via setAgentRuntime() during plugin init
157
220
  // so session creation never depends on a circular import. If never injected,
158
221
  // createSession simply falls back to classic preset-only behavior.
159
- let _smartBridgeApi = null;
160
- let _smartBridgeWarned = false;
222
+ let _agentRuntimeApi = null;
223
+ let _agentRuntimeWarned = false;
161
224
 
162
225
  /**
163
- * Inject the Smart Bridge singleton. Called once by agent/index.mjs init()
164
- * after initSmartBridge(). Safe to call multiple times — later calls
226
+ * Inject the Agent Runtime singleton. Called once by agent/index.mjs init()
227
+ * after initAgentRuntime(). Safe to call multiple times — later calls
165
228
  * replace the previous reference.
166
229
  */
167
- export function setSmartBridge(api) {
168
- _smartBridgeApi = api || null;
230
+ export function setAgentRuntime(api) {
231
+ _agentRuntimeApi = api || null;
169
232
  }
170
233
 
171
- function getSmartBridgeSync() {
172
- return _smartBridgeApi;
234
+ function getAgentRuntimeSync() {
235
+ return _agentRuntimeApi;
173
236
  }
174
237
 
175
238
  /**
176
- * Thrown when a session is closed while a call is in-flight. Callers (bridge
239
+ * Thrown when a session is closed while a call is in-flight. Callers (agent
177
240
  * handler, CLI) should render this as "cancelled" rather than a hard error.
178
241
  */
179
242
  export class SessionClosedError extends Error {
@@ -189,9 +252,15 @@ export class SessionClosedError extends Error {
189
252
  }
190
253
  }
191
254
  const HEARTBEAT_THROTTLE_MS = 60_000; // 60s
255
+ // Cap how long the terminal unwind blocks on the post-result session save.
256
+ // The result is already produced (and relayed for agent surfaces) before this
257
+ // save, so a stalled disk write must not hold askSession() open — otherwise the
258
+ // owning background task is stranded in `running` and its completion
259
+ // notification never fires. A slow write finishes in the background.
260
+ const TERMINAL_SAVE_TIMEOUT_MS = nonNegativeIntEnv('MIXDOG_TERMINAL_SAVE_TIMEOUT_MS', 5_000);
192
261
 
193
262
  // Merge externally-connected MCP tools with the plugin's in-process tools
194
- // (registered by agent's toolExecutor bridge). Internal tools are exposed
263
+ // (registered by agent's toolExecutor adapter). Internal tools are exposed
195
264
  // under their bare names — no mcp__ prefix, since the dispatcher in
196
265
  // server.mjs handles them directly without a transport.
197
266
  // Sorted deterministically by name — protects BP_1 hash stability from
@@ -208,7 +277,7 @@ function _getMcpTools() {
208
277
  inputSchema: t.inputSchema || { type: 'object', properties: {} },
209
278
  // Keep annotations so the permission filter / role invariants can
210
279
  // tell read-only from write-capable internal tools, and so
211
- // bridgeHidden can be read during deny filtering.
280
+ // agentHidden can be read during deny filtering.
212
281
  annotations: t.annotations || {},
213
282
  }));
214
283
  return [...mcp, ...internal].sort((a, b) => {
@@ -233,7 +302,7 @@ function _getMcpTools() {
233
302
  // adding a new toolset id here is a localised change.
234
303
  //
235
304
  // Unified-shard policy — the session's tool array normally never narrows
236
- // with permission or role. Bridge sessions share the same schema so BP_1
305
+ // with permission or role. Agent sessions share the same schema so BP_1
237
306
  // stays bit-identical and the provider-side cache shard is shared
238
307
  // workspace-wide. Rare specialist roles may pass schemaAllowedTools from a
239
308
  // declarative hidden-role toolSchemaProfile to keep their first-turn routing
@@ -242,6 +311,7 @@ function _getMcpTools() {
242
311
 
243
312
  const SESSION_ROUTE_TOOL_ORDER = [
244
313
  'code_graph',
314
+ 'find',
245
315
  'glob',
246
316
  'list',
247
317
  'grep',
@@ -253,6 +323,7 @@ const SESSION_ROUTE_TOOL_ORDER = [
253
323
  const SESSION_ROUTE_TOOL_RANK = new Map(SESSION_ROUTE_TOOL_ORDER.map((name, index) => [name, index]));
254
324
  const FILESYSTEM_TOOL_NAMES = new Set([
255
325
  'code_graph',
326
+ 'find',
256
327
  'glob',
257
328
  'list',
258
329
  'grep',
@@ -261,12 +332,115 @@ const FILESYSTEM_TOOL_NAMES = new Set([
261
332
  ]);
262
333
  const READONLY_TOOL_NAMES = new Set([
263
334
  'code_graph',
335
+ 'find',
336
+ 'glob',
337
+ 'list',
338
+ 'grep',
339
+ 'read',
340
+ ]);
341
+
342
+ const AGENT_STRING_PERMISSION_READ_ALLOW = Object.freeze([
343
+ 'code_graph',
344
+ 'find',
264
345
  'glob',
265
346
  'list',
266
347
  'grep',
267
348
  'read',
349
+ 'explore',
350
+ 'search',
351
+ 'web_fetch',
268
352
  ]);
269
353
 
354
+ function stringToolPermissionAllowList(toolPermission) {
355
+ if (toolPermission === 'read') return AGENT_STRING_PERMISSION_READ_ALLOW;
356
+ if (toolPermission === 'read-write') return AGENT_STRING_PERMISSION_READ_WRITE_ALLOW;
357
+ if (toolPermission === 'none') return [];
358
+ return null;
359
+ }
360
+
361
+ const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
362
+ 'code_graph',
363
+ 'find',
364
+ 'glob',
365
+ 'list',
366
+ 'grep',
367
+ 'read',
368
+ 'apply_patch',
369
+ 'explore',
370
+ 'search',
371
+ 'web_fetch',
372
+ ]);
373
+
374
+ function applyToolPermissionNarrowing(tools, toolPermission, warnRole = null) {
375
+ if (toolPermission === 'none') return [];
376
+ const allowList = stringToolPermissionAllowList(toolPermission);
377
+ if (allowList) {
378
+ const allowSet = new Set(allowList.map((n) => String(n).toLowerCase()));
379
+ return tools.filter((t) => allowSet.has(String(t?.name || '').toLowerCase()));
380
+ }
381
+ if (toolPermission && typeof toolPermission === 'object') {
382
+ const allowSet = Array.isArray(toolPermission.allow) && toolPermission.allow.length > 0
383
+ ? new Set(toolPermission.allow.map(n => String(n).toLowerCase()))
384
+ : null;
385
+ const denySet = Array.isArray(toolPermission.deny) && toolPermission.deny.length > 0
386
+ ? new Set(toolPermission.deny.map(n => String(n).toLowerCase()))
387
+ : null;
388
+ if (allowSet || denySet) {
389
+ const filtered = tools.filter(t => {
390
+ const name = String(t?.name || '').toLowerCase();
391
+ if (denySet && denySet.has(name)) return false;
392
+ if (allowSet && !allowSet.has(name)) return false;
393
+ return true;
394
+ });
395
+ if (filtered.length === 0) {
396
+ process.stderr.write(`[session] WARN: role permission intersection produced 0 tools — failing closed (role=${warnRole || 'unknown'})\n`);
397
+ }
398
+ return filtered;
399
+ }
400
+ }
401
+ return tools;
402
+ }
403
+
404
+ function recursiveWrapperToolNameForPublicAgentRole(role) {
405
+ if (!role) return null;
406
+ const key = String(role).trim();
407
+ for (const hiddenName of listHiddenRoleNames()) {
408
+ const def = getHiddenRole(hiddenName);
409
+ const invokedBy = typeof def?.invokedBy === 'string' ? def.invokedBy.trim() : '';
410
+ if (invokedBy && invokedBy === key) return invokedBy;
411
+ }
412
+ return null;
413
+ }
414
+
415
+ function finalizeSessionToolList(tools, {
416
+ schemaAllowedTools = null,
417
+ disallowedTools = null,
418
+ ownerIsAgent = false,
419
+ resolvedRole = null,
420
+ } = {}) {
421
+ let out = Array.isArray(tools) ? tools : [];
422
+ const hasCallerAllow = Array.isArray(schemaAllowedTools);
423
+ if (hasCallerAllow) {
424
+ const allowSet = new Set(schemaAllowedTools.map(n => String(n).toLowerCase()));
425
+ out = out.filter(t => allowSet.has(String(t?.name || '').toLowerCase()));
426
+ }
427
+ const callerDeny = Array.isArray(disallowedTools) ? disallowedTools.map(n => String(n)) : [];
428
+ if (callerDeny.length) {
429
+ const denySet = new Set(callerDeny.map(n => n.toLowerCase()));
430
+ out = out.filter(t => !denySet.has(String(t?.name || '').toLowerCase()));
431
+ }
432
+ const recursiveDeny = ownerIsAgent ? recursiveWrapperToolNameForPublicAgentRole(resolvedRole) : null;
433
+ if (recursiveDeny) {
434
+ const deny = recursiveDeny.toLowerCase();
435
+ out = out.filter(t => String(t?.name || '').toLowerCase() !== deny);
436
+ }
437
+ if (ownerIsAgent) {
438
+ out = out.filter(t => !t?.annotations?.agentHidden);
439
+ out = orderSessionTools(out);
440
+ }
441
+ return out;
442
+ }
443
+
270
444
  function orderSessionTools(tools) {
271
445
  return tools.map((tool, index) => ({ tool, index }))
272
446
  .sort((a, b) => {
@@ -284,13 +458,13 @@ const ALL_BUILTIN_SESSION_TOOLS = orderSessionTools(_dedupByName([
284
458
  ...CODE_GRAPH_TOOL_DEFS,
285
459
  ]));
286
460
 
287
- function resolveSessionTools(toolSpec, skills, { ownerIsBridge = false } = {}) {
461
+ function resolveSessionTools(toolSpec, skills, { ownerIsAgentSession = false } = {}) {
288
462
  const mcp = _getMcpTools();
289
- // Bridge sessions freeze the 3 skill meta-tools into the schema
463
+ // Agent sessions freeze the skill meta-tool into the schema
290
464
  // unconditionally — concrete skill resolution is cwd-scoped at tool-call
291
465
  // time (loop.mjs), so the schema bytes stay bit-identical across roles /
292
466
  // cwds and the provider cache shard does not fragment.
293
- const skillTools = buildSkillToolDefs(skills, { ownerIsBridge });
467
+ const skillTools = buildSkillToolDefs(skills, { ownerIsAgentSession });
294
468
  return _computeBaseTools(toolSpec, mcp, skillTools);
295
469
  }
296
470
 
@@ -314,8 +488,8 @@ function _dedupByName(tools) {
314
488
  return [...seen.values()];
315
489
  }
316
490
 
317
- // Bridge visibility is declared per-tool via annotations.bridgeHidden.
318
- // Tools with bridgeHidden:true are stripped from bridge sessions at schema
491
+ // Agent visibility is declared per-tool via annotations.agentHidden.
492
+ // Tools with agentHidden:true are stripped from agent sessions at schema
319
493
  // build time (see deny filtering below). No code-level name list needed.
320
494
 
321
495
  function _computeBaseTools(toolSpec, mcp, skillTools) {
@@ -356,7 +530,7 @@ function _computeBaseTools(toolSpec, mcp, skillTools) {
356
530
  // Name-pattern match: picks up `search` and any future tool
357
531
  // whose name contains `search`. `recall` and `explore` deliberately do NOT match
358
532
  // — they need `tools:mcp` (full mcp surface) or their own
359
- // toolset id if a role wants targeted retrieval. Public bridge
533
+ // toolset id if a role wants targeted retrieval. Public agent
360
534
  // roles never reach the wrapper bodies regardless: see the
361
535
  // isBlockedPublicWrapperCall guard in session/loop.mjs.
362
536
  addMany(mcp.filter(t => /search/i.test(t?.name || '')));
@@ -403,7 +577,7 @@ let nextId = Date.now();
403
577
  // this map trimmed to live models; older generations slow down reads
404
578
  // without buying anything.
405
579
  const CONTEXT_WINDOWS = {
406
- // OpenAI GPT-5.x family
580
+ // OpenAI GPT-5.x family (openai / openai-oauth)
407
581
  'gpt-5.5': 272000,
408
582
  'gpt-5.4': 272000,
409
583
  'gpt-5.4-mini': 272000,
@@ -418,12 +592,33 @@ const CONTEXT_WINDOWS = {
418
592
  'gemini-3-pro': 1000000,
419
593
  'gemini-3.5-flash': 1000000,
420
594
  'gemini-3-flash': 1000000,
595
+ // xAI Grok (catalog polyfill mirror — model-catalog PRICING_OVERRIDES)
596
+ 'grok-build-0.1': 256000,
597
+ 'grok-4.20': 1000000,
421
598
  };
599
+ // Family-pattern fallback used only when both the provider catalog and the
600
+ // exact-id table miss (cold metadata, before the LiteLLM/models.dev catalog
601
+ // warms). Keep these aligned with the catalog so /context, gateway, and the
602
+ // runtime agree on the boundary the first time a model is routed. Local models
603
+ // (llama/mistral/phi/qwen/gemma) stay small so an unknown local id never claims
604
+ // a giant window.
422
605
  function guessContextWindow(model) {
423
606
  if (CONTEXT_WINDOWS[model])
424
607
  return CONTEXT_WINDOWS[model];
425
- if (model.includes('llama') || model.includes('mistral') || model.includes('phi'))
608
+ const m = String(model || '').toLowerCase();
609
+ // Local/self-hosted families — never inflate an unknown local id.
610
+ if (m.includes('llama') || m.includes('mistral') || m.includes('mixtral')
611
+ || m.includes('phi') || m.includes('qwen') || m.includes('gemma')
612
+ || m.includes('deepseek-r1') || m.includes('codellama'))
426
613
  return 8192;
614
+ // Current hosted families by name pattern.
615
+ if (m.startsWith('claude-opus') || m.startsWith('claude-sonnet')) return 1000000;
616
+ if (m.startsWith('claude-haiku') || m.startsWith('claude-')) return 200000;
617
+ if (m.startsWith('gemini-3') || m.startsWith('gemini-2')) return 1000000;
618
+ if (m.startsWith('gpt-5')) return 272000;
619
+ if (m.startsWith('grok-build')) return 256000;
620
+ if (m.startsWith('grok-')) return 1000000;
621
+ if (m.startsWith('deepseek-v')) return 1000000;
427
622
  return 128000;
428
623
  }
429
624
  function positiveContextWindow(value) {
@@ -444,26 +639,108 @@ function providerNameOf(provider) {
444
639
  if (typeof provider === 'string') return provider.toLowerCase();
445
640
  return String(provider?.name || provider?.id || '').toLowerCase();
446
641
  }
447
- function compactBufferRatioForSession(session) {
448
- const cfg = session?.compaction || {};
449
- return normalizeCompactionBufferRatio(
450
- cfg.bufferPercent
451
- ?? cfg.bufferPct
452
- ?? cfg.bufferRatio
453
- ?? cfg.bufferFraction
454
- ?? process.env.MIXDOG_BRIDGE_COMPACT_BUFFER_PERCENT
455
- ?? process.env.MIXDOG_BRIDGE_COMPACT_BUFFER_RATIO,
456
- DEFAULT_COMPACTION_BUFFER_RATIO,
642
+ // Buffer-percent parsing trap: normalizeCompactionBufferRatio treats any value
643
+ // > 1 as a percent (n/100) and any value <= 1 as a literal ratio. That is wrong
644
+ // for percent-NAMED inputs: bufferPercent / bufferPct / *_BUFFER_PERCENT = 1
645
+ // means 1% (0.01), but the shared normalizer would read it as the literal ratio
646
+ // 1.0 (100%). Resolve percent-named and ratio-named inputs with the correct
647
+ // semantics here, before handing a finished ratio to the shared helper:
648
+ // percent inputs: n -> n/100 (1 -> 0.01, 10 -> 0.10)
649
+ // ratio inputs: n -> n>1 ? n/100 : n (0.01 -> 0.01, 10 -> 0.10 legacy)
650
+ function resolveBufferRatioCandidate(percentInputs, ratioInputs) {
651
+ for (const raw of percentInputs) {
652
+ const n = Number(raw);
653
+ if (Number.isFinite(n) && n > 0) return Math.min(1, n / 100);
654
+ }
655
+ for (const raw of ratioInputs) {
656
+ const n = Number(raw);
657
+ if (Number.isFinite(n) && n > 0) return n > 1 ? n / 100 : n;
658
+ }
659
+ return null;
660
+ }
661
+ function compactBufferRatioForConfig(cfg = {}) {
662
+ const resolved = resolveBufferRatioCandidate(
663
+ [cfg.bufferPercent, cfg.bufferPct, process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT],
664
+ [cfg.bufferRatio, cfg.bufferFraction, process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO],
457
665
  );
666
+ return normalizeCompactionBufferRatio(resolved, DEFAULT_COMPACTION_BUFFER_RATIO);
667
+ }
668
+ function compactBufferRatioForSession(session) {
669
+ return compactBufferRatioForConfig(session?.compaction || {});
670
+ }
671
+ // Carry the percent/ratio-named buffer config from a compaction config object
672
+ // onto session.compaction so downstream parsers (compactBufferRatioForSession,
673
+ // loop.resolveCompactBufferRatio, contextStatus) honor a configured buffer
674
+ // percent/ratio. Only finite positive values are copied; absent fields stay
675
+ // undefined so the default-ratio fallback still applies.
676
+ function preserveBufferConfigFields(cfg = {}) {
677
+ const out = {};
678
+ for (const key of ['bufferPercent', 'bufferPct', 'bufferRatio', 'bufferFraction']) {
679
+ const n = Number(cfg?.[key]);
680
+ if (Number.isFinite(n) && n > 0) out[key] = n;
681
+ }
682
+ return out;
683
+ }
684
+ const LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
685
+ function isPersistedZeroBufferTelemetry(cfg = {}, boundaryTokens = 0) {
686
+ const boundary = positiveContextWindow(boundaryTokens);
687
+ if (!boundary) return false;
688
+ if (positiveContextWindow(process.env.MIXDOG_AGENT_COMPACT_BUFFER_TOKENS)) return false;
689
+ if (Number.isFinite(Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT)) && Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT) > 0) return false;
690
+ if (Number.isFinite(Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO)) && Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO) > 0) return false;
691
+ for (const key of ['bufferPercent', 'bufferPct', 'bufferFraction']) {
692
+ const n = Number(cfg?.[key]);
693
+ if (Number.isFinite(n) && n > 0) return false;
694
+ }
695
+ const ratio = Number(cfg?.bufferRatio);
696
+ if (Number.isFinite(ratio) && ratio > 0) return false;
697
+ const explicitTokens = Number(cfg?.bufferTokens ?? cfg?.buffer);
698
+ if (!Number.isFinite(explicitTokens) || explicitTokens !== 0) return false;
699
+ return true;
700
+ }
701
+ function isLegacyDefaultBufferTelemetry(cfg = {}, boundaryTokens = 0) {
702
+ const boundary = positiveContextWindow(boundaryTokens);
703
+ if (!boundary) return false;
704
+ if (positiveContextWindow(process.env.MIXDOG_AGENT_COMPACT_BUFFER_TOKENS)) return false;
705
+ if (Number.isFinite(Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT)) && Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT) > 0) return false;
706
+ if (Number.isFinite(Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO)) && Number(process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO) > 0) return false;
707
+ // Percent/fraction-named fields are treated as operator config. The legacy
708
+ // default telemetry always persisted bufferTokens + bufferRatio after a
709
+ // check/compact pass, so only that shape is migrated away.
710
+ for (const key of ['bufferPercent', 'bufferPct', 'bufferFraction']) {
711
+ const n = Number(cfg?.[key]);
712
+ if (Number.isFinite(n) && n > 0) return false;
713
+ }
714
+ const explicitTokens = positiveContextWindow(cfg?.bufferTokens ?? cfg?.buffer);
715
+ const ratio = Number(cfg?.bufferRatio);
716
+ if (!explicitTokens || !Number.isFinite(ratio) || Math.abs(ratio - LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO) > 1e-9) return false;
717
+ const expectedTokens = Math.floor(boundary * LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO);
718
+ const cfgBoundary = positiveContextWindow(cfg?.boundaryTokens);
719
+ const cfgTrigger = positiveContextWindow(cfg?.triggerTokens);
720
+ return explicitTokens === expectedTokens
721
+ || (cfgBoundary === boundary && cfgTrigger > 0 && explicitTokens === Math.max(0, boundary - cfgTrigger));
722
+ }
723
+ function compactBufferConfigForBoundary(cfg = {}, boundaryTokens = 0) {
724
+ const base = cfg || {};
725
+ if (!isLegacyDefaultBufferTelemetry(base, boundaryTokens)
726
+ && !isPersistedZeroBufferTelemetry(base, boundaryTokens)) {
727
+ return base;
728
+ }
729
+ return {
730
+ ...base,
731
+ bufferTokens: null,
732
+ buffer: null,
733
+ bufferRatio: null,
734
+ };
458
735
  }
459
736
  function compactBufferTokensForSession(session, boundaryTokens) {
460
- const cfg = session?.compaction || {};
737
+ const cfg = compactBufferConfigForBoundary(session?.compaction || {}, boundaryTokens);
461
738
  const explicit = positiveContextWindow(cfg.bufferTokens ?? cfg.buffer)
462
- || positiveContextWindow(process.env.MIXDOG_BRIDGE_COMPACT_BUFFER_TOKENS)
739
+ || positiveContextWindow(process.env.MIXDOG_AGENT_COMPACT_BUFFER_TOKENS)
463
740
  || 0;
464
741
  return compactionBufferTokensForBoundary(boundaryTokens, {
465
742
  explicitTokens: explicit,
466
- ratio: compactBufferRatioForSession(session),
743
+ ratio: compactBufferRatioForConfig(cfg),
467
744
  maxRatio: 0.25,
468
745
  });
469
746
  }
@@ -471,8 +748,8 @@ const COMPACT_TARGET_RATIO = 0.02;
471
748
  const COMPACT_TARGET_MIN_TOKENS = 4_000;
472
749
  const COMPACT_TARGET_MAX_TOKENS = 16_000;
473
750
  function compactTargetRatio() {
474
- const raw = process.env.MIXDOG_COMPACT_TARGET_PERCENT
475
- ?? process.env.MIXDOG_BRIDGE_COMPACT_TARGET_PERCENT
751
+ const raw = process.env.MIXDOG_AGENT_COMPACT_TARGET_PERCENT
752
+ ?? process.env.MIXDOG_COMPACT_TARGET_PERCENT
476
753
  ?? COMPACT_TARGET_RATIO;
477
754
  const n = Number(raw);
478
755
  if (!Number.isFinite(n) || n <= 0) return COMPACT_TARGET_RATIO;
@@ -482,8 +759,8 @@ function compactTargetTokensForBoundary(boundaryTokens) {
482
759
  const boundary = positiveContextWindow(boundaryTokens);
483
760
  if (!boundary) return null;
484
761
  const explicit = positiveContextWindow(
485
- process.env.MIXDOG_COMPACT_TARGET_TOKENS
486
- ?? process.env.MIXDOG_BRIDGE_COMPACT_TARGET_TOKENS,
762
+ process.env.MIXDOG_AGENT_COMPACT_TARGET_TOKENS
763
+ ?? process.env.MIXDOG_COMPACT_TARGET_TOKENS,
487
764
  );
488
765
  if (explicit) return Math.max(1, Math.min(boundary, explicit));
489
766
  const minTarget = Math.min(boundary, positiveContextWindow(process.env.MIXDOG_COMPACT_TARGET_MIN_TOKENS) || COMPACT_TARGET_MIN_TOKENS);
@@ -492,10 +769,10 @@ function compactTargetTokensForBoundary(boundaryTokens) {
492
769
  return Math.max(1, Math.min(boundary, maxTarget, Math.max(minTarget, byRatio)));
493
770
  }
494
771
  function defaultEffectiveContextWindowPercent(provider) {
495
- // Gateway/statusline route metadata reserves a universal 5% headroom from
772
+ // Gateway/statusline route metadata reserves a universal 10% headroom from
496
773
  // the raw catalog window. Keep session compaction on the same effective
497
774
  // capacity so /context, the TUI statusline, and gateway telemetry agree.
498
- return 95;
775
+ return 90;
499
776
  }
500
777
  function resolveSessionContextMeta(provider, model, seed = {}) {
501
778
  const info = typeof provider?.getCachedModelInfo === 'function'
@@ -525,7 +802,8 @@ function resolveSessionContextMeta(provider, model, seed = {}) {
525
802
  );
526
803
  const pct = boundedPercent(effectiveContextWindowPercent, 100);
527
804
  const contextWindow = Math.max(1, Math.floor(rawContextWindow * pct / 100));
528
- const explicitCompactLimit = positiveContextWindow(
805
+ const compactBoundaryTokens = contextWindow;
806
+ const rawCompactLimit = positiveContextWindow(
529
807
  seed.autoCompactTokenLimit
530
808
  ?? seed.auto_compact_token_limit
531
809
  ?? info?.autoCompactTokenLimit
@@ -533,11 +811,27 @@ function resolveSessionContextMeta(provider, model, seed = {}) {
533
811
  ?? catalogInfo?.autoCompactTokenLimit
534
812
  ?? catalogInfo?.auto_compact_token_limit,
535
813
  );
536
- const derivedCompactLimit = contextWindow;
537
- const autoCompactTokenLimit = explicitCompactLimit && derivedCompactLimit
538
- ? Math.min(explicitCompactLimit, derivedCompactLimit)
539
- : (explicitCompactLimit || derivedCompactLimit);
540
- const compactBoundaryTokens = contextWindow;
814
+ // Legacy-data migration: old implementations derived autoCompactTokenLimit
815
+ // from the full effective/raw window and persisted it onto the session.
816
+ // A resumed session therefore re-seeds autoCompactTokenLimit == boundary
817
+ // (or the raw window), which compactTriggerForSession / loop policy used to
818
+ // honor as an explicit trigger, collapsing the compaction buffer to 0. Only
819
+ // accept an explicit limit that is STRICTLY BELOW the boundary; a value at
820
+ // or above the boundary is a derived full-window artifact and is dropped to
821
+ // null so the trigger falls back to the default boundary trigger.
822
+ const explicitCompactLimit = rawCompactLimit && rawCompactLimit < compactBoundaryTokens
823
+ ? rawCompactLimit
824
+ : null;
825
+ // Do NOT derive the auto-compact limit from the full effective window.
826
+ // Setting it to contextWindow makes autoTriggerTokens == boundary and the
827
+ // compaction buffer collapse to 0 (loop.mjs:708-713 / compactTriggerForSession),
828
+ // so auto-compact only fires when the context is already at the limit —
829
+ // at which point semantic compact fails ("result exceeds budget" /
830
+ // "summary cannot fit") and the turn can no longer be resumed.
831
+ // Leave it null unless the provider/catalog/seed supplies an explicit
832
+ // limit; the downstream buffer logic (default 10%, capped 25%) then
833
+ // triggers compaction with headroom, matching the reference auto-compact threshold.
834
+ const autoCompactTokenLimit = explicitCompactLimit || null;
541
835
  return {
542
836
  contextWindow,
543
837
  rawContextWindow,
@@ -550,10 +844,36 @@ function compactTriggerForSession(session, boundaryTokens) {
550
844
  const boundary = positiveContextWindow(boundaryTokens);
551
845
  if (!boundary) return null;
552
846
  const autoLimit = positiveContextWindow(session?.autoCompactTokenLimit ?? session?.compaction?.autoCompactTokenLimit);
553
- if (autoLimit && autoLimit <= boundary) return Math.max(1, autoLimit);
847
+ // Only honor an explicit auto-compact limit that sits STRICTLY BELOW the
848
+ // boundary. A persisted value == boundary (or >=) is a legacy derived
849
+ // full-window artifact; honoring it collapses the compaction buffer to 0,
850
+ // so fall through to the default boundary trigger instead.
851
+ if (autoLimit && autoLimit < boundary) return Math.max(1, autoLimit);
554
852
  const buffer = compactBufferTokensForSession(session, boundary);
555
853
  return Math.max(1, boundary - buffer);
556
854
  }
855
+ // Test-only exports for the legacy auto-compact-limit migration + buffer-config
856
+ // preservation (see scripts/compact-trigger-migration-smoke.mjs).
857
+ export const _resolveSessionContextMeta = resolveSessionContextMeta;
858
+ export const _compactTriggerForSession = compactTriggerForSession;
859
+ export const _preserveBufferConfigFields = preserveBufferConfigFields;
860
+ // 'compacting' is a transient in-flight stage written just before semantic /
861
+ // recall-fasttrack compaction runs. If the process crashes or only partially
862
+ // saves while it is set, a later load/resume reads a session that is NOT
863
+ // actually compacting but whose UI marker (App.jsx / ContextPanel) shows
864
+ // "Compacting conversation" permanently. Normalize that stale transient stage
865
+ // to 'interrupted' so the surface recovers. Terminal stages (post_turn /
866
+ // manual / auto_clear / *_failed / overflow_failed) are intentionally left as
867
+ // the durable record of the last real outcome.
868
+ function normalizeStaleCompactingStage(session) {
869
+ const c = session?.compaction;
870
+ if (!c || typeof c !== 'object') return false;
871
+ if (c.lastStage !== 'compacting' && c.inProgress !== true) return false;
872
+ c.lastStage = 'interrupted';
873
+ c.inProgress = false;
874
+ c.lastCheckedAt = Date.now();
875
+ return true;
876
+ }
557
877
  function compactTargetBudget(boundaryTokens, reserveTokens, _sourceTokens = null, _ratio = null) {
558
878
  const boundary = positiveContextWindow(boundaryTokens);
559
879
  if (!boundary) return null;
@@ -563,12 +883,21 @@ function compactTargetBudget(boundaryTokens, reserveTokens, _sourceTokens = null
563
883
  }
564
884
  function semanticCompactionEnabledForSession(session) {
565
885
  const cfg = session?.compaction || {};
886
+ if (process.env.MIXDOG_AGENT_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_AGENT_COMPACT_SEMANTIC', true);
566
887
  if (process.env.MIXDOG_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_COMPACT_SEMANTIC', true);
567
- if (process.env.MIXDOG_BRIDGE_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_BRIDGE_COMPACT_SEMANTIC', true);
568
888
  if (cfg.semantic === false || cfg.semantic === 'false' || cfg.semantic === 'off') return false;
569
889
  if (cfg.semantic === true || cfg.semantic === 'true' || cfg.semantic === 'on' || cfg.semantic === 'auto') return true;
570
890
  return true;
571
891
  }
892
+ function compactTypeForSession(session) {
893
+ const cfg = session?.compaction || {};
894
+ const configured = process.env.MIXDOG_AGENT_COMPACT_TYPE
895
+ ?? process.env.MIXDOG_COMPACT_TYPE
896
+ ?? cfg.type
897
+ ?? cfg.compactType
898
+ ?? cfg.compact_type;
899
+ return normalizeCompactType(configured, DEFAULT_COMPACT_TYPE);
900
+ }
572
901
  function addCompactUsageToSession(session, usage) {
573
902
  if (!session || !usage) return;
574
903
  const inputTokens = usage.inputTokens || 0;
@@ -581,6 +910,73 @@ function addCompactUsageToSession(session, usage) {
581
910
  session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + cacheWriteTokens;
582
911
  session.tokensCumulative = (session.tokensCumulative || 0) + inputTokens + outputTokens;
583
912
  }
913
+ async function runRecallFastTrackForSession(session, messages, opts = {}) {
914
+ const sessionId = opts.sessionId || session?.id || null;
915
+ if (!sessionId) throw new Error('recall-fasttrack requires a session id');
916
+ const query = `session:${sessionId}:all-chunks`;
917
+ const querySha = createHash('sha256').update(query).digest('hex').slice(0, 16);
918
+ const callerCtx = {
919
+ callerSessionId: sessionId,
920
+ callerCwd: session?.cwd || undefined,
921
+ routingSessionId: sessionId,
922
+ clientHostPid: session?.clientHostPid,
923
+ signal: opts.signal || null,
924
+ };
925
+ const hydrateLimit = positiveContextWindow(session?.compaction?.recallIngestLimit)
926
+ || Math.max(500, Math.min(5000, messages.length || 0));
927
+ try {
928
+ await executeInternalTool('memory', {
929
+ action: 'ingest_session',
930
+ sessionId,
931
+ messages,
932
+ cwd: session?.cwd,
933
+ limit: hydrateLimit,
934
+ }, callerCtx);
935
+ } catch (err) {
936
+ try { process.stderr.write(`[session] recall-fasttrack ingest skipped (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
937
+ }
938
+ const dumpArgs = {
939
+ action: 'dump_session_roots',
940
+ sessionId,
941
+ includeRaw: true,
942
+ limit: positiveContextWindow(session?.compaction?.recallChunkLimit ?? session?.compaction?.recallLimit) || hydrateLimit,
943
+ };
944
+ const runTool = (name, args) => executeInternalTool(name, args, callerCtx);
945
+ let recallText = await executeInternalTool('memory', dumpArgs, callerCtx);
946
+ let cycle1Text = '';
947
+ const hasRawRows = /(?:^|\n)# raw_pending\s+\d+\s+id=/i.test(String(recallText || ''));
948
+ if (hasRawRows) {
949
+ try {
950
+ // Drain this session's cycle1 in window×concurrency units until no
951
+ // raw rows remain, so the injected root is fully chunked rather than
952
+ // carrying the unprocessed transcript tail (single-pass left raw in).
953
+ const drained = await drainSessionCycle1(runTool, {
954
+ sessionId,
955
+ dumpArgs,
956
+ deadlineMs: positiveContextWindow(session?.compaction?.recallCycle1DeadlineMs) || 120_000,
957
+ maxPasses: positiveContextWindow(session?.compaction?.recallCycle1MaxPasses) || 0,
958
+ cycleArgs: {
959
+ min_batch: 1,
960
+ session_cap: 1,
961
+ batch_size: positiveContextWindow(session?.compaction?.recallCycle1BatchSize) || 100,
962
+ rows_per_session: positiveContextWindow(session?.compaction?.recallRowsPerSession) || 100,
963
+ window_size: positiveContextWindow(session?.compaction?.recallWindowSize) || 20,
964
+ concurrency: positiveContextWindow(session?.compaction?.recallConcurrency) || 5,
965
+ },
966
+ });
967
+ recallText = drained.recallText;
968
+ cycle1Text = drained.cycle1Text;
969
+ if (drained.rawRemaining > 0) {
970
+ try { process.stderr.write(`[session] recall-fasttrack drained passes=${drained.passes} rawRemaining=${drained.rawRemaining} (sess=${sessionId})\n`); } catch {}
971
+ }
972
+ } catch (err) {
973
+ try { process.stderr.write(`[session] recall-fasttrack cycle1 skipped (sess=${sessionId}): ${err?.message || err}\n`); } catch {}
974
+ }
975
+ } else {
976
+ cycle1Text = 'cycle1: skipped (session chunks already hydrated)';
977
+ }
978
+ return { query, querySha, recallText: [`session_id=${sessionId}`, cycle1Text, recallText].map(v => String(v || '').trim()).filter(Boolean).join('\n\n') };
979
+ }
584
980
  async function runSessionCompaction(session, opts = {}) {
585
981
  if (!session || session.closed === true) return null;
586
982
  const mode = opts.mode === 'auto' ? 'auto' : 'manual';
@@ -597,17 +993,18 @@ async function runSessionCompaction(session, opts = {}) {
597
993
  }
598
994
  const reserveTokens = estimateRequestReserveTokens(session.tools || []);
599
995
  const beforeMessageTokens = estimateMessagesTokens(messages);
600
- const lastContextTokens = positiveContextWindow(session.lastContextTokens) || 0;
601
996
  const triggerTokens = compactTriggerForSession(session, boundary)
602
997
  || positiveContextWindow(session.compaction?.triggerTokens)
603
998
  || boundary;
604
999
  const bufferTokens = Math.max(0, boundary - triggerTokens);
605
1000
  const bufferRatio = boundary ? (bufferTokens / boundary) : compactBufferRatioForSession(session);
606
- const pressureTokens = Math.max(beforeMessageTokens + reserveTokens, lastContextTokens);
1001
+ const pressureTokens = estimateTranscriptContextUsage(messages, session.tools || []);
607
1002
  const beforeTokens = pressureTokens;
1003
+ const compactType = compactTypeForSession(session);
608
1004
  if (!force && pressureTokens < triggerTokens) return {
609
1005
  changed: false,
610
1006
  reason: 'below threshold',
1007
+ compactType,
611
1008
  beforeMessages: messages.length,
612
1009
  afterMessages: messages.length,
613
1010
  beforeTokens,
@@ -627,13 +1024,43 @@ async function runSessionCompaction(session, opts = {}) {
627
1024
  const budgetSourceTokens = force ? Math.max(pressureTokens, triggerTokens) : pressureTokens;
628
1025
  const compactBudget = compactTargetBudget(boundary, reserveTokens, budgetSourceTokens);
629
1026
  const budget = compactBudget || boundary;
1027
+ try { opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
630
1028
  const provider = opts.provider || getProvider(session.provider) || null;
631
1029
  let compacted;
632
1030
  let compactError = null;
633
1031
  let semanticCompactResult = null;
634
1032
  let semanticCompactError = null;
635
- if (semanticCompactionEnabledForSession(session)) {
1033
+ let recallFastTrackResult = null;
1034
+ let recallFastTrackError = null;
1035
+ if (compactTypeIsRecallFastTrack(compactType)) {
1036
+ try {
1037
+ const recallPayload = await runRecallFastTrackForSession(session, messages, opts);
1038
+ recallFastTrackResult = recallFastTrackCompactMessages(messages, budget, {
1039
+ reserveTokens,
1040
+ force: true,
1041
+ recallText: recallPayload.recallText,
1042
+ query: recallPayload.query,
1043
+ querySha: recallPayload.querySha,
1044
+ allowEmptyRecall: true,
1045
+ tailTurns: positiveContextWindow(session.compaction?.tailTurns) || 2,
1046
+ keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens),
1047
+ preserveRecentTokens: positiveContextWindow(session.compaction?.preserveRecentTokens),
1048
+ });
1049
+ if (Array.isArray(recallFastTrackResult?.messages)) {
1050
+ compacted = recallFastTrackResult.messages;
1051
+ }
1052
+ } catch (err) {
1053
+ recallFastTrackError = err;
1054
+ compactError = err;
1055
+ try {
1056
+ process.stderr.write(`[session] recall-fasttrack ${mode} compact failed (sess=${session.id || 'unknown'}): ${err?.message || err}\n`);
1057
+ } catch { /* best-effort */ }
1058
+ }
1059
+ } else if (compactTypeIsSemantic(compactType)) {
636
1060
  try {
1061
+ if (!semanticCompactionEnabledForSession(session)) {
1062
+ throw new Error('semantic compact is disabled for this session');
1063
+ }
637
1064
  if (!provider || typeof provider.send !== 'function') {
638
1065
  throw new Error(`semantic compact provider unavailable: ${session.provider || 'unknown'}`);
639
1066
  }
@@ -662,22 +1089,14 @@ async function runSessionCompaction(session, opts = {}) {
662
1089
  }
663
1090
  } catch (err) {
664
1091
  semanticCompactError = err;
1092
+ compactError = err;
665
1093
  try {
666
- process.stderr.write(`[session] semantic ${mode} compact failed (sess=${session.id || 'unknown'}): ${err?.message || err}; falling back to deterministic compact\n`);
1094
+ process.stderr.write(`[session] semantic ${mode} compact failed (sess=${session.id || 'unknown'}): ${err?.message || err}\n`);
667
1095
  } catch { /* best-effort */ }
668
1096
  }
669
1097
  }
670
- try {
671
- if (!compacted) compacted = compactMessages(messages, budget, { reserveTokens, force: true });
672
- } catch (err) {
673
- try {
674
- process.stderr.write(`[session] ${mode} compact fallback (sess=${session.id || 'unknown'}): ${err?.message || err}\n`);
675
- } catch { /* best-effort */ }
676
- try {
677
- compacted = compactActiveTurn(messages, budget, { reserveTokens, force: true });
678
- } catch (fallbackErr) {
679
- compactError = fallbackErr;
680
- }
1098
+ if (!compacted && !compactError) {
1099
+ compactError = new Error(`${compactType} compact produced no messages`);
681
1100
  }
682
1101
  if (!compacted) {
683
1102
  const now = Date.now();
@@ -697,13 +1116,19 @@ async function runSessionCompaction(session, opts = {}) {
697
1116
  lastPressureTokens: pressureTokens,
698
1117
  lastCheckedAt: now,
699
1118
  lastChanged: false,
1119
+ type: compactType,
1120
+ compactType,
1121
+ lastCompactType: compactType,
700
1122
  lastSemantic: false,
701
1123
  lastSemanticError: semanticCompactError?.message || null,
702
- lastError: compactError?.message || semanticCompactError?.message || String(compactError || semanticCompactError || 'compact failed'),
1124
+ lastRecallFastTrack: false,
1125
+ lastRecallFastTrackError: recallFastTrackError?.message || null,
1126
+ lastError: compactError?.message || semanticCompactError?.message || recallFastTrackError?.message || String(compactError || semanticCompactError || recallFastTrackError || 'compact failed'),
703
1127
  };
704
1128
  return {
705
1129
  changed: false,
706
1130
  error: session.compaction.lastError,
1131
+ compactType,
707
1132
  beforeMessages: messages.length,
708
1133
  afterMessages: messages.length,
709
1134
  beforeTokens,
@@ -720,6 +1145,8 @@ async function runSessionCompaction(session, opts = {}) {
720
1145
  reserveTokens,
721
1146
  semanticCompact: false,
722
1147
  semanticError: semanticCompactError?.message || null,
1148
+ recallFastTrack: false,
1149
+ recallFastTrackError: recallFastTrackError?.message || null,
723
1150
  };
724
1151
  }
725
1152
  let beforeEncoded = '';
@@ -743,6 +1170,9 @@ async function runSessionCompaction(session, opts = {}) {
743
1170
  bufferTokens,
744
1171
  bufferRatio,
745
1172
  reserveTokens,
1173
+ type: compactType,
1174
+ compactType,
1175
+ lastCompactType: compactType,
746
1176
  lastStage: mode === 'auto' ? 'post_turn' : 'manual',
747
1177
  lastBeforeTokens: beforeTokens,
748
1178
  lastAfterTokens: afterTokens,
@@ -755,6 +1185,9 @@ async function runSessionCompaction(session, opts = {}) {
755
1185
  lastCompactAt: changed ? now : session.compaction?.lastCompactAt || null,
756
1186
  lastSemantic: semanticCompactResult?.semantic === true,
757
1187
  lastSemanticError: semanticCompactError?.message || null,
1188
+ lastRecallFastTrack: recallFastTrackResult?.recallFastTrack === true,
1189
+ lastRecallFastTrackError: recallFastTrackError?.message || null,
1190
+ lastRecallFastTrackQuerySha: recallFastTrackResult?.query ? createHash('sha256').update(recallFastTrackResult.query).digest('hex').slice(0, 16) : null,
758
1191
  lastSemanticUsage: semanticCompactResult?.usage ? {
759
1192
  inputTokens: semanticCompactResult.usage.inputTokens || 0,
760
1193
  outputTokens: semanticCompactResult.usage.outputTokens || 0,
@@ -767,6 +1200,7 @@ async function runSessionCompaction(session, opts = {}) {
767
1200
  return {
768
1201
  changed,
769
1202
  reason: unchangedReason,
1203
+ compactType,
770
1204
  beforeMessages: messages.length,
771
1205
  afterMessages: compacted.length,
772
1206
  beforeTokens,
@@ -783,20 +1217,20 @@ async function runSessionCompaction(session, opts = {}) {
783
1217
  reserveTokens,
784
1218
  semanticCompact: semanticCompactResult?.semantic === true,
785
1219
  semanticError: semanticCompactError?.message || null,
1220
+ recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
1221
+ recallFastTrackError: recallFastTrackError?.message || null,
786
1222
  usage: semanticCompactResult?.usage || null,
787
1223
  };
788
1224
  }
789
- async function autoCompactSessionAfterTurn(session, opts = {}) {
790
- return runSessionCompaction(session, { ...opts, mode: 'auto', force: false });
791
- }
792
1225
  // Provider-scoped unified cache key. Goal: all orchestrator-internal
793
- // dispatches (bridge/maintenance/mcp/scheduler/webhook) targeting the
1226
+ // dispatches (agent/maintenance/mcp/scheduler/webhook) targeting the
794
1227
  // same provider land in a single server-side cache shard, so the
795
1228
  // shared prefix (tools + system + pool system prompt) is reused
796
- // regardless of role. Per-role / per-session differentiation lives in
797
- // the message tail, which is naturally separated by content hashing.
1229
+ // regardless of role. Per-role / per-session differentiation lives after the
1230
+ // system prefix (BP3 sessionMarker system block / later messages), which is
1231
+ // naturally separated by provider-side content hashing.
798
1232
  const PROVIDER_ALIAS = {
799
- 'openai-oauth': 'codex', // ChatGPT subscription (Codex backend)
1233
+ 'openai-oauth': 'codex', // ChatGPT subscription (OpenAI OAuth backend)
800
1234
  'anthropic-oauth': 'claude', // Claude Max subscription
801
1235
  'openai': 'openai',
802
1236
  'anthropic': 'anthropic',
@@ -811,9 +1245,10 @@ function providerCacheKey(provider, override) {
811
1245
  }
812
1246
 
813
1247
  // ── Prefetch permission guard ─────────────────────────────────────────────────
814
- // Mirrors _checkWorkerPermission in loop.mjs for tool calls that originate
815
- // in the prefetch path (outside the agent loop). Returns an error string if
816
- // blocked, or null if allowed.
1248
+ // Runs the shared permission evaluator for tool calls that originate in the
1249
+ // prefetch path (outside the agent loop). Permission enforcement is disabled
1250
+ // (the evaluator always returns allow), so this is effectively a pass-through
1251
+ // kept for API compatibility. Returns an error string if blocked, or null.
817
1252
  const _permEvalForPrefetch = (() => {
818
1253
  const _req = createRequire(import.meta.url);
819
1254
  try {
@@ -824,9 +1259,8 @@ const _permEvalForPrefetch = (() => {
824
1259
  })();
825
1260
  function _guardedPrefetchTool(toolName, toolArgs, session) {
826
1261
  if (!_permEvalForPrefetch) return null;
827
- // Same baseline as _checkWorkerPermission: when no explicit mode is
828
- // attached to the session, run the evaluator under 'default' so the
829
- // bypass-proof hard-deny patterns still apply during prefetch dispatch.
1262
+ // When no explicit mode is attached to the session, run the evaluator
1263
+ // under 'default'. The evaluator now always allows, so this never blocks.
830
1264
  const permissionMode = session?.permissionMode || 'default';
831
1265
  const projectDir = session?.cwd || undefined;
832
1266
  const userCwd = session?.cwd || undefined;
@@ -845,7 +1279,7 @@ function _guardedPrefetchTool(toolName, toolArgs, session) {
845
1279
 
846
1280
  async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
847
1281
  if (!explicitPrefetch || typeof explicitPrefetch !== 'object') return null;
848
- if (session?.owner !== 'bridge') return null;
1282
+ if (!isAgentOwner(session)) return null;
849
1283
  const parts = [];
850
1284
  const failed = [];
851
1285
  const totalEntries = [];
@@ -887,7 +1321,7 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
887
1321
  if (files.length > 0) {
888
1322
  const _pfGuard = _guardedPrefetchTool('read', { path: files }, session);
889
1323
  if (_pfGuard) {
890
- process.stderr.write(`[bridge-prefetch] files read blocked: ${_pfGuard}\n`);
1324
+ process.stderr.write(`[agent-prefetch] files read blocked: ${_pfGuard}\n`);
891
1325
  failed.push(...files);
892
1326
  totalEntries.push(...files);
893
1327
  } else {
@@ -931,7 +1365,7 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
931
1365
  readArgs.n = Number.isFinite(opts.n) ? opts.n : 120;
932
1366
  }
933
1367
  const out = await executeInternalTool('read', readArgs).catch((e) => {
934
- process.stderr.write(`[bridge-prefetch] file read failed (${f}): ${e && e.message || e}\n`);
1368
+ process.stderr.write(`[agent-prefetch] file read failed (${f}): ${e && e.message || e}\n`);
935
1369
  return null;
936
1370
  });
937
1371
  if (out !== null) {
@@ -969,9 +1403,11 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
969
1403
  parts.push(`### prefetch files\nread ${readParts.length}\n\n${readParts.join('\n\n')}`);
970
1404
  }
971
1405
  // Log hit/miss counters so dispatch telemetry shows prefetch effectiveness.
972
- process.stderr.write(
973
- `[prefetch] files=${files.length} cached=${fileHits.length} miss=${fileMisses.length} failed=${failed.length}\n`
974
- );
1406
+ if (process.env.MIXDOG_DEBUG_SESSION_LOG) {
1407
+ process.stderr.write(
1408
+ `[prefetch] files=${files.length} cached=${fileHits.length} miss=${fileMisses.length} failed=${failed.length}\n`
1409
+ );
1410
+ }
975
1411
  // Attach stats to session so post-hoc analyzers (inspect-session.mjs)
976
1412
  // can see prefetch effectiveness without parsing stderr logs.
977
1413
  if (session && typeof session === 'object') {
@@ -993,13 +1429,13 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
993
1429
  totalEntries.push(symbol);
994
1430
  const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
995
1431
  if (blocked) {
996
- process.stderr.write(`[bridge-prefetch] callers(${symbol}) blocked: ${blocked}\n`);
1432
+ process.stderr.write(`[agent-prefetch] callers(${symbol}) blocked: ${blocked}\n`);
997
1433
  return Promise.resolve({ symbol, out: null, blocked: true });
998
1434
  }
999
- return executeCodeGraphTool('code_graph', cgArgs, session?.cwd)
1435
+ return _executeCodeGraphToolLazy('code_graph', cgArgs, session?.cwd)
1000
1436
  .then(out => ({ symbol, out }))
1001
1437
  .catch(e => {
1002
- process.stderr.write(`[bridge-prefetch] callers(${symbol}) failed: ${e && e.message || e}\n`);
1438
+ process.stderr.write(`[agent-prefetch] callers(${symbol}) failed: ${e && e.message || e}\n`);
1003
1439
  return { symbol, out: null };
1004
1440
  });
1005
1441
  });
@@ -1024,13 +1460,13 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
1024
1460
  totalEntries.push(symbol);
1025
1461
  const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
1026
1462
  if (blocked) {
1027
- process.stderr.write(`[bridge-prefetch] references(${symbol}) blocked: ${blocked}\n`);
1463
+ process.stderr.write(`[agent-prefetch] references(${symbol}) blocked: ${blocked}\n`);
1028
1464
  return Promise.resolve({ symbol, out: null, blocked: true });
1029
1465
  }
1030
- return executeCodeGraphTool('code_graph', cgArgs, session?.cwd)
1466
+ return _executeCodeGraphToolLazy('code_graph', cgArgs, session?.cwd)
1031
1467
  .then(out => ({ symbol, out }))
1032
1468
  .catch(e => {
1033
- process.stderr.write(`[bridge-prefetch] references(${symbol}) failed: ${e && e.message || e}\n`);
1469
+ process.stderr.write(`[agent-prefetch] references(${symbol}) failed: ${e && e.message || e}\n`);
1034
1470
  return { symbol, out: null };
1035
1471
  });
1036
1472
  });
@@ -1065,29 +1501,29 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
1065
1501
  return `${warnLine}<prefetch>\n${parts.join('\n\n')}\n</prefetch>`;
1066
1502
  }
1067
1503
 
1068
- // --- bridge spawn (createSession) ---
1504
+ // --- agent spawn (createSession) ---
1069
1505
  // opts can pass either a `preset` object (from config.presets) or raw provider/model.
1070
1506
  // Preset shape: { name, provider, model, effort?, fast?, tools? }
1071
1507
  //
1072
- // Smart Bridge integration:
1508
+ // Agent Runtime integration:
1073
1509
  // opts.taskType / opts.role / opts.profileId — enables profile-aware routing.
1074
1510
  // Rule-based SmartRouter resolves these synchronously; the resolved
1075
1511
  // profile controls context filtering (skip.skills/memory/etc) and cache
1076
1512
  // strategy. If no rule matches, falls back to classic preset behavior.
1077
1513
  // opts.profile — pre-resolved profile (bypasses router; used by async
1078
- // callers who already ran SmartBridge.resolve()).
1514
+ // callers who already ran AgentRuntime.resolve()).
1079
1515
  // opts.providerCacheOpts — pre-resolved cache options merged into ask() sendOpts.
1080
1516
  export function createSession(opts) {
1081
1517
  const presetObj = opts.preset && typeof opts.preset === 'object' ? opts.preset : null;
1082
1518
 
1083
- // --- Smart Bridge profile resolution (best-effort, sync) ---
1519
+ // --- Agent Runtime profile resolution (best-effort, sync) ---
1084
1520
  let profile = opts.profile || null;
1085
1521
  let providerCacheOpts = opts.providerCacheOpts || null;
1086
1522
  if (!profile && (opts.taskType || opts.role || opts.profileId)) {
1087
- const smartBridge = getSmartBridgeSync();
1088
- if (smartBridge) {
1523
+ const agentRuntime = getAgentRuntimeSync();
1524
+ if (agentRuntime) {
1089
1525
  try {
1090
- const resolved = smartBridge.resolveSync({
1526
+ const resolved = agentRuntime.resolveSync({
1091
1527
  taskType: opts.taskType,
1092
1528
  role: opts.role,
1093
1529
  profileId: opts.profileId,
@@ -1099,10 +1535,10 @@ export function createSession(opts) {
1099
1535
  providerCacheOpts = resolved.providerCacheOpts;
1100
1536
  }
1101
1537
  } catch (e) {
1102
- // Smart Bridge error — log once, fall back to classic behavior.
1103
- if (!_smartBridgeWarned) {
1104
- _smartBridgeWarned = true;
1105
- process.stderr.write(`[session] smart bridge resolve failed: ${e.message}\n`);
1538
+ // Agent Runtime error — log once, fall back to classic behavior.
1539
+ if (!_agentRuntimeWarned) {
1540
+ _agentRuntimeWarned = true;
1541
+ process.stderr.write(`[session] agent runtime resolve failed: ${e.message}\n`);
1106
1542
  }
1107
1543
  }
1108
1544
  }
@@ -1130,132 +1566,108 @@ export function createSession(opts) {
1130
1566
  throw new Error(`Provider "${providerName}" not found or not enabled`);
1131
1567
  const id = `sess_${process.pid}_${nextId++}_${Date.now()}_${randomBytes(16).toString('hex')}`;
1132
1568
  const messages = [];
1133
- const agentTemplate = opts.agent ? loadAgentTemplate(opts.agent, opts.cwd) : null;
1134
- const skills = opts.skipSkills ? [] : collectSkillsCached(opts.cwd);
1135
-
1136
- // Bridge shared prefix (bit-identical across roles). Hidden roles reuse the
1137
- // same shared bridge rules so the cache shard stays stable across bridge
1138
- // callers. User-defined data (DATA_DIR roles/schedules/webhooks) is baked
1139
- // into BP1 as a single fixed-value monolithic block so every role shares
1140
- // one cache shard. A user edit invalidates BP1 once and the new prefix
1141
- // re-warms across all roles together.
1142
- const bridgeRulesRole = opts.role || profile?.taskType || null;
1143
- const injectedRules = opts.skipBridgeRules ? '' : (opts.owner === 'bridge' ? _buildBridgeRules() : _buildLeadRules());
1144
- const roleSpecific = opts.owner === 'bridge' && !opts.skipBridgeRules ? _buildRoleSpecific(bridgeRulesRole) : '';
1145
- // mixdog.md user/project context (global + cwd ancestors, broad-to-specific).
1146
- const projectContext = collectMixdogMd(opts.cwd);
1147
-
1148
- // Role template (Phase B §4 — UI-managed). Reads <DATA_DIR>/roles/<role>.md
1149
- // and parses frontmatter (description, permission). The template is
1150
- // injected into the Tier 3 system-reminder so role differences never
1151
- // touch the BP_2 cache prefix.
1569
+ const ownerIsAgent = isAgentOwner(opts.owner);
1152
1570
  const resolvedRole = opts.role || profile?.taskType || null;
1153
- const dataDir = resolvePluginData();
1154
- const roleTemplate = resolvedRole && dataDir
1155
- ? loadRoleTemplate(resolvedRole, dataDir)
1156
- : null;
1571
+ const hiddenRole = getHiddenRole(resolvedRole);
1572
+ const isRetrievalRole = hiddenRole?.kind === 'retrieval';
1573
+ // Skill schema is fixed; the compact manifest is discovered once through
1574
+ // the mtime-cached frontmatter index so BP1 tells every role which Skill()
1575
+ // names are available without loading full SKILL.md bodies.
1576
+ const skills = opts.skipSkills ? [] : collectSkillsCached(opts.cwd);
1157
1577
 
1158
- // Bridge sessions must not inherit role/profile/preset tool narrowing: Pool
1159
- // B and Pool C share one bit-identical tool schema for BP_1/BP_2 cache
1160
- // reuse, and permission differences are enforced only at call time. Raw
1161
- // non-bridge callers keep the historical profile.tools / preset.tools
1578
+ // BP1 is shared tool policy (+ compact skill manifest in compose). BP2 is
1579
+ // role/system rules. User-defined schedules/webhooks ride as normal user
1580
+ // context below so event data does not rewrite BP3 memory/meta.
1581
+ const agentRulesRole = opts.role || profile?.taskType || null;
1582
+ const agentRulesProfile = isRetrievalRole ? 'retrieval' : 'full';
1583
+ const skipAgentRules = opts.skipAgentRules === true;
1584
+ // Retrieval roles already inject a compact # Tool Use via BP2; skip the full BP1 shared tool policy to avoid duplicating it.
1585
+ const injectedRules = (skipAgentRules || isRetrievalRole) ? '' : _buildSharedRules();
1586
+ const roleRules = skipAgentRules ? '' : (ownerIsAgent ? _buildAgentRules(agentRulesProfile) : _buildLeadRules());
1587
+ const metaContext = skipAgentRules ? '' : (ownerIsAgent ? '' : _buildLeadMetaContext());
1588
+ const roleSpecific = ownerIsAgent && !skipAgentRules ? _buildRoleSpecific(agentRulesRole) : '';
1589
+ // Agent sessions must not inherit role/profile/preset tool narrowing: Pool
1590
+ // B and Pool C share one bit-identical tool schema to maximize provider
1591
+ // prefix reuse, and permission differences are enforced only at call time. Raw
1592
+ // non-agent callers keep the historical profile.tools / preset.tools
1162
1593
  // behaviour.
1163
- const toolSpec = opts.owner === 'bridge'
1594
+ const toolSpec = ownerIsAgent
1164
1595
  ? 'full'
1165
1596
  : (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
1166
1597
 
1167
1598
  // Prompt permission is metadata only. Preset tool restrictions must NOT
1168
- // enter the prompt, or they split the shared bridge cache tail; they map
1599
+ // enter the prompt, or they split the shared agent cache tail; they map
1169
1600
  // to toolPermission below and are enforced only at call time.
1170
1601
  const permission = opts.permission
1171
- || roleTemplate?.permission
1172
1602
  || null;
1173
1603
  const toolPermission = opts.permission
1174
1604
  || profile?.permission
1175
- || roleTemplate?.permission
1176
1605
  || permissionFromToolSpec(toolPreset)
1177
1606
  || null;
1178
- let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsBridge: opts.owner === 'bridge' });
1179
- // Fail-closed permission intersection: when a role declares an explicit
1180
- // permission (from user-workflow.json or the role template), intersect the
1607
+ let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsAgentSession: ownerIsAgent });
1608
+ // Fail-closed permission intersection: when a session declares an explicit
1609
+ // object-form permission, intersect the
1181
1610
  // resolved tool list with the permission's allow/deny lists. If the
1182
1611
  // intersection produces an empty set the permission config is broken —
1183
1612
  // fail closed (zero tools) rather than silently falling back to the full
1184
1613
  // preset, which would grant the role more surface than declared.
1185
- if (toolPermission && typeof toolPermission === 'object') {
1186
- const allowSet = Array.isArray(toolPermission.allow) && toolPermission.allow.length > 0
1187
- ? new Set(toolPermission.allow.map(n => String(n).toLowerCase()))
1188
- : null;
1189
- const denySet = Array.isArray(toolPermission.deny) && toolPermission.deny.length > 0
1190
- ? new Set(toolPermission.deny.map(n => String(n).toLowerCase()))
1191
- : null;
1192
- if (allowSet || denySet) {
1193
- const filtered = toolsForRouting.filter(t => {
1194
- const name = String(t?.name || '').toLowerCase();
1195
- if (denySet && denySet.has(name)) return false;
1196
- if (allowSet && !allowSet.has(name)) return false;
1197
- return true;
1198
- });
1199
- // Fail-closed: an empty intersection means the permission config is
1200
- // misconfigured — do not silently fall back to the full preset.
1201
- toolsForRouting = filtered;
1202
- if (filtered.length === 0) {
1203
- process.stderr.write(`[session] WARN: role permission intersection produced 0 tools — failing closed (role=${opts.role || 'unknown'})
1204
- `);
1205
- }
1206
- }
1614
+ if (ownerIsAgent) {
1615
+ toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, toolPermission, opts.role || null);
1207
1616
  }
1208
1617
 
1209
- const { baseRules, roleCatalog, sessionMarker, volatileTail } = composeSystemPrompt({
1618
+ const { baseRules, stableSystemContext, sessionMarker, volatileTail } = composeSystemPrompt({
1210
1619
  userPrompt: opts.systemPrompt,
1211
- bridgeRules: injectedRules || undefined,
1212
- roleSpecific: roleSpecific || undefined,
1213
- skipRoleCatalog: opts.owner !== 'bridge',
1214
- agentTemplate: agentTemplate || undefined,
1215
- roleTemplate: roleTemplate || undefined,
1216
- hasSkills: skills.length > 0,
1620
+ agentRules: injectedRules || undefined,
1621
+ roleRules: roleRules || undefined,
1622
+ metaContext: metaContext || undefined,
1623
+ skipRoleCatalog: !ownerIsAgent,
1217
1624
  profile: profile || undefined,
1218
1625
  role: resolvedRole,
1219
- skipRoleReminder: opts.skipRoleReminder || false,
1220
- permission,
1221
- taskBrief: opts.taskBrief || null,
1222
1626
  workflowContext: opts.workflowContext || null,
1223
1627
  workspaceContext: opts.workspaceContext || null,
1224
1628
  coreMemoryContext: opts.coreMemoryContext || null,
1225
- projectContext: projectContext || null,
1629
+ skillManifest: buildSkillManifest(skills),
1226
1630
  tools: toolsForRouting,
1227
- bashIsPersistent: opts.owner === 'bridge' && toolsForRouting.some(t => t?.name === 'shell'),
1631
+ bashIsPersistent: ownerIsAgent && toolsForRouting.some(t => t?.name === 'shell'),
1228
1632
  // Effective cwd rides in tier3Reminder so explore-like tools know
1229
1633
  // their search root without needing to shove "Override cwd:" into
1230
1634
  // the user message body (that used to fragment the shard prefix).
1231
1635
  cwd: opts.cwd || null,
1232
- // BP2 catalog policy — explicit-cache providers see the unified
1233
- // all-roles catalog; implicit-prefix-hash providers keep self-only.
1234
1636
  provider: providerName || null,
1235
1637
  });
1236
1638
  // 4-BP layout (see composeSystemPrompt docs):
1237
- // system block #1 = baseRules — BP1 (1h) shared across ALL roles
1238
- // system block #2 = roleCatalog — BP2 (1h) scoped role catalog
1239
- // first <system-reminder> user = sessionMarker — BP3 (1h) mixdog.md + stable role body
1240
- // second <system-reminder> user = volatileTail — rides near BP4 (5m)
1241
- // Anthropic multi-block system pins each block with cache_control.
1242
- // OpenAI gets a stable provider cache key/session prefix. Gemini relies
1243
- // on implicit prompt caching only, so hits are observed, not treated as a
1244
- // guaranteed warm shard.
1639
+ // system block #1 = baseRules — BP1 (1h) shared tool policy + skills
1640
+ // system block #2 = stableSystemContext — BP2 (1h) role/system rules
1641
+ // system block #3 = sessionMarker — BP3 (1h) memory/meta + Profile
1642
+ // Preferences (language/name). It rides as a real `system` block so
1643
+ // locale/name directives are pinned firmly and do not drift to English
1644
+ // after a few turns the way a `user <system-reminder>` reminder did.
1645
+ // later normal messages = BP4/tail (task, role data, tool history)
1646
+ // Anthropic multi-block system pins each block with cache_control (BP3 is
1647
+ // the 3rd system block and carries the tier3 1h marker). OpenAI/xAI get
1648
+ // stable provider cache keys/session prefixes. Gemini manages explicit
1649
+ // cachedContents inside its provider.
1245
1650
  if (baseRules) {
1246
1651
  messages.push({ role: 'system', content: baseRules });
1247
1652
  }
1248
- if (roleCatalog) {
1249
- messages.push({ role: 'system', content: roleCatalog });
1653
+ if (stableSystemContext) {
1654
+ messages.push({ role: 'system', content: stableSystemContext });
1250
1655
  }
1251
1656
  if (sessionMarker) {
1252
- messages.push({ role: 'user', content: `<system-reminder>\n${sessionMarker}\n</system-reminder>` });
1253
- messages.push({ role: 'assistant', content: '.' });
1657
+ // cacheTier:'tier3' tells the Anthropic providers to pin THIS system
1658
+ // block with the tier3 1h cache_control (BP3) — distinct from the
1659
+ // BP1/BP2 system TTL. Harmless on non-Anthropic providers (they ignore
1660
+ // the field and serialize content as a normal system instruction).
1661
+ messages.push({ role: 'system', content: sessionMarker, cacheTier: 'tier3' });
1254
1662
  }
1255
1663
  if (volatileTail) {
1256
1664
  messages.push({ role: 'user', content: `<system-reminder>\n${volatileTail}\n</system-reminder>` });
1257
1665
  messages.push({ role: 'assistant', content: '.' });
1258
1666
  }
1667
+ if (roleSpecific) {
1668
+ messages.push({ role: 'user', content: `<system-reminder>\n${roleSpecific}\n</system-reminder>` });
1669
+ messages.push({ role: 'assistant', content: '.' });
1670
+ }
1259
1671
  if (opts.files?.length) {
1260
1672
  const fileContext = opts.files
1261
1673
  .map(f => `### ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
@@ -1263,58 +1675,30 @@ export function createSession(opts) {
1263
1675
  messages.push({ role: 'user', content: `Reference files:\n\n${fileContext}` });
1264
1676
  messages.push({ role: 'assistant', content: '.' });
1265
1677
  }
1266
- let tools = toolsForRouting;
1267
-
1268
- // Schema filtering applied after schema build:
1269
- // - opts.schemaAllowedTools : declarative hidden-role schema profile
1270
- // allowlist for tiny specialist roles where one-shot tool routing
1271
- // beats the shared-schema cache win.
1272
- // - opts.disallowedTools : per-call caller override (Anthropic
1273
- // BuiltInAgentDefinition pattern)
1274
- // - annotations.bridgeHidden : declarative per-tool flag (tools.json
1275
- // and internal tool defs). Pool A (Lead) still sees all tools.
1276
- //
1277
1678
  const hasCallerAllow = Array.isArray(opts.schemaAllowedTools);
1278
- const callerAllow = hasCallerAllow ? opts.schemaAllowedTools.map(n => String(n).toLowerCase()) : [];
1279
- if (hasCallerAllow) {
1280
- const allowSet = new Set(callerAllow);
1281
- const before = tools.length;
1282
- tools = tools.filter(t => allowSet.has(String(t?.name || '').toLowerCase()));
1283
- if (tools.length !== before && !process.env.MIXDOG_QUIET_SESSION_LOG) {
1284
- process.stderr.write(`[session] schemaAllowedTools=${callerAllow.join(',')} kept ${tools.length}/${before} tools\n`);
1285
- }
1286
- }
1287
- const callerDeny = Array.isArray(opts.disallowedTools) ? opts.disallowedTools.map(n => String(n)) : [];
1288
- if (callerDeny.length) {
1289
- const denySet = new Set(callerDeny);
1290
- const before = tools.length;
1291
- tools = tools.filter(t => !denySet.has(String(t?.name || '').toLowerCase()));
1292
- if (tools.length !== before && !process.env.MIXDOG_QUIET_SESSION_LOG) {
1293
- process.stderr.write(`[session] disallowedTools=${callerDeny.join(',')} stripped ${before - tools.length} tools\n`);
1294
- }
1295
- }
1296
- if (opts.owner === 'bridge') {
1297
- const before = tools.length;
1298
- tools = tools.filter(t => !t?.annotations?.bridgeHidden);
1299
- if (tools.length !== before && !process.env.MIXDOG_QUIET_SESSION_LOG) {
1300
- process.stderr.write(`[session] bridgeHidden stripped ${before - tools.length} tools\n`);
1301
- }
1302
- }
1303
-
1304
- // Bridge tool canonicalization: keep route-sensitive tools in policy order
1305
- // while preserving deterministic MCP/skill order for BP1 shard stability.
1306
- if (opts.owner === 'bridge') {
1307
- tools = orderSessionTools(tools);
1308
- }
1679
+ const tools = finalizeSessionToolList(toolsForRouting, {
1680
+ schemaAllowedTools: hasCallerAllow ? opts.schemaAllowedTools : null,
1681
+ disallowedTools: opts.disallowedTools,
1682
+ ownerIsAgent,
1683
+ resolvedRole,
1684
+ });
1309
1685
 
1310
1686
  // Unified-shard policy — no broad role-specific schema filter. Keep
1311
- // bridge schemas shared unless a hidden-role schema profile explicitly
1687
+ // agent schemas shared unless a hidden-role schema profile explicitly
1312
1688
  // passes schemaAllowedTools for a small specialist; broad role
1313
1689
  // whitelists would fragment the cache shard.
1314
- if (resolvedRole && !process.env.MIXDOG_QUIET_SESSION_LOG) {
1690
+ if (resolvedRole && process.env.MIXDOG_DEBUG_SESSION_LOG) {
1315
1691
  process.stderr.write(`[session] role=${resolvedRole} permission=${permission || 'full'} toolPermission=${toolPermission || 'full'} tools=${tools.length}\n`);
1316
1692
  }
1317
1693
  const contextMeta = resolveSessionContextMeta(provider, modelName);
1694
+ const workflowMeta = opts.workflow && typeof opts.workflow === 'object' && String(opts.workflow.id || '').trim()
1695
+ ? {
1696
+ id: String(opts.workflow.id || '').trim(),
1697
+ name: String(opts.workflow.name || opts.workflow.id || '').trim(),
1698
+ description: String(opts.workflow.description || '').trim(),
1699
+ source: String(opts.workflow.source || '').trim(),
1700
+ }
1701
+ : null;
1318
1702
  const session = {
1319
1703
  id,
1320
1704
  provider: providerName,
@@ -1329,13 +1713,27 @@ export function createSession(opts) {
1329
1713
  auto: opts.compaction?.auto !== false,
1330
1714
  prune: opts.compaction?.prune === true,
1331
1715
  semantic: opts.compaction?.semantic ?? 'auto',
1716
+ type: normalizeCompactType(opts.compaction?.type ?? opts.compaction?.compactType ?? opts.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
1717
+ compactType: normalizeCompactType(opts.compaction?.type ?? opts.compaction?.compactType ?? opts.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
1332
1718
  model: opts.compaction?.model || null,
1333
1719
  timeoutMs: positiveContextWindow(opts.compaction?.timeoutMs),
1334
1720
  tailTurns: positiveContextWindow(opts.compaction?.tailTurns),
1335
1721
  bufferTokens: positiveContextWindow(opts.compaction?.bufferTokens ?? opts.compaction?.buffer),
1722
+ // Preserve percent/ratio-named buffer config so the manager/loop/
1723
+ // contextStatus parsers (which read bufferPercent/bufferPct/
1724
+ // bufferRatio/bufferFraction) can honor it. createSession previously
1725
+ // only stored bufferTokens, silently dropping a configured percent.
1726
+ ...preserveBufferConfigFields(opts.compaction),
1336
1727
  keepTokens: positiveContextWindow(opts.compaction?.keepTokens ?? opts.compaction?.keep?.tokens),
1337
1728
  preserveRecentTokens: positiveContextWindow(opts.compaction?.preserveRecentTokens),
1338
1729
  reservedTokens: positiveContextWindow(opts.compaction?.reservedTokens),
1730
+ recallIngestLimit: positiveContextWindow(opts.compaction?.recallIngestLimit),
1731
+ recallChunkLimit: positiveContextWindow(opts.compaction?.recallChunkLimit ?? opts.compaction?.recallLimit),
1732
+ recallCycle1BatchSize: positiveContextWindow(opts.compaction?.recallCycle1BatchSize),
1733
+ recallRowsPerSession: positiveContextWindow(opts.compaction?.recallRowsPerSession),
1734
+ recallWindowSize: positiveContextWindow(opts.compaction?.recallWindowSize),
1735
+ recallConcurrency: positiveContextWindow(opts.compaction?.recallConcurrency),
1736
+ recallCycle1DeadlineMs: positiveContextWindow(opts.compaction?.recallCycle1DeadlineMs),
1339
1737
  boundaryTokens: contextMeta.compactBoundaryTokens,
1340
1738
  },
1341
1739
  tools,
@@ -1347,40 +1745,42 @@ export function createSession(opts) {
1347
1745
  owner: opts.owner || 'user',
1348
1746
  mcpPid: process.pid,
1349
1747
  scopeKey: opts.scopeKey || null,
1350
- lane: opts.lane || 'bridge',
1748
+ lane: opts.lane || 'agent',
1351
1749
  cwd: opts.cwd,
1750
+ workflow: workflowMeta,
1352
1751
  createdAt: Date.now(),
1353
1752
  updatedAt: Date.now(),
1354
1753
  lastHeartbeatAt: null,
1355
1754
  totalInputTokens: 0,
1356
1755
  totalOutputTokens: 0,
1357
- // Refreshed on each completed ask() — surfaced by bridge type=list for
1756
+ // Refreshed on each completed ask() — surfaced by agent type=list for
1358
1757
  // debugging + consumed by store.mjs's idle-sweep to reclaim stalled
1359
- // bridge sessions past RUNNING_STALL_MS.
1758
+ // agent sessions past RUNNING_STALL_MS.
1360
1759
  lastUsedAt: Date.now(),
1361
1760
  tokensCumulative: 0,
1362
1761
  role: opts.role || null,
1363
1762
  taskType: opts.taskType || null,
1364
1763
  maxLoopIterations: Number.isFinite(opts.maxLoopIterations) ? opts.maxLoopIterations : null,
1365
- // Bridge tag (auto worker{n} on spawn) persisted so the forked status
1764
+ // Agent tag (auto worker{n} on spawn) persisted so the forked status
1366
1765
  // process (statusline) + aggregator can read it from the session JSON.
1367
1766
  // In-process send/close still resolve via _tagSessionRegistry.
1368
- bridgeTag: opts.bridgeTag || null,
1767
+ agentTag: opts.agentTag || null,
1369
1768
  // Prompt permission is separate from runtime toolPermission so preset
1370
- // restrictions do not fragment the bridge cache prefix.
1769
+ // restrictions do not fragment the agent cache prefix.
1371
1770
  permission: permission || null,
1372
1771
  toolPermission: toolPermission || null,
1373
- // Origin tag written into every bridge-trace usage row so analytics
1772
+ schemaAllowedTools: hasCallerAllow ? opts.schemaAllowedTools.map((n) => String(n)) : null,
1773
+ // Origin tag written into every agent-trace usage row so analytics
1374
1774
  // can slice by (sourceType, sourceName) — e.g. maintenance/cycle1,
1375
1775
  // scheduler/daily-standup, webhook/github-push, lead/worker.
1376
1776
  sourceType: opts.sourceType || null,
1377
1777
  sourceName: opts.sourceName || null,
1378
1778
  // Provider-scoped unified cache key — one shard per provider,
1379
- // shared across all roles / sources (bridge/maintenance/mcp/
1779
+ // shared across all roles / sources (agent/maintenance/mcp/
1380
1780
  // scheduler/webhook). Role or source-specific context must be
1381
1781
  // injected into the message tail, not the shared prefix.
1382
1782
  promptCacheKey: providerCacheKey(presetObj?.provider || opts.provider, opts.cacheKeyOverride),
1383
- // Bridge shell continuity: when a bridge session explicitly opts into
1783
+ // Agent shell continuity: when an agent session explicitly opts into
1384
1784
  // persistent shell state (`bash` with `persistent:true`, or direct
1385
1785
  // `bash_session`), the minted bash_session id is stored here so later
1386
1786
  // opted-in `bash` calls can reuse the same shell state.
@@ -1389,7 +1789,7 @@ export function createSession(opts) {
1389
1789
  // orchestrator session so closeSession can kill them all, not just
1390
1790
  // the most recently recorded one.
1391
1791
  allBashSessionIds: [],
1392
- // Smart Bridge metadata — optional. Applied on every ask() to merge
1792
+ // Agent Runtime metadata — optional. Applied on every ask() to merge
1393
1793
  // profile-driven cache settings into provider sendOpts.
1394
1794
  profileId: profile?.id || null,
1395
1795
  permissionMode: opts.permissionMode ?? null,
@@ -1405,7 +1805,7 @@ export function createSession(opts) {
1405
1805
  }
1406
1806
 
1407
1807
  // ── Runtime liveness map ──────────────────────────────────────────────
1408
- // In-memory only. Tracks per-session stage + stream heartbeat so bridge type=list
1808
+ // In-memory only. Tracks per-session stage + stream heartbeat so agent type=list
1409
1809
  // can surface whether a session is actually alive vs stuck. Never persisted —
1410
1810
  // heartbeats would otherwise churn the session JSON on every SSE delta.
1411
1811
  // Entry shape: {
@@ -1415,6 +1815,7 @@ export function createSession(opts) {
1415
1815
  // closed?: boolean, // flipped by closeSession()
1416
1816
  // }
1417
1817
  const _runtimeState = new Map();
1818
+ const _toolActivityHeartbeats = new Map();
1418
1819
  const VALID_STAGES = new Set([
1419
1820
  'connecting', 'requesting', 'streaming', 'tool_running', 'idle', 'error', 'done', 'cancelling',
1420
1821
  ]);
@@ -1426,6 +1827,33 @@ function _touchRuntime(id) {
1426
1827
  }
1427
1828
  return entry;
1428
1829
  }
1830
+
1831
+ function _stopToolActivityHeartbeat(id) {
1832
+ if (!id) return;
1833
+ const timer = _toolActivityHeartbeats.get(id);
1834
+ if (!timer) return;
1835
+ try { clearInterval(timer); } catch { /* ignore */ }
1836
+ _toolActivityHeartbeats.delete(id);
1837
+ }
1838
+
1839
+ function _touchSessionActivityProgress(id) {
1840
+ const entry = _runtimeState.get(id);
1841
+ if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
1842
+ if (entry.stage !== 'tool_running') return;
1843
+ const now = Date.now();
1844
+ entry.lastProgressAt = now;
1845
+ entry.updatedAt = now;
1846
+ publishHeartbeat(id, now);
1847
+ }
1848
+
1849
+ function _startToolActivityHeartbeat(id) {
1850
+ _stopToolActivityHeartbeat(id);
1851
+ if (!(DEFAULT_ACTIVITY_HEARTBEAT_MS > 0)) return;
1852
+ const timer = setInterval(() => _touchSessionActivityProgress(id), DEFAULT_ACTIVITY_HEARTBEAT_MS);
1853
+ if (typeof timer.unref === 'function') timer.unref();
1854
+ _toolActivityHeartbeats.set(id, timer);
1855
+ }
1856
+
1429
1857
  export function updateSessionStage(id, stage) {
1430
1858
  if (!id || !VALID_STAGES.has(stage)) return;
1431
1859
  const entry = _touchRuntime(id);
@@ -1436,6 +1864,7 @@ export function updateSessionStage(id, stage) {
1436
1864
  }
1437
1865
  entry.lastProgressAt = now;
1438
1866
  entry.updatedAt = now;
1867
+ if (stage !== 'tool_running') _stopToolActivityHeartbeat(id);
1439
1868
  }
1440
1869
 
1441
1870
  export function updateSessionRoute(id, route = {}) {
@@ -1501,7 +1930,11 @@ export function updateSessionRoute(id, route = {}) {
1501
1930
  */
1502
1931
  export function markSessionAskStart(id) {
1503
1932
  if (!id) return;
1933
+ _stopToolActivityHeartbeat(id);
1504
1934
  const entry = _touchRuntime(id);
1935
+ entry.usageMetricsTurnIncremental = false;
1936
+ const sessionForTurn = entry.session ?? loadSession(id);
1937
+ if (sessionForTurn) bumpUsageMetricsTurnId(sessionForTurn);
1505
1938
  entry.stage = 'connecting';
1506
1939
  entry.lastStreamDeltaAt = null;
1507
1940
  entry.lastToolCall = null;
@@ -1525,7 +1958,7 @@ export function markSessionAskStart(id) {
1525
1958
  // Publish heartbeat immediately so the status aggregator picks the
1526
1959
  // session up in the connecting / requesting window. Without this the
1527
1960
  // .hb file only landed on the first stream chunk — producing a 3–10s
1528
- // (xhigh: 30s+) invisible gap where bridge sessions ran but the CC
1961
+ // (xhigh: 30s+) invisible gap where agent sessions ran but the CC
1529
1962
  // statusline showed no maintenance/agent badge. STREAM_FRESH_MS (5 min)
1530
1963
  // still drops a session whose provider truly never returns a chunk;
1531
1964
  // markSessionStreamDelta keeps refreshing once chunks arrive.
@@ -1543,6 +1976,7 @@ export async function markSessionStreamDelta(id) {
1543
1976
  // path). Skip a missing, tombstoned, or aborted entry — never refresh liveness.
1544
1977
  const entry = _runtimeState.get(id);
1545
1978
  if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
1979
+ _stopToolActivityHeartbeat(id);
1546
1980
  const now = Date.now();
1547
1981
  entry.lastStreamDeltaAt = now;
1548
1982
  entry.lastProgressAt = now;
@@ -1572,9 +2006,14 @@ export function markSessionToolCall(id, toolName) {
1572
2006
  entry.lastProgressAt = entry.toolStartedAt;
1573
2007
  entry.updatedAt = entry.toolStartedAt;
1574
2008
  publishHeartbeat(id, entry.toolStartedAt);
2009
+ _startToolActivityHeartbeat(id);
1575
2010
  }
2011
+ // Parent AbortSignal listeners are dropped on askSession unwind (finally /
2012
+ // terminal return) and on error/cancel/close — not in markSessionDone, which
2013
+ // also runs between queued follow-up turns within one ask.
1576
2014
  export function markSessionDone(id, { empty = false } = {}) {
1577
2015
  if (!id) return;
2016
+ _stopToolActivityHeartbeat(id);
1578
2017
  const entry = _touchRuntime(id);
1579
2018
  entry.stage = 'done';
1580
2019
  entry.lastError = null;
@@ -1609,6 +2048,7 @@ export function markSessionEmptyFinal(id) {
1609
2048
  }
1610
2049
  export function markSessionError(id, msg) {
1611
2050
  if (!id) return;
2051
+ _stopToolActivityHeartbeat(id);
1612
2052
  const entry = _touchRuntime(id);
1613
2053
  entry.stage = 'error';
1614
2054
  entry.lastError = msg ? String(msg).slice(0, 200) : null;
@@ -1623,9 +2063,11 @@ export function markSessionError(id, msg) {
1623
2063
  entry.lastProgressAt = errTs;
1624
2064
  entry.updatedAt = errTs;
1625
2065
  deleteHeartbeat(id);
2066
+ _unlinkParentAbortListener(entry);
1626
2067
  }
1627
2068
  export function markSessionCancelled(id) {
1628
2069
  if (!id) return;
2070
+ _stopToolActivityHeartbeat(id);
1629
2071
  const entry = _touchRuntime(id);
1630
2072
  entry.stage = 'done';
1631
2073
  entry.lastError = null;
@@ -1638,11 +2080,24 @@ export function markSessionCancelled(id) {
1638
2080
  entry.lastProgressAt = doneTs;
1639
2081
  entry.updatedAt = doneTs;
1640
2082
  deleteHeartbeat(id);
2083
+ _unlinkParentAbortListener(entry);
1641
2084
  }
1642
2085
  export function getSessionRuntime(id) {
1643
2086
  return id ? (_runtimeState.get(id) || null) : null;
1644
2087
  }
1645
2088
 
2089
+ const _COMPACTION_BLOCKED_STAGES = new Set([
2090
+ 'connecting', 'requesting', 'streaming', 'tool_running', 'cancelling',
2091
+ ]);
2092
+
2093
+ export function isSessionCompactionBlocked(sessionId) {
2094
+ if (!sessionId) return false;
2095
+ const entry = _runtimeState.get(sessionId);
2096
+ if (!entry || entry.closed === true) return false;
2097
+ if (entry.controller && !entry.controller.signal?.aborted) return true;
2098
+ return _COMPACTION_BLOCKED_STAGES.has(entry.stage);
2099
+ }
2100
+
1646
2101
  export function getSessionProgressSnapshot(sessionId) {
1647
2102
  const entry = _runtimeState.get(sessionId);
1648
2103
  if (!entry) return null;
@@ -1682,29 +2137,126 @@ export function forEachSessionRuntime() {
1682
2137
  }
1683
2138
 
1684
2139
  // --- Incremental metric persistence (fix A) ---
1685
- // Per-session idempotency tracking: sessionId → Set of seen iterationIndex keys.
2140
+ // Per-session idempotency tracking: sessionId → Set of seen turn:epoch:iteration:source keys.
1686
2141
  const _metricSeenIter = new Map();
1687
2142
 
2143
+ /** Monotonic per-session ask/turn id for incremental usage idempotency. */
2144
+ export function bumpUsageMetricsTurnId(session) {
2145
+ if (!session || typeof session !== 'object') return 0;
2146
+ const next = (Number(session.usageMetricsTurnId) || 0) + 1;
2147
+ session.usageMetricsTurnId = next;
2148
+ return next;
2149
+ }
2150
+
2151
+ export function resolveUsageMetricsTurnId(session, delta = {}) {
2152
+ if (delta.usageMetricsTurnId != null && Number.isFinite(Number(delta.usageMetricsTurnId))) {
2153
+ return Number(delta.usageMetricsTurnId);
2154
+ }
2155
+ return Number(session?.usageMetricsTurnId) || 0;
2156
+ }
2157
+
2158
+ /** Advance loop metrics epoch when agentLoop resets its iteration counter (post-compact). */
2159
+ export function bumpUsageMetricsEpoch(session) {
2160
+ if (!session || typeof session !== 'object') return 0;
2161
+ const next = (Number(session.usageMetricsEpoch) || 0) + 1;
2162
+ session.usageMetricsEpoch = next;
2163
+ return next;
2164
+ }
2165
+
2166
+ /**
2167
+ * Resolve usage-metrics epoch for idempotency (exported for regression smoke).
2168
+ * Prefers session.usageMetricsEpoch (bumped in loop on compact reset) and optional
2169
+ * delta.usageMetricsEpoch; falls back to iteration regression when loop did not bump.
2170
+ */
2171
+ export function resolveUsageMetricsEpoch(session, delta = {}) {
2172
+ if (!session) return 0;
2173
+ let epoch = Number(session.usageMetricsEpoch) || 0;
2174
+ if (delta.usageMetricsEpoch != null && Number.isFinite(Number(delta.usageMetricsEpoch))) {
2175
+ epoch = Math.max(epoch, Number(delta.usageMetricsEpoch));
2176
+ }
2177
+ const idx = Number(delta.iterationIndex);
2178
+ const prevLastIdx = typeof session.lastIterationIndex === 'number'
2179
+ ? session.lastIterationIndex
2180
+ : null;
2181
+ if (
2182
+ (delta.usageMetricsEpoch == null || !Number.isFinite(Number(delta.usageMetricsEpoch)))
2183
+ && prevLastIdx !== null
2184
+ && Number.isFinite(idx)
2185
+ && idx < prevLastIdx
2186
+ ) {
2187
+ epoch += 1;
2188
+ }
2189
+ return epoch;
2190
+ }
2191
+
2192
+ export function usageMetricsSourceKey(delta = {}) {
2193
+ const raw = delta.source ?? delta.usageSource;
2194
+ if (raw == null || raw === '') return 'provider_send';
2195
+ return String(raw);
2196
+ }
2197
+
2198
+ /** Idempotency key for incremental usage persistence (exported for regression smoke). */
2199
+ export function usageMetricsIdempotencyKey(sessionId, session, delta = {}) {
2200
+ const turnId = resolveUsageMetricsTurnId(session, delta);
2201
+ const epoch = resolveUsageMetricsEpoch(session, delta);
2202
+ const source = usageMetricsSourceKey(delta);
2203
+ return `${sessionId}:${turnId}:${epoch}:${delta.iterationIndex}:${source}`;
2204
+ }
2205
+
2206
+ /**
2207
+ * Apply terminal ask usage to session totals. Skips lifetime totals when incremental
2208
+ * per-iteration persistence already counted this turn (askSession path).
2209
+ */
2210
+ export function applyAskTerminalUsageTotals(session, result, options = {}) {
2211
+ if (!session || !result?.usage) return;
2212
+ const skipTotals = options.skipTotalsIfIncremental === true;
2213
+ if (!skipTotals) {
2214
+ session.totalInputTokens = (session.totalInputTokens || 0) + (result.usage.inputTokens || 0);
2215
+ session.totalOutputTokens = (session.totalOutputTokens || 0) + (result.usage.outputTokens || 0);
2216
+ session.tokensCumulative = (session.tokensCumulative || 0)
2217
+ + (result.usage.inputTokens || 0)
2218
+ + (result.usage.outputTokens || 0);
2219
+ session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (result.usage.cachedTokens || 0);
2220
+ session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (result.usage.cacheWriteTokens || 0);
2221
+ }
2222
+ const _lastTurn = result.lastTurnUsage || result.usage || {};
2223
+ session.lastInputTokens = _lastTurn.inputTokens || 0;
2224
+ session.lastOutputTokens = _lastTurn.outputTokens || 0;
2225
+ session.lastCachedReadTokens = _lastTurn.cachedTokens || 0;
2226
+ session.lastCacheWriteTokens = _lastTurn.cacheWriteTokens || 0;
2227
+ const _inputExcludesCache = providerInputExcludesCache(session.provider);
2228
+ session.lastContextTokens = _inputExcludesCache
2229
+ ? (_lastTurn.inputTokens || 0) + (_lastTurn.cachedTokens || 0)
2230
+ : (_lastTurn.inputTokens || 0);
2231
+ session.lastContextTokensUpdatedAt = Date.now();
2232
+ session.lastContextTokensStaleAfterCompact = false;
2233
+ }
2234
+
1688
2235
  /**
1689
2236
  * Persist incremental usage delta immediately after each provider.send iteration.
1690
- * Idempotency key `sessionId:iterationIndex` ensures a retry of the same iteration
1691
- * index overwrites instead of double-counting.
2237
+ * Idempotency key `sessionId:turnId:epoch:iterationIndex:source` scopes retries
2238
+ * per ask, compaction epoch, iteration, and usage source.
1692
2239
  */
1693
2240
  export async function persistIterationMetrics(delta) {
1694
2241
  if (!delta || !delta.sessionId) return;
1695
2242
  const { sessionId, iterationIndex, deltaInput, deltaOutput, deltaCachedRead, deltaCacheWrite, ts } = delta;
2243
+ const runtimeEntry = _runtimeState.get(sessionId);
2244
+ const session = runtimeEntry?.session ?? loadSession(sessionId);
2245
+ if (!session || session.closed) return;
2246
+ const epoch = resolveUsageMetricsEpoch(session, delta);
2247
+ if (epoch !== (Number(session.usageMetricsEpoch) || 0)) {
2248
+ session.usageMetricsEpoch = epoch;
2249
+ }
1696
2250
  let seen = _metricSeenIter.get(sessionId);
1697
2251
  if (!seen) {
1698
2252
  seen = new Set();
1699
2253
  _metricSeenIter.set(sessionId, seen);
1700
2254
  }
1701
- const ikey = `${sessionId}:${iterationIndex}`;
2255
+ const ikey = usageMetricsIdempotencyKey(sessionId, session, delta);
1702
2256
  const isReplay = seen.has(ikey);
1703
2257
  seen.add(ikey);
1704
- const runtimeEntry = _runtimeState.get(sessionId);
1705
- const session = runtimeEntry?.session ?? loadSession(sessionId);
1706
- if (!session || session.closed) return;
1707
2258
  if (!isReplay) {
2259
+ if (runtimeEntry) runtimeEntry.usageMetricsTurnIncremental = true;
1708
2260
  session.totalInputTokens = (session.totalInputTokens || 0) + (deltaInput || 0);
1709
2261
  session.totalOutputTokens = (session.totalOutputTokens || 0) + (deltaOutput || 0);
1710
2262
  session.tokensCumulative = (session.tokensCumulative || 0) + (deltaInput || 0) + (deltaOutput || 0);
@@ -1714,7 +2266,7 @@ export async function persistIterationMetrics(delta) {
1714
2266
  // includes cached_read / cache_write in its terminal usage rollup).
1715
2267
  session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (deltaCachedRead || 0);
1716
2268
  session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (deltaCacheWrite || 0);
1717
- // Window snapshot updated per iteration so bridge type=list reflects the
2269
+ // Window snapshot updated per iteration so agent type=list reflects the
1718
2270
  // most-recent provider-reported input size even for short dispatches
1719
2271
  // that finish before askSession's terminal save lands.
1720
2272
  session.lastInputTokens = deltaInput || 0;
@@ -1740,6 +2292,14 @@ export async function persistIterationMetrics(delta) {
1740
2292
 
1741
2293
  function standaloneStatusRouteInfo(session) {
1742
2294
  if (!session) return null;
2295
+ // autoCompactTokenLimit is an EXPLICIT sub-boundary auto-compact limit only.
2296
+ // Do NOT fall back to compactBoundaryTokens/contextWindow here — that
2297
+ // labels a derived full-window value as an explicit limit, which the
2298
+ // runtime would treat as the compaction trigger and collapse the buffer.
2299
+ // The boundary/window stays available via contextWindow/rawContextWindow.
2300
+ const _boundary = Number(session.compactBoundaryTokens || session.contextWindow || 0);
2301
+ const _limit = Number(session.autoCompactTokenLimit || 0);
2302
+ const explicitAutoCompactTokenLimit = _limit > 0 && (!_boundary || _limit < _boundary) ? _limit : null;
1743
2303
  return {
1744
2304
  provider: session.provider,
1745
2305
  model: session.model,
@@ -1749,7 +2309,7 @@ function standaloneStatusRouteInfo(session) {
1749
2309
  contextWindow: session.contextWindow || null,
1750
2310
  rawContextWindow: session.rawContextWindow || session.contextWindow || null,
1751
2311
  effectiveContextWindowPercent: session.effectiveContextWindowPercent || null,
1752
- autoCompactTokenLimit: session.autoCompactTokenLimit || session.compactBoundaryTokens || null,
2312
+ autoCompactTokenLimit: explicitAutoCompactTokenLimit,
1753
2313
  presetId: session.presetId || null,
1754
2314
  presetName: session.presetName || null,
1755
2315
  };
@@ -1764,9 +2324,17 @@ function recordStandaloneStatusTelemetry(session, result, durationMs) {
1764
2324
  model: result.model,
1765
2325
  serviceTier: result.serviceTier,
1766
2326
  };
2327
+ // The transcript estimate is the SSOT for the displayed context footprint.
2328
+ // agentLoop()'s result has no `compact` field, so build a synthetic compact
2329
+ // arg carrying the live monotonic estimate (estimateMessagesTokens+reserve)
2330
+ // as afterTokens. This lights up summarizeGatewayUsage's estimate-based
2331
+ // contextUsedPct branch (provider input_tokens swing wildly / unbounded on
2332
+ // e.g. OpenAI gpt-5.5), and lets a genuine >100% pass through.
2333
+ const _estTokens = estimateTranscriptContextUsage(session.messages, session.tools || []);
2334
+ const _compactArg = { ...(result.compact && typeof result.compact === 'object' ? result.compact : {}), afterTokens: _estTokens };
1767
2335
  try {
1768
2336
  const summary = {
1769
- ...summarizeGatewayUsage(routeInfo, providerOut, result.compact || null, durationMs),
2337
+ ...summarizeGatewayUsage(routeInfo, providerOut, _compactArg, durationMs),
1770
2338
  requestKind: 'chat',
1771
2339
  sessionId: session.id || null,
1772
2340
  toolCount: result.toolCallsTotal ?? null,
@@ -1829,6 +2397,7 @@ export function getSessionLastProgressAt(sessionId) {
1829
2397
  const entry = _runtimeState.get(sessionId);
1830
2398
  if (!entry) return 0;
1831
2399
  return Math.max(
2400
+ entry.lastProgressAt || 0,
1832
2401
  entry.lastStreamDeltaAt || 0,
1833
2402
  entry.toolStartedAt || 0,
1834
2403
  entry.askStartedAt || 0,
@@ -1837,8 +2406,8 @@ export function getSessionLastProgressAt(sessionId) {
1837
2406
 
1838
2407
  /**
1839
2408
  * Link a parent AbortSignal to a sub-session's controller so that aborting
1840
- * the parent (fan-out deadline or caller ESC) tears down the bridge role's
1841
- * provider call promptly. Safe to call after prepareBridgeSession but before
2409
+ * the parent (fan-out deadline or caller ESC) tears down the agent role's
2410
+ * provider call promptly. Safe to call after prepareAgentSession but before
1842
2411
  * askSession completes. No-op if the session runtime isn't found.
1843
2412
  *
1844
2413
  * @param {string} sessionId — the sub-session to abort
@@ -1855,15 +2424,27 @@ export function linkParentSignalToSession(sessionId, parentSignal) {
1855
2424
  return new Error('parent signal aborted');
1856
2425
  };
1857
2426
  if (parentSignal.aborted) {
2427
+ _unlinkParentAbortListener(entry);
1858
2428
  try { entry.controller.abort(abortReason()); } catch { /* ignore */ }
1859
2429
  return;
1860
2430
  }
1861
- parentSignal.addEventListener('abort', () => {
2431
+ _unlinkParentAbortListener(entry);
2432
+ const onParentAbort = () => {
1862
2433
  try { entry.controller?.abort(abortReason()); } catch { /* ignore */ }
1863
- }, { once: true });
2434
+ };
2435
+ parentSignal.addEventListener('abort', onParentAbort, { once: true });
2436
+ entry.parentAbortLink = { signal: parentSignal, listener: onParentAbort };
2437
+ }
2438
+ function _unlinkParentAbortListener(entry) {
2439
+ const link = entry?.parentAbortLink;
2440
+ if (!link) return;
2441
+ try { link.signal.removeEventListener('abort', link.listener); } catch { /* ignore */ }
2442
+ entry.parentAbortLink = null;
1864
2443
  }
1865
2444
  function _clearSessionRuntime(id) {
1866
2445
  if (id) {
2446
+ _stopToolActivityHeartbeat(id);
2447
+ _unlinkParentAbortListener(_runtimeState.get(id));
1867
2448
  _runtimeState.delete(id);
1868
2449
  // R15: also drop the per-session metric-idempotency Set; otherwise it
1869
2450
  // grows O(sessions x iterations) for the whole server lifetime since
@@ -1915,8 +2496,8 @@ export async function _api_call_with_interrupt(sessionId, fn) {
1915
2496
 
1916
2497
  // Per-session mutex: queues concurrent askSession calls to prevent message loss
1917
2498
  const _sessionLocks = new Map();
1918
- // Per-session pending-message queue (Claude Code `pendingMessages` pattern).
1919
- // A `bridge type=send` to a worker whose turn is still in flight ENQUEUES the
2499
+ // Per-session pending-message queue (in-flight send enqueue pattern).
2500
+ // A `agent type=send` to a worker whose turn is still in flight ENQUEUES the
1920
2501
  // message here instead of rejecting; askSession drains the queue after each
1921
2502
  // turn and runs the messages as the next user turn(s), preserving order — the
1922
2503
  // queued send runs AFTER the in-flight prompt, which also closes the spawn
@@ -1929,11 +2510,16 @@ const _sessionLocks = new Map();
1929
2510
  // Keeping the queue outside the session JSON avoids racing session saves that
1930
2511
  // loaded before the send arrived.
1931
2512
  //
1932
- // Map<sessionId, string[]>. Shared with index.mjs's bridge send handler via
1933
- // the enqueue/drain accessors below — one queue contract, two call sites.
2513
+ // Map<sessionId, Array<string|{text?:string,content:any}>>. Shared with
2514
+ // index.mjs's agent send handler via the enqueue/drain accessors below — one
2515
+ // queue contract, two call sites. Rich content is kept in memory for the live
2516
+ // relay path; the disk mirror stores only a text fallback so image bytes do not
2517
+ // leak into the pending-message JSON.
1934
2518
  const _sessionPendingMessages = new Map();
1935
2519
  const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
1936
2520
  const PENDING_MESSAGES_MODE = 0o600;
2521
+ const _pendingPersistBuffers = new Map();
2522
+ let _pendingPersistImmediate = null;
1937
2523
 
1938
2524
  function pendingMessagesPath() {
1939
2525
  return join(resolvePluginData(), PENDING_MESSAGES_FILE);
@@ -1962,14 +2548,53 @@ function normalizePendingStore(raw) {
1962
2548
  return out;
1963
2549
  }
1964
2550
 
1965
- function persistPendingMessage(sessionId, message) {
2551
+ function normalizePendingMessageEntry(entry) {
2552
+ if (typeof entry === 'string') {
2553
+ const text = entry.trim();
2554
+ return text ? { content: text, text } : null;
2555
+ }
2556
+ if (Array.isArray(entry)) {
2557
+ if (entry.length === 0) return null;
2558
+ const text = promptContentText(entry).trim();
2559
+ return { content: entry, text };
2560
+ }
2561
+ if (!entry || typeof entry !== 'object') return null;
2562
+ const content = Object.prototype.hasOwnProperty.call(entry, 'content') ? entry.content : null;
2563
+ if (content == null) return null;
2564
+ const text = typeof entry.text === 'string' ? entry.text.trim() : promptContentText(content).trim();
2565
+ if (Array.isArray(content)) return content.length > 0 ? { content, text } : null;
2566
+ if (typeof content === 'string') {
2567
+ const value = content.trim();
2568
+ return value ? { content: value, text: text || value } : null;
2569
+ }
2570
+ const fallback = promptContentText(content).trim();
2571
+ return fallback ? { content: fallback, text: text || fallback } : null;
2572
+ }
2573
+
2574
+ function pendingMessageText(entry) {
2575
+ const normalized = normalizePendingMessageEntry(entry);
2576
+ return normalized ? String(normalized.text || promptContentText(normalized.content) || '').trim() : '';
2577
+ }
2578
+
2579
+ function pendingMessageQueueEntry(entry) {
2580
+ const normalized = normalizePendingMessageEntry(entry);
2581
+ if (!normalized) return null;
2582
+ if (typeof normalized.content === 'string' && normalized.content === normalized.text) return normalized.content;
2583
+ return { content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
2584
+ }
2585
+
2586
+ function persistPendingMessages(sessionId, messages) {
1966
2587
  if (!isValidPendingSessionId(sessionId)) return 0;
2588
+ const persistedMessages = (Array.isArray(messages) ? messages : [messages])
2589
+ .map(pendingMessageText)
2590
+ .filter(Boolean);
2591
+ if (persistedMessages.length === 0) return 0;
1967
2592
  let depth = 0;
1968
2593
  try {
1969
2594
  updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
1970
2595
  const next = normalizePendingStore(raw);
1971
2596
  const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
1972
- q.push(message);
2597
+ q.push(...persistedMessages);
1973
2598
  next.sessions[sessionId] = q;
1974
2599
  next.updatedAt = Date.now();
1975
2600
  depth = q.length;
@@ -1981,6 +2606,43 @@ function persistPendingMessage(sessionId, message) {
1981
2606
  return depth;
1982
2607
  }
1983
2608
 
2609
+ function flushPendingMessagePersistsSync() {
2610
+ if (_pendingPersistImmediate) {
2611
+ try { clearImmediate(_pendingPersistImmediate); } catch {}
2612
+ _pendingPersistImmediate = null;
2613
+ }
2614
+ if (_pendingPersistBuffers.size === 0) return;
2615
+ const batches = [..._pendingPersistBuffers.entries()];
2616
+ _pendingPersistBuffers.clear();
2617
+ for (const [sid, messages] of batches) {
2618
+ persistPendingMessages(sid, messages);
2619
+ }
2620
+ }
2621
+
2622
+ function schedulePendingMessagePersist(sessionId, message) {
2623
+ if (!isValidPendingSessionId(sessionId)) return 0;
2624
+ const persistedMessage = pendingMessageText(message);
2625
+ if (!persistedMessage) return 0;
2626
+ const q = _pendingPersistBuffers.get(sessionId) || [];
2627
+ q.push(persistedMessage);
2628
+ _pendingPersistBuffers.set(sessionId, q);
2629
+ if (!_pendingPersistImmediate) {
2630
+ _pendingPersistImmediate = setImmediate(() => {
2631
+ _pendingPersistImmediate = null;
2632
+ flushPendingMessagePersistsSync();
2633
+ });
2634
+ }
2635
+ return q.length;
2636
+ }
2637
+
2638
+ function takeBufferedPendingMessages(sessionId) {
2639
+ if (!isValidPendingSessionId(sessionId)) return [];
2640
+ const buffered = _pendingPersistBuffers.get(sessionId);
2641
+ if (!buffered || buffered.length === 0) return [];
2642
+ _pendingPersistBuffers.delete(sessionId);
2643
+ return buffered.slice();
2644
+ }
2645
+
1984
2646
  function drainPersistedPendingMessages(sessionId) {
1985
2647
  if (!isValidPendingSessionId(sessionId)) return [];
1986
2648
  let drained = [];
@@ -2001,25 +2663,33 @@ function drainPersistedPendingMessages(sessionId) {
2001
2663
  }
2002
2664
 
2003
2665
  export function enqueuePendingMessage(sessionId, message) {
2004
- if (!sessionId || typeof message !== 'string' || !message) return 0;
2666
+ const entry = pendingMessageQueueEntry(message);
2667
+ if (!sessionId || !entry) return 0;
2005
2668
  let q = _sessionPendingMessages.get(sessionId);
2006
2669
  if (!q) { q = []; _sessionPendingMessages.set(sessionId, q); }
2007
- q.push(message);
2008
- const persistedDepth = persistPendingMessage(sessionId, message);
2009
- return Math.max(q.length, persistedDepth || 0);
2670
+ q.push(entry);
2671
+ const bufferedDepth = schedulePendingMessagePersist(sessionId, entry);
2672
+ return Math.max(q.length, bufferedDepth || 0);
2010
2673
  }
2011
2674
  export function drainPendingMessages(sessionId) {
2012
2675
  const q = _sessionPendingMessages.get(sessionId);
2013
2676
  const memory = q && q.length > 0 ? q.slice() : [];
2014
2677
  _sessionPendingMessages.delete(sessionId);
2015
- const persisted = drainPersistedPendingMessages(sessionId);
2016
- if (memory.length === 0) return persisted;
2017
- if (persisted.length === 0) return memory;
2018
- const prefixMatches = memory.every((m, i) => persisted[i] === m);
2019
- if (prefixMatches) return [...memory, ...persisted.slice(memory.length)];
2020
- const out = persisted.slice();
2021
- for (const m of memory) {
2022
- if (!out.includes(m)) out.push(m);
2678
+ const persisted = [...takeBufferedPendingMessages(sessionId), ...drainPersistedPendingMessages(sessionId)];
2679
+ const memoryVisible = modelVisiblePendingMessages(memory);
2680
+ const persistedVisible = modelVisiblePendingMessages(persisted);
2681
+ if (memoryVisible.length === 0) return persistedVisible;
2682
+ if (persistedVisible.length === 0) return memoryVisible;
2683
+ const persistedTexts = persistedVisible.map(pendingMessageText);
2684
+ const prefixMatches = memoryVisible.every((m, i) => persistedTexts[i] === pendingMessageText(m));
2685
+ if (prefixMatches) return [...memoryVisible, ...persistedVisible.slice(memoryVisible.length)];
2686
+ const out = persistedVisible.slice();
2687
+ const seen = new Set(persistedTexts);
2688
+ for (const m of memoryVisible) {
2689
+ const text = pendingMessageText(m);
2690
+ if (!text || seen.has(text)) continue;
2691
+ out.push(m);
2692
+ seen.add(text);
2023
2693
  }
2024
2694
  return out;
2025
2695
  }
@@ -2037,6 +2707,10 @@ function promptContentText(content) {
2037
2707
  return String(content ?? '');
2038
2708
  }
2039
2709
 
2710
+ function hasModelVisiblePromptContent(prompt) {
2711
+ return !!promptContentText(prompt).trim();
2712
+ }
2713
+
2040
2714
  function promptContentBytes(content) {
2041
2715
  try {
2042
2716
  if (typeof content === 'string') return Buffer.byteLength(content, 'utf8');
@@ -2053,6 +2727,190 @@ function prefixUserTurnContent(content, contextBlock) {
2053
2727
  }
2054
2728
  return `${contextBlock}# Task\n${content}`;
2055
2729
  }
2730
+
2731
+ function prefixSessionStartContent(content, sessionBlock) {
2732
+ if (!sessionBlock) return content;
2733
+ if (Array.isArray(content)) {
2734
+ return [{ type: 'text', text: `${sessionBlock}\n\n` }, ...content];
2735
+ }
2736
+ return `${sessionBlock}\n\n${content}`;
2737
+ }
2738
+
2739
+ function localIsoDate(date = new Date()) {
2740
+ const year = date.getFullYear();
2741
+ const month = String(date.getMonth() + 1).padStart(2, '0');
2742
+ const day = String(date.getDate()).padStart(2, '0');
2743
+ return `${year}-${month}-${day}`;
2744
+ }
2745
+
2746
+ function localDateTimeWithZone(date = new Date()) {
2747
+ const datePart = localIsoDate(date);
2748
+ const hh = String(date.getHours()).padStart(2, '0');
2749
+ const mm = String(date.getMinutes()).padStart(2, '0');
2750
+ const ss = String(date.getSeconds()).padStart(2, '0');
2751
+ let zone = '';
2752
+ try { zone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch {}
2753
+ return zone ? `${datePart} ${hh}:${mm}:${ss} ${zone}` : `${datePart} ${hh}:${mm}:${ss}`;
2754
+ }
2755
+
2756
+ function temporalPromptText(content) {
2757
+ const text = promptContentText(content)
2758
+ .replace(/\s+/g, ' ')
2759
+ .trim()
2760
+ .toLowerCase();
2761
+ return text;
2762
+ }
2763
+
2764
+ function promptNeedsDateReminder(content) {
2765
+ const text = temporalPromptText(content);
2766
+ if (!text) return false;
2767
+ return /(?:오늘|내일|어제|모레|그저께|요즘|최근|방금|아까|현재\s*(?:날짜|시간|시각)|지금\s*(?:몇\s*시|시간|날짜|요일)|몇\s*월\s*몇\s*일|몇\s*시|무슨\s*요일|요일|날짜|이번\s*(?:주|달|월|년)|지난\s*(?:주|달|월|년)|다음\s*(?:주|달|월|년)|올해|작년|내년|today|tomorrow|yesterday|recently|current\s+(?:date|time)|what\s+(?:date|time)|which\s+day|weekday|this\s+(?:week|month|year)|last\s+(?:week|month|year)|next\s+(?:week|month|year))/i.test(text);
2768
+ }
2769
+
2770
+ function promptNeedsTimeReminder(content) {
2771
+ const text = temporalPromptText(content);
2772
+ if (!text) return false;
2773
+ return /(?:현재\s*(?:시간|시각)|지금\s*(?:몇\s*시|시간)|몇\s*시|시각|시간|current\s+time|what\s+time|time\s+is\s+it)/i.test(text);
2774
+ }
2775
+
2776
+ function buildCurrentTimeBlock(content) {
2777
+ const needsTime = promptNeedsTimeReminder(content);
2778
+ if (!needsTime && !promptNeedsDateReminder(content)) return '';
2779
+ return localDateTimeWithZone(new Date());
2780
+ }
2781
+
2782
+ function sessionModelDisplay(model) {
2783
+ const text = String(model || '').trim();
2784
+ if (!text) return '';
2785
+ return text
2786
+ .replace(/-\d{4}-\d{2}-\d{2}$/, '')
2787
+ .replace(/^gpt-/i, 'GPT-')
2788
+ .replace(/(?:^|-)([a-z])/g, (m) => m.toUpperCase());
2789
+ }
2790
+
2791
+ function buildSessionStartBlock(session, cwd) {
2792
+ if (!session || session.owner === 'agent') return '';
2793
+ const lines = ['# Session'];
2794
+ const effectiveCwd = String(cwd || session.cwd || '').trim();
2795
+ if (effectiveCwd) lines.push(`Cwd: ${effectiveCwd}`);
2796
+ const modelBits = [
2797
+ sessionModelDisplay(session.model),
2798
+ session.effort ? String(session.effort).trim().toUpperCase() : '',
2799
+ session.fast === true ? 'FAST' : '',
2800
+ ].filter(Boolean);
2801
+ if (modelBits.length) lines.push(`Model: ${modelBits.join(' · ')}`);
2802
+ const workflowName = String(session.workflow?.name || session.workflow?.id || '').trim();
2803
+ if (workflowName) lines.push(`Workflow: ${workflowName}`);
2804
+ return lines.length > 1 ? lines.join('\n') : '';
2805
+ }
2806
+
2807
+ function isReferenceFilesMessage(message) {
2808
+ return message?.role === 'user'
2809
+ && typeof message.content === 'string'
2810
+ && /^Reference files:\s*/i.test(message.content.trimStart());
2811
+ }
2812
+
2813
+ function isProtectedContextUserMessage(message) {
2814
+ return message?.role === 'user'
2815
+ && typeof message.content === 'string'
2816
+ && message.content.trimStart().startsWith('<system-reminder>');
2817
+ }
2818
+
2819
+ function hasUserConversationMessage(messages) {
2820
+ return (Array.isArray(messages) ? messages : []).some((message) => (
2821
+ message?.role === 'user'
2822
+ && !isProtectedContextUserMessage(message)
2823
+ && !isReferenceFilesMessage(message)
2824
+ ));
2825
+ }
2826
+
2827
+ function modelVisiblePendingMessages(messages) {
2828
+ return (Array.isArray(messages) ? messages : [])
2829
+ .map(pendingMessageQueueEntry)
2830
+ .filter(Boolean)
2831
+ .filter((message) => !isInternalRuntimeNotificationText(
2832
+ message && typeof message === 'object' && Object.prototype.hasOwnProperty.call(message, 'content')
2833
+ ? message.content
2834
+ : message,
2835
+ ));
2836
+ }
2837
+
2838
+ export function _mergePendingMessageEntries(entries) {
2839
+ const normalized = (Array.isArray(entries) ? entries : [])
2840
+ .map(normalizePendingMessageEntry)
2841
+ .filter(Boolean);
2842
+ if (normalized.length === 0) return null;
2843
+ const displayText = normalized.map((entry) => entry.text || promptContentText(entry.content))
2844
+ .filter((text) => String(text || '').trim())
2845
+ .join('\n');
2846
+ if (normalized.every((entry) => typeof entry.content === 'string')) {
2847
+ return {
2848
+ content: normalized.map((entry) => entry.content).filter(Boolean).join('\n'),
2849
+ text: displayText,
2850
+ count: normalized.length,
2851
+ };
2852
+ }
2853
+ const parts = [];
2854
+ for (const entry of normalized) {
2855
+ if (typeof entry.content === 'string') {
2856
+ if (entry.content.trim()) parts.push({ type: 'text', text: entry.content });
2857
+ } else if (Array.isArray(entry.content)) {
2858
+ parts.push(...entry.content);
2859
+ } else {
2860
+ const text = promptContentText(entry.content);
2861
+ if (text.trim()) parts.push({ type: 'text', text });
2862
+ }
2863
+ parts.push({ type: 'text', text: '\n' });
2864
+ }
2865
+ while (parts.length && parts[parts.length - 1]?.type === 'text' && parts[parts.length - 1]?.text === '\n') parts.pop();
2866
+ return { content: parts, text: displayText || promptContentText(parts), count: normalized.length };
2867
+ }
2868
+
2869
+ function isInternalRuntimeNotificationText(content) {
2870
+ return contractIsInternalRuntimeNotificationText(promptContentText(content));
2871
+ }
2872
+
2873
+ export const _isInternalRuntimeNotificationText = isInternalRuntimeNotificationText;
2874
+
2875
+ function isInternalCancelledAssistantMessage(message) {
2876
+ if (!message || message.role !== 'assistant') return false;
2877
+ if (message.cancelled === true) return true;
2878
+ const text = promptContentText(message.content).trim();
2879
+ return /^\[cancelled\]\s+This turn was interrupted before completion\./i.test(text)
2880
+ || /Preserve the user request above as the active task context/i.test(text);
2881
+ }
2882
+
2883
+ function sanitizeSessionMessagesForModel(messages) {
2884
+ // Drop internal runtime-notification turns and cancelled-assistant stubs so
2885
+ // they never reach the model, but KEEP image content intact. Reference-agent
2886
+ // parity: the live transcript and every model request retain attached
2887
+ // images across turns; only the compaction-summary call strips them. The
2888
+ // disk-stored session JSON replaces image bytes with a text placeholder at
2889
+ // serialization time (see store.mjs), so this no longer touches images.
2890
+ if (!Array.isArray(messages) || messages.length === 0) return [];
2891
+ const out = [];
2892
+ let droppingInternalTurn = false;
2893
+ for (const message of messages) {
2894
+ if (isInternalCancelledAssistantMessage(message)) {
2895
+ droppingInternalTurn = false;
2896
+ continue;
2897
+ }
2898
+ if (message?.role === 'user' && isInternalRuntimeNotificationText(message.content)) {
2899
+ droppingInternalTurn = true;
2900
+ continue;
2901
+ }
2902
+ if (droppingInternalTurn) {
2903
+ if (message?.role === 'user') {
2904
+ droppingInternalTurn = false;
2905
+ } else {
2906
+ continue;
2907
+ }
2908
+ }
2909
+ out.push(message);
2910
+ }
2911
+ return out;
2912
+ }
2913
+
2056
2914
  function acquireSessionLock(sessionId) {
2057
2915
  let entry = _sessionLocks.get(sessionId);
2058
2916
  if (!entry) {
@@ -2072,19 +2930,114 @@ function acquireSessionLock(sessionId) {
2072
2930
  });
2073
2931
  }
2074
2932
 
2933
+ function sessionMessagesSnapshotChanged(before, after) {
2934
+ if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
2935
+ if (before.length !== after.length) return true;
2936
+ for (let i = 0; i < before.length; i += 1) {
2937
+ if (before[i] !== after[i]) return true;
2938
+ try {
2939
+ if (JSON.stringify(before[i]) !== JSON.stringify(after[i])) return true;
2940
+ } catch {
2941
+ return true;
2942
+ }
2943
+ }
2944
+ return false;
2945
+ }
2946
+
2947
+ function isCompactedOutgoingFinalAssistantMessage(message) {
2948
+ if (!message || message.role !== 'assistant') return false;
2949
+ if (message.emptyFinal === true) return true;
2950
+ return true;
2951
+ }
2952
+
2953
+ function sessionMessagesAdvancedBeyondCompactedOutgoing(currentSanitized, compactedSanitized) {
2954
+ if (!Array.isArray(currentSanitized) || !Array.isArray(compactedSanitized)) return false;
2955
+ if (currentSanitized.length !== compactedSanitized.length + 1) return false;
2956
+ const prefix = currentSanitized.slice(0, compactedSanitized.length);
2957
+ if (sessionMessagesSnapshotChanged(compactedSanitized, prefix)) return false;
2958
+ return isCompactedOutgoingFinalAssistantMessage(currentSanitized[currentSanitized.length - 1]);
2959
+ }
2960
+
2961
+ export const _sessionMessagesAdvancedBeyondCompactedOutgoing = sessionMessagesAdvancedBeyondCompactedOutgoing;
2962
+
2963
+ function applyCompactFailurePersistToSession(activeSession, {
2964
+ priorSanitized,
2965
+ sanitized,
2966
+ messagesAdvanced,
2967
+ error = null,
2968
+ }) {
2969
+ if (!messagesAdvanced && !sessionMessagesSnapshotChanged(priorSanitized, sanitized)) return false;
2970
+ if (!messagesAdvanced) {
2971
+ activeSession.messages = sanitized;
2972
+ activeSession.providerState = undefined;
2973
+ }
2974
+ activeSession.updatedAt = Date.now();
2975
+ activeSession.lastUsedAt = Date.now();
2976
+ if (activeSession.compaction && typeof activeSession.compaction === 'object'
2977
+ && (activeSession.compaction.lastStage === 'compacting'
2978
+ || activeSession.compaction.lastStage === 'overflow_failed')) {
2979
+ const prev = activeSession.compaction;
2980
+ const cause = error?.cause;
2981
+ const overflow = error?.code === 'AGENT_CONTEXT_OVERFLOW';
2982
+ activeSession.compaction = {
2983
+ ...prev,
2984
+ lastStage: prev.lastStage === 'overflow_failed'
2985
+ ? 'overflow_failed'
2986
+ : (overflow ? 'overflow_failed' : 'failed'),
2987
+ lastCheckedAt: Date.now(),
2988
+ lastError: prev.lastError || cause?.message || error?.message || null,
2989
+ lastSemanticError: prev.lastSemanticError || cause?.message || null,
2990
+ lastRecallFastTrackError: prev.lastRecallFastTrackError
2991
+ || (cause?.message && String(cause?.name || '').includes('Recall') ? cause.message : null),
2992
+ };
2993
+ }
2994
+ return true;
2995
+ }
2996
+
2997
+ export const _applyCompactFailurePersistToSession = applyCompactFailurePersistToSession;
2998
+
2999
+ async function persistCompactedOutgoingAfterAskFailure({
3000
+ sessionId,
3001
+ activeSession,
3002
+ askGeneration,
3003
+ turnOutgoing,
3004
+ error = null,
3005
+ }) {
3006
+ if (!activeSession || activeSession.closed === true) return;
3007
+ if (!Array.isArray(turnOutgoing) || turnOutgoing.length === 0) return;
3008
+ const currentRuntime = _runtimeState.get(sessionId);
3009
+ if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) return;
3010
+ const sanitized = sanitizeSessionMessagesForModel(turnOutgoing);
3011
+ const priorSanitized = sanitizeSessionMessagesForModel(
3012
+ Array.isArray(activeSession.messages) ? activeSession.messages : [],
3013
+ );
3014
+ const messagesAdvanced = sessionMessagesAdvancedBeyondCompactedOutgoing(priorSanitized, sanitized);
3015
+ const applied = applyCompactFailurePersistToSession(activeSession, {
3016
+ priorSanitized,
3017
+ sanitized,
3018
+ messagesAdvanced,
3019
+ error,
3020
+ });
3021
+ if (!applied) return;
3022
+ try {
3023
+ await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
3024
+ } catch { /* best-effort: preserve in-memory compaction even if disk is slow */ }
3025
+ if (currentRuntime) currentRuntime.session = activeSession;
3026
+ }
3027
+
2075
3028
  export async function askSession(sessionId, prompt, context, onToolCall, cwdOverride, explicitPrefetch, askOpts = {}) {
2076
3029
  const _askStartedAt = Date.now();
2077
3030
  const _promptSrc = 'prompt';
2078
3031
  const _prefetchFiles = (explicitPrefetch?.files?.length) || 0;
2079
3032
  const _prefetchCallers = (explicitPrefetch?.callers?.length) || 0;
2080
3033
  const _prefetchRefs = (explicitPrefetch?.references?.length) || 0;
2081
- if (process.env.MIXDOG_DEBUG_BRIDGE) {
2082
- process.stderr.write(`[bridge-trace] t0-ask-start sessionHash=${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 8)} role=? iteration=0 promptSrc=${_promptSrc} prefetchFiles=${_prefetchFiles} callers=${_prefetchCallers} references=${_prefetchRefs}\n`);
3034
+ if (process.env.MIXDOG_DEBUG_AGENT) {
3035
+ process.stderr.write(`[agent-trace] t0-ask-start sessionHash=${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 8)} role=? iteration=0 promptSrc=${_promptSrc} prefetchFiles=${_prefetchFiles} callers=${_prefetchCallers} references=${_prefetchRefs}\n`);
2083
3036
  }
2084
3037
  const unlock = await acquireSessionLock(sessionId);
2085
3038
  const _lockWaitedMs = Date.now() - _askStartedAt;
2086
- if (process.env.MIXDOG_DEBUG_BRIDGE) {
2087
- process.stderr.write(`[bridge-trace] lock-acquired waitedMs=${_lockWaitedMs}\n`);
3039
+ if (process.env.MIXDOG_DEBUG_AGENT) {
3040
+ process.stderr.write(`[agent-trace] lock-acquired waitedMs=${_lockWaitedMs}\n`);
2088
3041
  }
2089
3042
  // The mutex is held for the WHOLE askSession call, including any follow-up
2090
3043
  // turns drained from the pending-message queue below — the single outer
@@ -2093,18 +3046,19 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2093
3046
  // result, mirroring how a live chat returns the latest turn).
2094
3047
  let _result;
2095
3048
  // Local FIFO of follow-up prompts drained from the pending-message queue
2096
- // after each turn — keeps queued `bridge type=send` messages in order.
3049
+ // after each turn — keeps queued `agent type=send` messages in order.
2097
3050
  const _pendingTail = [];
2098
3051
  // Hoisted so the outer finally (which runs once after the whole turn loop)
2099
3052
  // can compare against the last turn's generation.
2100
3053
  let askGeneration = 0;
2101
3054
  try {
2102
3055
  // Turn loop (pendingMessages pattern): run the current prompt, then drain
2103
- // any `bridge type=send` messages that were queued while this turn was in
3056
+ // any `agent type=send` messages that were queued while this turn was in
2104
3057
  // flight and run them — in order — as the next user turn(s). Because the
2105
3058
  // queued send always lands AFTER the in-flight prompt here, ordering is
2106
3059
  // preserved and the spawn/connecting startup race disappears.
2107
3060
  for (;;) {
3061
+ let _pwstTurnDrained = null;
2108
3062
  // After the first turn, the next prompt comes from the drained queue.
2109
3063
  // (On the first iteration _pendingTail is empty and `prompt` is the
2110
3064
  // caller's original message.)
@@ -2114,6 +3068,23 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2114
3068
  // prefetch is re-applied (those belonged to the original ask).
2115
3069
  context = null;
2116
3070
  explicitPrefetch = null;
3071
+ } else if (!hasModelVisiblePromptContent(prompt)) {
3072
+ // Idle resume: TUI kicks an empty ask() after execution completions
3073
+ // mirror model-visible bodies into session pending. Drain that queue
3074
+ // here so we never synthesize an empty user turn for the model.
3075
+ const _preDrained = drainPendingMessages(sessionId);
3076
+ if (_preDrained.length > 0) {
3077
+ const _mergedPre = _mergePendingMessageEntries(_preDrained);
3078
+ if (_mergedPre?.content) {
3079
+ prompt = _mergedPre.content;
3080
+ context = null;
3081
+ explicitPrefetch = null;
3082
+ }
3083
+ }
3084
+ }
3085
+ if (!hasModelVisiblePromptContent(prompt)) {
3086
+ _unlinkParentAbortListener(_runtimeState.get(sessionId));
3087
+ return _result;
2117
3088
  }
2118
3089
  // ── Synchronous pre-await setup (must happen before any await so
2119
3090
  // closeSession() can't interleave between load and registration) ──
@@ -2124,6 +3095,11 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2124
3095
  if (preSession.closed === true) {
2125
3096
  throw new SessionClosedError(sessionId, 'session already closed');
2126
3097
  }
3098
+ // A prior crash/partial-save during compaction may have pinned
3099
+ // compaction.lastStage='compacting'. This ask is starting fresh, so
3100
+ // recover the stale transient stage before the loop's pre-send compact
3101
+ // path runs (it will overwrite lastStage with real telemetry).
3102
+ normalizeStaleCompactingStage(preSession);
2127
3103
  askGeneration = typeof preSession.generation === 'number' ? preSession.generation : 0;
2128
3104
  const runtime = _touchRuntime(sessionId);
2129
3105
  // Fresh controller per ask — the previous ask's controller may have aborted.
@@ -2137,6 +3113,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2137
3113
  // leaving stage='connecting' forever.
2138
3114
  let activeSession = preSession;
2139
3115
  let cancelledUserTurnContent = '';
3116
+ let _turnOutgoing = null;
2140
3117
  try {
2141
3118
  const session = activeSession;
2142
3119
  const provider = getProvider(session.provider);
@@ -2155,6 +3132,8 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2155
3132
  ...(session.compaction || {}),
2156
3133
  auto: session.compaction?.auto !== false,
2157
3134
  semantic: session.compaction?.semantic ?? 'auto',
3135
+ type: normalizeCompactType(session.compaction?.type ?? session.compaction?.compactType ?? session.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
3136
+ compactType: normalizeCompactType(session.compaction?.type ?? session.compaction?.compactType ?? session.compaction?.compact_type, DEFAULT_COMPACT_TYPE),
2158
3137
  boundaryTokens: contextMeta.compactBoundaryTokens,
2159
3138
  bufferTokens: positiveContextWindow(session.compaction?.bufferTokens ?? session.compaction?.buffer) || session.compaction?.bufferTokens || null,
2160
3139
  keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens) || session.compaction?.keepTokens || null,
@@ -2191,7 +3170,8 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2191
3170
  if (explicitPrefetchResult) {
2192
3171
  _contextBlock += `# Prefetch\n${_capCtx(explicitPrefetchResult)}\n\n`;
2193
3172
  }
2194
- const beforeCount = session.messages.length + 1;
3173
+ const historyMessages = sanitizeSessionMessagesForModel(session.messages);
3174
+ const beforeCount = historyMessages.length + 1;
2195
3175
  const promptTextForMetrics = promptContentText(prompt);
2196
3176
  // Soft warning only; real size management (compaction primary,
2197
3177
  // byte-budget trim as safety net) lives in agentLoop. Selecting a
@@ -2202,21 +3182,43 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2202
3182
  process.stderr.write(`[session] Warning: prompt is very large (est. ${Math.round(promptTokenEstimate)} tokens vs ${softBudget} soft budget)\n`);
2203
3183
  }
2204
3184
  const effectiveCwd = cwdOverride || session.cwd;
2205
- const _userTurnContent = prefixUserTurnContent(prompt, _contextBlock);
3185
+ const shouldInjectSessionStart = session.sessionStartMetaInjected !== true
3186
+ && !hasUserConversationMessage(historyMessages);
3187
+ const _sessionStartBlock = shouldInjectSessionStart
3188
+ ? buildSessionStartBlock(session, effectiveCwd)
3189
+ : '';
3190
+ const _currentTimeBlock = buildCurrentTimeBlock(prompt);
3191
+ const _turnReminderBlock = _currentTimeBlock
3192
+ ? `<system-reminder>\n# Current Time\n${_currentTimeBlock}\n</system-reminder>`
3193
+ : '';
3194
+ const _turnPrefixBlock = [_sessionStartBlock, _turnReminderBlock].filter(Boolean).join('\n\n');
3195
+ const _baseUserTurnContent = prefixUserTurnContent(prompt, _contextBlock);
3196
+ const _userTurnContent = prefixSessionStartContent(_baseUserTurnContent, _turnPrefixBlock);
3197
+ if (shouldInjectSessionStart && _sessionStartBlock) {
3198
+ session.sessionStartMetaInjected = true;
3199
+ }
2206
3200
  cancelledUserTurnContent = _userTurnContent;
2207
- const outgoing = [...session.messages, { role: 'user', content: _userTurnContent }];
3201
+ const outgoing = [...historyMessages, { role: 'user', content: _userTurnContent }];
3202
+ _turnOutgoing = outgoing;
3203
+ // Expose the in-flight working transcript so contextStatus() can
3204
+ // estimate the LIVE context footprint mid-turn. agentLoop mutates
3205
+ // `outgoing` in place (user turn + tool calls/results + compaction),
3206
+ // so the statusline context gauge climbs as the turn accumulates
3207
+ // tool output instead of freezing at the pre-turn snapshot. Cleared
3208
+ // on turn commit (below) and in the ask finally.
3209
+ session.liveTurnMessages = outgoing;
2208
3210
  // Per-turn injected-context trace row (complements kind:"usage").
2209
3211
  // Cheap byte-length accounting — no hashing, no payload bodies.
2210
- // Honors the same MIXDOG_BRIDGE_TRACE_DISABLE gate as usage rows;
2211
- // appendBridgeTrace is a no-op when that env is set.
3212
+ // Honors the same MIXDOG_AGENT_TRACE_DISABLE gate as usage rows;
3213
+ // appendAgentTrace is a no-op when that env is set.
2212
3214
  try {
2213
3215
  const _ctxBytes = Buffer.byteLength(context || '', 'utf8');
2214
3216
  const _prefetchBytes = Buffer.byteLength(explicitPrefetchResult || '', 'utf8');
2215
3217
  const _promptBytes = promptContentBytes(prompt);
2216
3218
  const _userTurnBytes = promptContentBytes(_userTurnContent);
2217
- const _messagesBytes = Buffer.byteLength(JSON.stringify(session.messages || []), 'utf8');
3219
+ const _messagesBytes = Buffer.byteLength(JSON.stringify(historyMessages || []), 'utf8');
2218
3220
  const _totalBytes = _userTurnBytes + _messagesBytes;
2219
- appendBridgeTrace({
3221
+ appendAgentTrace({
2220
3222
  kind: 'context',
2221
3223
  sessionId,
2222
3224
  model: session.model,
@@ -2228,26 +3230,36 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2228
3230
  promptBytes: _promptBytes,
2229
3231
  userTurnBytes: _userTurnBytes,
2230
3232
  messagesBytes: _messagesBytes,
2231
- messagesCount: Array.isArray(session.messages) ? session.messages.length : 0,
3233
+ messagesCount: historyMessages.length,
2232
3234
  },
2233
3235
  });
2234
3236
  } catch { /* trace must never break the ask path */ }
2235
- const result = await _api_call_with_interrupt(sessionId, (signal) =>
3237
+ const agentLoop = await _getAgentLoop();
3238
+ const priorToolApprovalHook = session.toolApprovalHook;
3239
+ if (typeof askOpts?.onToolApproval === 'function') {
3240
+ session.toolApprovalHook = askOpts.onToolApproval;
3241
+ }
3242
+ let result;
3243
+ try {
3244
+ result = await _api_call_with_interrupt(sessionId, (signal) =>
2236
3245
  agentLoop(provider, outgoing, session.model, session.tools, onToolCall, effectiveCwd, {
2237
3246
  effort: session.effort || null,
2238
3247
  fast: session.fast === true,
2239
3248
  sessionId,
2240
3249
  onTextDelta: typeof askOpts?.onTextDelta === 'function' ? askOpts.onTextDelta : undefined,
2241
3250
  onReasoningDelta: typeof askOpts?.onReasoningDelta === 'function' ? askOpts.onReasoningDelta : undefined,
3251
+ onAssistantText: typeof askOpts?.onAssistantText === 'function' ? askOpts.onAssistantText : undefined,
2242
3252
  onUsageDelta: (d) => {
2243
3253
  persistIterationMetrics(d).catch(() => {});
2244
3254
  try { askOpts?.onUsageDelta?.(d); } catch {}
2245
3255
  },
2246
3256
  onToolResult: typeof askOpts?.onToolResult === 'function' ? askOpts.onToolResult : undefined,
3257
+ onToolApproval: typeof askOpts?.onToolApproval === 'function' ? askOpts.onToolApproval : undefined,
3258
+ onCompactEvent: typeof askOpts?.onCompactEvent === 'function' ? askOpts.onCompactEvent : undefined,
2247
3259
  // Mid-turn steering drain. agentLoop calls this at every
2248
3260
  // tool-batch boundary (before the next provider.send) and
2249
3261
  // injects any returned strings as user turns — so input
2250
- // (user typing, `bridge type=send`) that arrives WHILE a
3262
+ // (user typing, `agent type=send`) that arrives WHILE a
2251
3263
  // long multi-tool turn is in flight is picked up on the
2252
3264
  // model's very next iteration instead of waiting for the
2253
3265
  // whole task to finish. The post-turn _pendingTail drain
@@ -2266,6 +3278,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2266
3278
  return out;
2267
3279
  },
2268
3280
  onSteerMessage: typeof askOpts?.onSteerMessage === 'function' ? askOpts.onSteerMessage : undefined,
3281
+ notifyFn: typeof askOpts?.notifyFn === 'function' ? askOpts.notifyFn : undefined,
2269
3282
  promptCacheKey: session.promptCacheKey || sessionId,
2270
3283
  // Provider-scoped cache key (mixdog-codex, mixdog-claude…).
2271
3284
  // Distinct from sessionId — providers that pool sockets
@@ -2277,7 +3290,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2277
3290
  signal,
2278
3291
  providerState: session.providerState ?? undefined,
2279
3292
  session,
2280
- // Smart Bridge cache settings — merged last so session overrides
3293
+ // Agent Runtime cache settings — merged last so session overrides
2281
3294
  // don't get overridden by defaults. When session has no profile,
2282
3295
  // providerCacheOpts is null and this spread is a no-op.
2283
3296
  ...(session.providerCacheOpts || {}),
@@ -2291,6 +3304,13 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2291
3304
  },
2292
3305
  }),
2293
3306
  );
3307
+ } finally {
3308
+ if (priorToolApprovalHook === undefined) {
3309
+ delete session.toolApprovalHook;
3310
+ } else {
3311
+ session.toolApprovalHook = priorToolApprovalHook;
3312
+ }
3313
+ }
2294
3314
  // Post-loop validation: if closeSession() landed while we were awaiting,
2295
3315
  // drop the save so the tombstone on disk isn't overwritten.
2296
3316
  const currentRuntime = _runtimeState.get(sessionId);
@@ -2301,10 +3321,16 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2301
3321
  // Update and save. outgoing is mutated in place by agentLoop
2302
3322
  // (compaction + safety trim), so its length reflects post-loop state.
2303
3323
  const messagesDropped = Math.max(0, beforeCount - outgoing.length);
2304
- session.messages = outgoing;
3324
+ session.messages = sanitizeSessionMessagesForModel(outgoing);
3325
+ // Turn committed into session.messages; drop the live-turn alias so
3326
+ // contextStatus() reverts to the authoritative committed transcript.
3327
+ session.liveTurnMessages = null;
2305
3328
  if (result.content || result.reasoningContent) {
2306
3329
  session.messages.push({
2307
3330
  role: 'assistant',
3331
+ // Keep content as-is in memory (model-visible). Image bytes,
3332
+ // if any, are swapped for a placeholder only at disk write
3333
+ // time inside the session store (store.mjs _sessionForDisk).
2308
3334
  content: result.content || '',
2309
3335
  ...(typeof result.reasoningContent === 'string' && result.reasoningContent
2310
3336
  ? { reasoningContent: result.reasoningContent }
@@ -2354,46 +3380,17 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2354
3380
  }
2355
3381
  session.updatedAt = Date.now();
2356
3382
  session.lastUsedAt = Date.now();
2357
- if (result.usage) {
2358
- session.totalInputTokens += result.usage.inputTokens;
2359
- session.totalOutputTokens += result.usage.outputTokens;
2360
- session.tokensCumulative = (session.tokensCumulative || 0)
2361
- + (result.usage.inputTokens || 0)
2362
- + (result.usage.outputTokens || 0);
2363
- // Cache totals — same `||0` undefined-safe accumulation pattern as
2364
- // persistIterationMetrics so live + terminal paths stay in lock-step
2365
- // and legacy sessions migrate lazily on first iteration.
2366
- session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (result.usage.cachedTokens || 0);
2367
- session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (result.usage.cacheWriteTokens || 0);
2368
- // Window snapshot = the current context size, which is the LAST
2369
- // single call — NOT result.usage (that is lastUsage, the per-turn
2370
- // SUM accumulated with += across iterations in agentLoop). Use
2371
- // lastTurnUsage (the final iteration's raw usage) so this reflects
2372
- // "what's in the window now" rather than the lifetime sum.
2373
- const _lastTurn = result.lastTurnUsage || result.usage || {};
2374
- session.lastInputTokens = _lastTurn.inputTokens || 0;
2375
- session.lastOutputTokens = _lastTurn.outputTokens || 0;
2376
- session.lastCachedReadTokens = _lastTurn.cachedTokens || 0;
2377
- session.lastCacheWriteTokens = _lastTurn.cacheWriteTokens || 0;
2378
- // Provider-normalized footprint, identical formula to
2379
- // persistIterationMetrics so both writers agree: Anthropic
2380
- // input_tokens excludes cache (add it back), openai/grok/gemini
2381
- // already include it.
2382
- const _inputExcludesCache = providerInputExcludesCache(session.provider);
2383
- session.lastContextTokens = _inputExcludesCache
2384
- ? (_lastTurn.inputTokens || 0) + (_lastTurn.cachedTokens || 0)
2385
- : (_lastTurn.inputTokens || 0);
2386
- session.lastContextTokensUpdatedAt = Date.now();
2387
- session.lastContextTokensStaleAfterCompact = false;
2388
- }
2389
- // Smart Bridge cache stats — record hit/miss after every successful
2390
- // ask so the registry reflects all bridge traffic, not just
2391
- // maintenance cycles. Guarded against any smart-bridge error so
3383
+ applyAskTerminalUsageTotals(session, result, {
3384
+ skipTotalsIfIncremental: runtime?.usageMetricsTurnIncremental === true,
3385
+ });
3386
+ // Agent Runtime cache stats — record hit/miss after every successful
3387
+ // ask so the registry reflects all agent traffic, not just
3388
+ // maintenance cycles. Guarded against any agent-runtime error so
2392
3389
  // metric recording never breaks the ask itself.
2393
3390
  let prefixHashForLog = null;
2394
- if (session.profileId && result.usage && _smartBridgeApi) {
3391
+ if (session.profileId && result.usage && _agentRuntimeApi) {
2395
3392
  try {
2396
- const profile = _smartBridgeApi.getProfile(session.profileId);
3393
+ const profile = _agentRuntimeApi.getProfile(session.profileId);
2397
3394
  if (profile) {
2398
3395
  // Collect every leading system-role message (BP1, BP2, ...)
2399
3396
  // until the first non-system message so the registry hash
@@ -2403,24 +3400,24 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2403
3400
  if (m?.role !== 'system') break;
2404
3401
  systemMsgs.push(typeof m.content === 'string' ? m.content : '');
2405
3402
  }
2406
- _smartBridgeApi.recordCall(profile, session.provider, {
3403
+ _agentRuntimeApi.recordCall(profile, session.provider, {
2407
3404
  systemPrompt: systemMsgs,
2408
3405
  tools: session.tools || [],
2409
3406
  usage: result.usage,
2410
3407
  });
2411
- const entry = _smartBridgeApi.registry?.data?.profiles?.[session.profileId]?.[session.provider];
3408
+ const entry = _agentRuntimeApi.registry?.data?.profiles?.[session.profileId]?.[session.provider];
2412
3409
  prefixHashForLog = entry?.prefixHash || null;
2413
3410
  }
2414
3411
  } catch {}
2415
3412
  }
2416
- // Append to bridge-trace.jsonl with the rich bridge usage fields.
3413
+ // Append to the agent trace store with rich usage fields.
2417
3414
  if (result.usage) {
2418
3415
  const inputTokens = result.usage.inputTokens || 0;
2419
3416
  const outputTokens = result.usage.outputTokens || 0;
2420
3417
  const cacheReadTokens = result.usage.cachedTokens || 0;
2421
3418
  const cacheWriteTokens = result.usage.cacheWriteTokens || 0;
2422
3419
  // Unified total-prompt field. Anthropic = input+cache_read+cache_write
2423
- // (additive); OpenAI/Codex/Gemini = input_tokens already includes the
3420
+ // (additive); OpenAI OAuth/API/Gemini = input_tokens already includes the
2424
3421
  // cached portion (inclusive), so the fallback must not double-count.
2425
3422
  const { isInclusiveProvider, computeCostUsd } = await import('../../../shared/llm/cost.mjs');
2426
3423
  const inclusive = isInclusiveProvider(session.provider);
@@ -2460,29 +3457,61 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2460
3457
  recordStandaloneStatusTelemetry(session, result, Date.now() - _askStartedAt);
2461
3458
  }
2462
3459
  // Persist opaque providerState for future stateful providers.
2463
- // No provider currently emits it (Codex OAuth is stateless per
3460
+ // No provider currently emits it (openai-oauth is stateless per
2464
3461
  // contract), so this branch is dormant — kept so a future
2465
3462
  // Responses-API provider with stable continuation can plug in
2466
3463
  // without reworking the session shape.
2467
3464
  if (result.providerState !== undefined) {
2468
3465
  session.providerState = result.providerState;
2469
3466
  }
2470
- const postTurnCompact = await autoCompactSessionAfterTurn(session, {
2471
- provider,
2472
- model: session.model,
2473
- sessionId,
2474
- signal: getSessionAbortSignal(sessionId),
2475
- });
2476
- if (postTurnCompact?.changed) {
3467
+ const terminalResultPreview = {
3468
+ ...result,
3469
+ trimmed: messagesDropped > 0,
3470
+ messagesDropped,
3471
+ };
3472
+ _pwstTurnDrained = drainPendingMessages(sessionId);
3473
+ if (_pwstTurnDrained.length === 0 && typeof askOpts?.onTerminalResult === 'function') {
3474
+ try {
3475
+ askOpts.onTerminalResult(terminalResultPreview, {
3476
+ sessionId,
3477
+ beforeSave: true,
3478
+ durationMs: Date.now() - _askStartedAt,
3479
+ });
3480
+ } catch { /* best-effort early completion relay */ }
3481
+ }
3482
+ // Auto-compact runs at the start of the next
3483
+ // query/provider send (agentLoop pre-send), not after the previous
3484
+ // answer. This lets queued follow-up prompts resume immediately;
3485
+ // if they need compaction, their own spinner shows compacting first.
3486
+ // Bounded, best-effort terminal save. The result is already produced
3487
+ // and (for agent surfaces) relayed via onTerminalResult above. If the
3488
+ // disk write stalls, blocking the terminal unwind here would strand the
3489
+ // owning background task in `running` and suppress its completion
3490
+ // notification. Cap the wait; let a slow write finish in the
3491
+ // background instead of holding askSession() open indefinitely.
3492
+ {
3493
+ const savePromise = saveSessionAsync(session, { expectedGeneration: askGeneration });
3494
+ let saveTimer = null;
3495
+ const saveTimeout = new Promise((resolveTimeout) => {
3496
+ saveTimer = setTimeout(() => resolveTimeout('__save_timeout__'), TERMINAL_SAVE_TIMEOUT_MS);
3497
+ saveTimer.unref?.();
3498
+ });
2477
3499
  try {
2478
- process.stderr.write(
2479
- `[session] auto compacted after turn sessionId=${sessionId} ` +
2480
- `${postTurnCompact.beforeTokens}->${postTurnCompact.afterTokens} ` +
2481
- `pressure=${postTurnCompact.pressureTokens} trigger=${postTurnCompact.triggerTokens}\n`,
2482
- );
2483
- } catch { /* best-effort */ }
3500
+ const outcome = await Promise.race([
3501
+ savePromise.then(() => '__save_ok__', (err) => { throw err; }),
3502
+ saveTimeout,
3503
+ ]);
3504
+ if (outcome === '__save_timeout__') {
3505
+ try { process.stderr.write(`[session] terminal save exceeded ${TERMINAL_SAVE_TIMEOUT_MS}ms; continuing best-effort (${sessionId})\n`); } catch {}
3506
+ // Don't drop the write — let it settle in the background.
3507
+ savePromise.catch((err) => {
3508
+ try { process.stderr.write(`[session] deferred terminal save failed: ${err?.message || err}\n`); } catch {}
3509
+ });
3510
+ }
3511
+ } finally {
3512
+ if (saveTimer) { try { clearTimeout(saveTimer); } catch {} }
3513
+ }
2484
3514
  }
2485
- await saveSessionAsync(session, { expectedGeneration: askGeneration });
2486
3515
  activeSession = session;
2487
3516
  runtime.session = session;
2488
3517
  // Tag empty-synthesis BEFORE markSessionDone so the watchdog
@@ -2493,47 +3522,63 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2493
3522
  markSessionEmptyFinal(sessionId);
2494
3523
  }
2495
3524
  markSessionDone(sessionId, { empty: isEmptyFinal });
2496
- _result = {
2497
- ...result,
2498
- trimmed: messagesDropped > 0,
2499
- messagesDropped,
2500
- postTurnCompact: postTurnCompact?.changed ? postTurnCompact : null,
2501
- };
3525
+ _result = terminalResultPreview;
2502
3526
  } catch (err) {
3527
+ // Cancellation/error paths bypass the commit point above; drop the
3528
+ // live-turn alias so contextStatus() stops estimating from the
3529
+ // stale in-flight array once the turn unwinds.
3530
+ if (activeSession) activeSession.liveTurnMessages = null;
2503
3531
  if (err instanceof SessionClosedError) {
2504
3532
  const currentRuntime = _runtimeState.get(sessionId);
2505
- if (!currentRuntime?.closed && activeSession && cancelledUserTurnContent) {
2506
- activeSession.messages = [
2507
- ...(Array.isArray(activeSession.messages) ? activeSession.messages : []),
2508
- { role: 'user', content: cancelledUserTurnContent },
2509
- {
2510
- role: 'assistant',
2511
- content: '[cancelled] This turn was interrupted before completion. Preserve the user request above as the active task context if the user asks to continue.',
2512
- cancelled: true,
2513
- ts: Date.now(),
2514
- },
2515
- ];
2516
- activeSession.updatedAt = Date.now();
2517
- activeSession.lastUsedAt = Date.now();
2518
- try {
2519
- await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
2520
- } catch { /* cancellation persistence is best-effort */ }
3533
+ if (!currentRuntime?.closed) {
3534
+ if (activeSession) {
3535
+ const originalMessages = Array.isArray(activeSession.messages) ? activeSession.messages : [];
3536
+ const cleanedMessages = sanitizeSessionMessagesForModel(originalMessages);
3537
+ const nextMessages = cleanedMessages.slice();
3538
+ // In-memory cancelled turn keeps its original content
3539
+ // (images intact for the next model send); the store
3540
+ // layer placeholders image bytes on disk serialization.
3541
+ const cancelledStoredContent = cancelledUserTurnContent;
3542
+ const shouldPreserveUserTurn = cancelledStoredContent && !isInternalRuntimeNotificationText(cancelledStoredContent);
3543
+ const lastMessage = nextMessages[nextMessages.length - 1];
3544
+ if (shouldPreserveUserTurn && !(lastMessage?.role === 'user' && promptContentText(lastMessage.content) === promptContentText(cancelledStoredContent))) {
3545
+ nextMessages.push({ role: 'user', content: cancelledStoredContent });
3546
+ }
3547
+ const messagesChanged = nextMessages.length !== originalMessages.length
3548
+ || nextMessages.some((message, index) => message !== originalMessages[index]);
3549
+ if (messagesChanged) {
3550
+ activeSession.messages = nextMessages;
3551
+ activeSession.updatedAt = Date.now();
3552
+ activeSession.lastUsedAt = Date.now();
3553
+ try {
3554
+ await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
3555
+ } catch { /* cancellation cleanup is best-effort */ }
3556
+ }
3557
+ }
2521
3558
  markSessionCancelled(sessionId);
2522
3559
  }
2523
3560
  // Cancellation is not an error; propagate silently so callers
2524
3561
  // can render it as "cancelled" rather than a red failure.
2525
3562
  throw err;
2526
3563
  }
3564
+ await persistCompactedOutgoingAfterAskFailure({
3565
+ sessionId,
3566
+ activeSession,
3567
+ askGeneration,
3568
+ turnOutgoing: _turnOutgoing,
3569
+ error: err,
3570
+ });
2527
3571
  markSessionError(sessionId, err && err.message ? err.message : String(err));
2528
3572
  throw err;
2529
3573
  }
2530
- // ── Turn complete. Drain the pending-message queue (Claude Code
2531
- // pendingMessages): any `bridge type=send` that arrived while this
3574
+ // ── Turn complete. Drain the pending-message queue: any `agent type=send` that arrived while this
2532
3575
  // turn was in flight runs next, in order, as a follow-up user turn.
2533
3576
  // The mutex is still held, so a send racing this drain either landed
2534
3577
  // before (picked up here) or enqueues for the next loop. When the
2535
3578
  // queue is empty we return the latest turn's result. ──
2536
- const _drained = drainPendingMessages(sessionId);
3579
+ const _drained = (_pwstTurnDrained && _pwstTurnDrained.length > 0)
3580
+ ? _pwstTurnDrained
3581
+ : drainPendingMessages(sessionId);
2537
3582
  if (_drained.length > 0) {
2538
3583
  // Same merge rule as the mid-turn steering drain (loop.mjs) and
2539
3584
  // the TUI engine.mjs drain(): a single drain batch is joined with
@@ -2541,11 +3586,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2541
3586
  // Keeps every steering/follow-up path on identical
2542
3587
  // merge-then-deliver semantics. Anything that arrives AFTER this
2543
3588
  // drain enqueues for the next loop pass and is merged there.
2544
- const _mergedTail = _drained
2545
- .filter((m) => typeof m === 'string' && m.length > 0)
2546
- .join('\n');
2547
- if (_mergedTail.length > 0) {
2548
- _pendingTail.push(_mergedTail);
3589
+ const _mergedTail = _mergePendingMessageEntries(_drained);
3590
+ if (_mergedTail?.content) {
3591
+ _pendingTail.push(_mergedTail.content);
2549
3592
  const refreshed = loadSession(sessionId);
2550
3593
  if (refreshed && refreshed.closed !== true) {
2551
3594
  activeSession = refreshed;
@@ -2554,14 +3597,16 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2554
3597
  continue;
2555
3598
  }
2556
3599
  }
3600
+ _unlinkParentAbortListener(_runtimeState.get(sessionId));
2557
3601
  return _result;
2558
3602
  }
2559
3603
  } finally {
2560
3604
  // Clear the controller only if it's still ours (closeSession may have
2561
- // swapped it). Leave the rest of the runtime entry intact so bridge type=list
3605
+ // swapped it). Leave the rest of the runtime entry intact so agent type=list
2562
3606
  // can still surface the final stage (done/error/cancelling).
2563
3607
  const entry = _runtimeState.get(sessionId);
2564
3608
  if (entry && entry.generation === askGeneration) {
3609
+ _unlinkParentAbortListener(entry);
2565
3610
  entry.controller = null;
2566
3611
  // Detach the live session reference; ask is over.
2567
3612
  entry.session = null;
@@ -2569,7 +3614,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2569
3614
  unlock();
2570
3615
  }
2571
3616
  }
2572
- // Session lookup by scopeKey — used by CLI bridge to resume a pinned
3617
+ // Session lookup by scopeKey — used by CLI agent to resume a pinned
2573
3618
  // scope session when the caller passes --scope (agent/<name>).
2574
3619
  export function findSessionByScopeKey(scopeKey) {
2575
3620
  if (!scopeKey) return null;
@@ -2591,20 +3636,38 @@ export async function resumeSession(sessionId, preset) {
2591
3636
  // than silently dropping the tool-refresh side effects.
2592
3637
  if (session.closed === true) return null;
2593
3638
  if (!session.owner) session.owner = 'user';
3639
+ // A crash / partial save during compaction can leave compaction.lastStage
3640
+ // pinned at 'compacting'. The TUI (App.jsx openContextPicker) reads that as
3641
+ // an in-progress compaction and would show "Compacting conversation"
3642
+ // forever on resume. No compaction is actually running for a freshly loaded
3643
+ // session, so normalize the stale transient stage back to an interrupted
3644
+ // marker. Successful auto/manual compact persistence (lastStage post_turn /
3645
+ // manual / auto_clear) is untouched.
3646
+ normalizeStaleCompactingStage(session);
2594
3647
  // Refresh tools (MCP connections may have changed).
2595
3648
  // Re-resolve from profile.tools when the session stored a profileId —
2596
3649
  // otherwise fall back to preset.tools. Same resolution order as
2597
3650
  // createSession so resume and spawn produce identical BP_1 shapes.
2598
3651
  const oldTools = session.tools || [];
2599
- const skills = collectSkillsCached(session.cwd);
2600
- let toolSpec = preset || session.preset || 'full';
2601
- if (session.profileId && _smartBridgeApi?.getProfile) {
3652
+ const ownerIsAgent = isAgentOwner(session);
3653
+ const skills = ownerIsAgent ? [] : collectSkillsCached(session.cwd);
3654
+ let toolSpec = ownerIsAgent ? 'full' : (preset || session.preset || 'full');
3655
+ if (session.profileId && _agentRuntimeApi?.getProfile) {
2602
3656
  try {
2603
- const profile = _smartBridgeApi.getProfile(session.profileId);
2604
- if (Array.isArray(profile?.tools)) toolSpec = profile.tools;
3657
+ const profile = _agentRuntimeApi.getProfile(session.profileId);
3658
+ if (!ownerIsAgent && Array.isArray(profile?.tools)) toolSpec = profile.tools;
2605
3659
  } catch { /* ignore lookup failures, keep preset fallback */ }
2606
3660
  }
2607
- session.tools = resolveSessionTools(toolSpec, skills, { ownerIsBridge: session.owner === 'bridge' });
3661
+ let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsAgentSession: ownerIsAgent });
3662
+ if (ownerIsAgent) {
3663
+ toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, session.toolPermission, session.role || null);
3664
+ }
3665
+ session.tools = finalizeSessionToolList(toolsForRouting, {
3666
+ schemaAllowedTools: Array.isArray(session.schemaAllowedTools) ? session.schemaAllowedTools : null,
3667
+ disallowedTools: null,
3668
+ ownerIsAgent,
3669
+ resolvedRole: session.role || null,
3670
+ });
2608
3671
  const newTools = session.tools;
2609
3672
  const missing = oldTools.filter(t => !newTools.find(n => n.name === t.name));
2610
3673
  if (missing.length) {
@@ -2622,44 +3685,89 @@ export function listSessions(opts = {}) {
2622
3685
  const sessions = listStoredSessionSummaries();
2623
3686
  const hiddenIds = new Set([..._runtimeState.entries()].filter(([, e]) => e.listHidden).map(([id]) => id));
2624
3687
  // Tombstoned sessions (closed===true) are excluded unless the caller opts in
2625
- // (e.g. bridge list includeClosed:true).
3688
+ // (e.g. agent list includeClosed:true).
2626
3689
  return sessions.filter(s => !hiddenIds.has(s.id) && (includeClosed || s.closed !== true));
2627
3690
  }
2628
3691
  // --- Clear messages (keep system prompt + provider/model/cwd) ---
2629
- export async function clearSessionMessages(sessionId) {
3692
+ export async function clearSessionMessages(sessionId, options = {}) {
2630
3693
  const session = loadSession(sessionId);
2631
3694
  if (!session)
2632
3695
  return false;
2633
3696
  // Don't resurrect a closed session just to clear its messages.
2634
3697
  if (session.closed === true) return false;
3698
+ const clearOptions = options && typeof options === 'object' ? options : {};
3699
+ const requestedCompactType = clearOptions.compactType ?? clearOptions.compact_type ?? clearOptions.type;
3700
+ const compactBeforeClear = requestedCompactType != null && requestedCompactType !== false && String(requestedCompactType).trim() !== '';
2635
3701
  const keep = [];
2636
- const messages = Array.isArray(session.messages) ? session.messages : [];
3702
+ let messages = Array.isArray(session.messages) ? session.messages : [];
2637
3703
  const beforeMessageTokens = estimateMessagesTokens(messages);
3704
+ let clearCompactType = null;
3705
+ let clearCompactError = null;
3706
+ if (compactBeforeClear && messages.length >= 3) {
3707
+ clearCompactType = normalizeCompactType(requestedCompactType, DEFAULT_COMPACT_TYPE);
3708
+ session.compaction = {
3709
+ ...(session.compaction || {}),
3710
+ type: clearCompactType,
3711
+ compactType: clearCompactType,
3712
+ };
3713
+ try {
3714
+ const compactResult = await runSessionCompaction(session, { mode: 'manual', force: true, sessionId });
3715
+ if (compactResult?.error) {
3716
+ clearCompactError = new Error(compactResult.error);
3717
+ }
3718
+ } catch (err) {
3719
+ clearCompactError = err;
3720
+ try { process.stderr.write(`[session] auto-clear pre-compact failed (sess=${sessionId}): ${err?.message || err}\n`); } catch { /* best-effort */ }
3721
+ }
3722
+ messages = Array.isArray(session.messages) ? session.messages : [];
3723
+ }
3724
+ if (compactBeforeClear && clearOptions.requireCompactSuccess === true) {
3725
+ const hasRetainedSummary = messages.some((m) => (
3726
+ m?.role === 'user'
3727
+ && typeof m.content === 'string'
3728
+ && m.content.startsWith(SUMMARY_PREFIX)
3729
+ ));
3730
+ if (!hasRetainedSummary && !clearCompactError) {
3731
+ clearCompactError = new Error('compact produced no retained summary');
3732
+ }
3733
+ }
3734
+ if (clearCompactError && clearOptions.requireCompactSuccess === true) {
3735
+ const now = Date.now();
3736
+ session.compaction = {
3737
+ ...(session.compaction || {}),
3738
+ lastStage: 'auto_clear_failed',
3739
+ lastCheckedAt: now,
3740
+ lastChanged: false,
3741
+ lastClearAt: session.compaction?.lastClearAt || null,
3742
+ lastClearCompactType: clearCompactType || session.compaction?.compactType || null,
3743
+ lastClearCompactError: clearCompactError?.message || String(clearCompactError),
3744
+ };
3745
+ session.updatedAt = now;
3746
+ await saveSessionAsync(session, { expectedGeneration: session.generation });
3747
+ throw new Error(`auto-clear compact failed; conversation kept: ${session.compaction.lastClearCompactError}`);
3748
+ }
3749
+ const preserveCompactSummary = compactBeforeClear && clearOptions.keepCompactSummary !== false;
2638
3750
  for (let i = 0; i < messages.length; i += 1) {
2639
3751
  const m = messages[i];
2640
3752
  if (!m) continue;
2641
3753
  if (m.role === 'system') {
3754
+ // BP1/BP2/BP3 all ride `role:'system'` blocks now (BP3 sessionMarker
3755
+ // moved off the `<system-reminder>` user wrapper), so the stable
3756
+ // memory/meta layer is preserved here unconditionally — no sentinel
3757
+ // scan / dummy-assistant pairing needed anymore.
2642
3758
  keep.push(m);
2643
3759
  continue;
2644
3760
  }
2645
- const stableContext =
2646
- m.role === 'user'
3761
+ if (preserveCompactSummary
3762
+ && m.role === 'user'
2647
3763
  && typeof m.content === 'string'
2648
- && m.content.startsWith('<system-reminder>')
2649
- && m.content.includes('<!-- bp3-sentinel -->');
2650
- if (stableContext) {
3764
+ && m.content.startsWith(SUMMARY_PREFIX)) {
2651
3765
  keep.push(m);
2652
- const next = messages[i + 1];
2653
- if (next?.role === 'assistant' && String(next.content || '').trim() === '.') {
2654
- keep.push(next);
2655
- i += 1;
2656
- }
2657
3766
  }
2658
3767
  }
2659
3768
  const afterMessageTokens = estimateMessagesTokens(keep);
2660
- const reserveTokens = estimateRequestReserveTokens(session.tools || []);
2661
- const beforeTokens = Math.max(beforeMessageTokens + reserveTokens, positiveContextWindow(session.lastContextTokens) || 0);
2662
- const afterTokens = afterMessageTokens + reserveTokens;
3769
+ const beforeTokens = estimateTranscriptContextUsage(messages, session.tools || []);
3770
+ const afterTokens = estimateTranscriptContextUsage(keep, session.tools || []);
2663
3771
  const now = Date.now();
2664
3772
  session.messages = keep;
2665
3773
  session.totalInputTokens = 0;
@@ -2689,6 +3797,8 @@ export async function clearSessionMessages(sessionId) {
2689
3797
  lastClearAfterTokens: afterTokens,
2690
3798
  lastClearBeforeMessageTokens: beforeMessageTokens,
2691
3799
  lastClearAfterMessageTokens: afterMessageTokens,
3800
+ lastClearCompactType: clearCompactType || session.compaction?.compactType || null,
3801
+ lastClearCompactError: clearCompactError?.message || null,
2692
3802
  };
2693
3803
  session.updatedAt = now;
2694
3804
  await saveSessionAsync(session, { expectedGeneration: session.generation });
@@ -2698,6 +3808,9 @@ export async function compactSessionMessages(sessionId) {
2698
3808
  const session = loadSession(sessionId);
2699
3809
  if (!session) return null;
2700
3810
  if (session.closed === true) return null;
3811
+ if (isSessionCompactionBlocked(sessionId)) {
3812
+ return { changed: false, reason: 'compact skipped: turn in progress' };
3813
+ }
2701
3814
  const result = await runSessionCompaction(session, {
2702
3815
  mode: 'manual',
2703
3816
  force: true,
@@ -2725,7 +3838,7 @@ export async function updateSessionStatus(id, status) {
2725
3838
  const session = loadSession(id);
2726
3839
  if (!session) return false;
2727
3840
  // Respect tombstones — don't resurrect a closed session just to update a
2728
- // status label (bridge handler emits running→idle/error around askSession).
3841
+ // status label (agent handler emits running→idle/error around askSession).
2729
3842
  if (session.closed === true) return false;
2730
3843
  session.status = status;
2731
3844
  session.updatedAt = Date.now();
@@ -2748,6 +3861,7 @@ export async function updateSessionStatus(id, status) {
2748
3861
  */
2749
3862
  export function closeSession(id, reason = 'manual') {
2750
3863
  if (!id) return false;
3864
+ _stopToolActivityHeartbeat(id);
2751
3865
  // Prefer in-memory runtime session — allBashSessionIds may not be persisted
2752
3866
  // yet for shells opened in the current turn (BL-bash-disk-sync).
2753
3867
  const inMemory = _runtimeState.get(id)?.session;
@@ -2784,10 +3898,10 @@ export function closeSession(id, reason = 'manual') {
2784
3898
  const durationMs = (typeof askStartedAt === 'number') ? (Date.now() - askStartedAt) : null;
2785
3899
  const parts = [`session=${id}`, `reason=${reason}`];
2786
3900
  if (durationMs != null) parts.push(`duration=${durationMs}ms`);
2787
- if (!process.env.MIXDOG_QUIET_SESSION_LOG) process.stderr.write(`[bridge-close] ${parts.join(' ')}\n`);
3901
+ if (process.env.MIXDOG_DEBUG_SESSION_LOG) process.stderr.write(`[agent-close] ${parts.join(' ')}\n`);
2788
3902
  } catch { /* best-effort */ }
2789
3903
  for (const bsid of allBashIds) {
2790
- try { closeBashSession(bsid, `bridge-close:${id}`); } catch { /* ignore */ }
3904
+ try { _closeBashSessionLazy(bsid, `agent-close:${id}`); } catch { /* ignore */ }
2791
3905
  }
2792
3906
  // Drop session-scoped read dedup cache so the Map doesn't accumulate
2793
3907
  // entries across mcp-server lifetime.
@@ -2807,6 +3921,7 @@ export function closeSession(id, reason = 'manual') {
2807
3921
  }
2808
3922
  export function abortSessionTurn(id, reason = 'turn-abort') {
2809
3923
  if (!id) return false;
3924
+ _stopToolActivityHeartbeat(id);
2810
3925
  const entry = _runtimeState.get(id);
2811
3926
  if (!entry || entry.closed) return false;
2812
3927
  entry.stage = 'cancelling';
@@ -2863,12 +3978,12 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
2863
3978
  } else {
2864
3979
  _clearSessionRuntime(d.id);
2865
3980
  if (d.bashSessionId) {
2866
- try { closeBashSession(d.bashSessionId, `idle-sweep:${d.id}`); } catch { /* ignore */ }
3981
+ try { _closeBashSessionLazy(d.bashSessionId, `idle-sweep:${d.id}`); } catch { /* ignore */ }
2867
3982
  }
2868
3983
  }
2869
- process.stderr.write(`[bridge-session] idle cleanup: closed ${d.id} (idle ${d.idleMinutes}m, owner=${d.owner})\n`);
3984
+ process.stderr.write(`[agent-session] idle cleanup: closed ${d.id} (idle ${d.idleMinutes}m, owner=${d.owner})\n`);
2870
3985
  }
2871
- process.stderr.write(`[bridge-session] idle sweep: cleaned ${cleaned} session(s), ${remaining} remaining\n`);
3986
+ process.stderr.write(`[agent-session] idle sweep: cleaned ${cleaned} session(s), ${remaining} remaining\n`);
2872
3987
  }
2873
3988
  if (tombstonesCleaned > 0) {
2874
3989
  for (const d of tombstoneDetails) {
@@ -2885,7 +4000,7 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
2885
4000
  process.stderr.write(`[session-sweep] cleanup took ${elapsed}ms (idle=${cleaned}, tombstones=${tombstonesCleaned}, remaining=${remaining})\n`);
2886
4001
  }
2887
4002
  } catch (e) {
2888
- process.stderr.write(`[bridge-session] idle sweep error: ${e && e.message || e}\n`);
4003
+ process.stderr.write(`[agent-session] idle sweep error: ${e && e.message || e}\n`);
2889
4004
  }
2890
4005
  }
2891
4006