mixdog 0.8.0 → 0.9.0

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 (285) hide show
  1. package/README.md +47 -23
  2. package/package.json +33 -27
  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 +344 -0
  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 +1223 -95
  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 -7
  62. package/src/lib/rules-builder.cjs +240 -96
  63. package/src/lib/text-utils.cjs +1 -1
  64. package/src/mixdog-session-runtime.mjs +2325 -450
  65. package/src/output-styles/default.md +12 -28
  66. package/src/output-styles/extreme-simple.md +9 -6
  67. package/src/output-styles/simple.md +22 -9
  68. package/src/repl.mjs +118 -59
  69. package/src/rules/agent/00-common.md +15 -0
  70. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  71. package/src/rules/agent/30-explorer.md +22 -0
  72. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  73. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  75. package/src/rules/lead/01-general.md +9 -5
  76. package/src/rules/lead/04-workflow.md +51 -12
  77. package/src/rules/lead/lead-tool.md +6 -0
  78. package/src/rules/shared/01-tool.md +12 -1
  79. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  80. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  81. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  82. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  83. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  85. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  86. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  87. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  88. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  89. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  90. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  91. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  92. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  93. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +377 -243
  95. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +146 -93
  96. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  97. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  99. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  100. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  101. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  102. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  103. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  104. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  106. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +221 -70
  107. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +477 -147
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -496
  109. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  110. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  111. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +31 -11
  112. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  113. package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +4 -4
  114. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
  115. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  116. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  117. package/src/runtime/agent/orchestrator/session/loop.mjs +851 -674
  118. package/src/runtime/agent/orchestrator/session/manager.mjs +1593 -466
  119. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  120. package/src/runtime/agent/orchestrator/session/store.mjs +291 -46
  121. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  122. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  123. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  124. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
  125. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
  126. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
  127. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
  128. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  129. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  130. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  131. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  132. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  133. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  139. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  140. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  141. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  142. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  143. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  144. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +1 -1
  145. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  146. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  147. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  148. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  149. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  150. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  151. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  152. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  153. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  154. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  155. package/src/runtime/channels/index.mjs +152 -24
  156. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  157. package/src/runtime/channels/lib/session-discovery.mjs +3 -2
  158. package/src/runtime/channels/lib/tool-format.mjs +0 -2
  159. package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
  160. package/src/runtime/channels/lib/webhook.mjs +1 -1
  161. package/src/runtime/channels/tool-defs.mjs +29 -29
  162. package/src/runtime/memory/index.mjs +635 -107
  163. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  164. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  165. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  166. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  167. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  168. package/src/runtime/memory/lib/memory-cycle1.mjs +38 -17
  169. package/src/runtime/memory/lib/memory-cycle2.mjs +6 -7
  170. package/src/runtime/memory/lib/memory-cycle3.mjs +4 -4
  171. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
  172. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  173. package/src/runtime/memory/lib/memory.mjs +88 -9
  174. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  175. package/src/runtime/memory/lib/pg/adapter.mjs +15 -1
  176. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  177. package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
  178. package/src/runtime/memory/lib/session-ingest.mjs +194 -0
  179. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  180. package/src/runtime/memory/tool-defs.mjs +46 -37
  181. package/src/runtime/search/index.mjs +102 -466
  182. package/src/runtime/search/lib/web-tools.mjs +45 -25
  183. package/src/runtime/search/tool-defs.mjs +16 -23
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/atomic-file.mjs +4 -3
  186. package/src/runtime/shared/background-tasks.mjs +122 -11
  187. package/src/runtime/shared/child-spawn-gate.mjs +145 -0
  188. package/src/runtime/shared/config.mjs +7 -4
  189. package/src/runtime/shared/err-text.mjs +131 -4
  190. package/src/runtime/shared/llm/cost.mjs +2 -2
  191. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  192. package/src/runtime/shared/llm/index.mjs +34 -11
  193. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  194. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  195. package/src/runtime/shared/singleton-owner.mjs +104 -0
  196. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  197. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  198. package/src/runtime/shared/tool-surface.mjs +624 -98
  199. package/src/runtime/shared/user-data-guard.mjs +0 -2
  200. package/src/standalone/agent-task-status.mjs +203 -0
  201. package/src/standalone/agent-task-status.test.mjs +76 -0
  202. package/src/standalone/agent-tool.mjs +1913 -0
  203. package/src/standalone/channel-worker.mjs +370 -14
  204. package/src/standalone/explore-tool.mjs +165 -70
  205. package/src/standalone/folder-dialog.mjs +314 -0
  206. package/src/standalone/hook-bus.mjs +898 -22
  207. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  208. package/src/standalone/projects.mjs +226 -0
  209. package/src/standalone/provider-admin.mjs +41 -24
  210. package/src/standalone/seeds.mjs +2 -69
  211. package/src/standalone/usage-dashboard.mjs +96 -8
  212. package/src/tui/App.jsx +4798 -2153
  213. package/src/tui/components/AnsiText.jsx +39 -28
  214. package/src/tui/components/ContextPanel.jsx +87 -29
  215. package/src/tui/components/Markdown.jsx +43 -77
  216. package/src/tui/components/MarkdownTable.jsx +9 -184
  217. package/src/tui/components/Message.jsx +28 -11
  218. package/src/tui/components/Picker.jsx +95 -56
  219. package/src/tui/components/PromptInput.jsx +367 -239
  220. package/src/tui/components/QueuedCommands.jsx +1 -1
  221. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  222. package/src/tui/components/Spinner.jsx +67 -38
  223. package/src/tui/components/StatusLine.jsx +606 -38
  224. package/src/tui/components/TextEntryPanel.jsx +128 -9
  225. package/src/tui/components/ToolExecution.jsx +617 -368
  226. package/src/tui/components/TurnDone.jsx +3 -3
  227. package/src/tui/components/UsagePanel.jsx +3 -5
  228. package/src/tui/components/tool-output-format.mjs +365 -0
  229. package/src/tui/components/tool-output-format.test.mjs +220 -0
  230. package/src/tui/dist/index.mjs +8915 -2418
  231. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  232. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  233. package/src/tui/engine.mjs +1455 -279
  234. package/src/tui/figures.mjs +21 -40
  235. package/src/tui/index.jsx +75 -31
  236. package/src/tui/input-editing.mjs +25 -0
  237. package/src/tui/markdown/format-token.mjs +511 -68
  238. package/src/tui/markdown/format-token.test.mjs +216 -0
  239. package/src/tui/markdown/render-ansi.mjs +94 -0
  240. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  241. package/src/tui/markdown/stream-fence.mjs +34 -0
  242. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  243. package/src/tui/markdown/table-layout.mjs +250 -0
  244. package/src/tui/paste-attachments.mjs +0 -7
  245. package/src/tui/spinner-verbs.mjs +1 -2
  246. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  247. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  248. package/src/tui/theme.mjs +746 -24
  249. package/src/tui/time-format.mjs +1 -1
  250. package/src/tui/transcript-tool-failures.mjs +67 -0
  251. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  252. package/src/ui/ansi.mjs +1 -2
  253. package/src/ui/markdown.mjs +85 -26
  254. package/src/ui/markdown.test.mjs +70 -0
  255. package/src/ui/model-display.mjs +121 -0
  256. package/src/ui/session-stats.mjs +44 -0
  257. package/src/ui/statusline-context-label.test.mjs +15 -0
  258. package/src/ui/statusline.mjs +386 -178
  259. package/src/ui/tool-card.mjs +3 -16
  260. package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
  261. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  262. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  263. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  264. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  265. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  266. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  267. package/src/workflows/default/WORKFLOW.md +12 -5
  268. package/src/workflows/default/workflow.json +0 -1
  269. package/src/workflows/solo/WORKFLOW.md +15 -0
  270. package/src/workflows/solo/workflow.json +7 -0
  271. package/vendor/ink/build/output.js +6 -1
  272. package/src/agents/scheduler-task.md +0 -3
  273. package/src/agents/web-researcher/AGENT.md +0 -3
  274. package/src/agents/web-researcher/agent.json +0 -6
  275. package/src/agents/webhook-handler.md +0 -3
  276. package/src/rules/bridge/00-common.md +0 -5
  277. package/src/rules/bridge/30-explorer.md +0 -4
  278. package/src/rules/lead/00-tool-lead.md +0 -5
  279. package/src/rules/shared/00-language.md +0 -3
  280. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  281. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  282. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  283. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  284. package/src/standalone/bridge-tool.mjs +0 -1414
  285. 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';
26
- import { saveSession, saveSessionAsync, loadSession, listStoredSessions, sweepStaleSessions, markSessionClosed, publishHeartbeat, deleteHeartbeat, setLiveSession } from './store.mjs';
31
+ import { collectSkillsCached, buildSkillManifest, buildSkillToolDefs, composeSystemPrompt } from '../context/collect.mjs';
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',
@@ -251,9 +321,9 @@ const SESSION_ROUTE_TOOL_ORDER = [
251
321
  'task',
252
322
  ];
253
323
  const SESSION_ROUTE_TOOL_RANK = new Map(SESSION_ROUTE_TOOL_ORDER.map((name, index) => [name, index]));
254
- const MODEL_HIDDEN_FILE_MUTATION_TOOLS = new Set(['edit', 'write']);
255
324
  const FILESYSTEM_TOOL_NAMES = new Set([
256
325
  'code_graph',
326
+ 'find',
257
327
  'glob',
258
328
  'list',
259
329
  'grep',
@@ -262,12 +332,115 @@ const FILESYSTEM_TOOL_NAMES = new Set([
262
332
  ]);
263
333
  const READONLY_TOOL_NAMES = new Set([
264
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',
265
345
  'glob',
266
346
  'list',
267
347
  'grep',
268
348
  'read',
349
+ 'explore',
350
+ 'search',
351
+ 'web_fetch',
269
352
  ]);
270
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
+
271
444
  function orderSessionTools(tools) {
272
445
  return tools.map((tool, index) => ({ tool, index }))
273
446
  .sort((a, b) => {
@@ -280,18 +453,18 @@ function orderSessionTools(tools) {
280
453
  }
281
454
 
282
455
  const ALL_BUILTIN_SESSION_TOOLS = orderSessionTools(_dedupByName([
283
- ...BUILTIN_TOOLS.filter(t => !MODEL_HIDDEN_FILE_MUTATION_TOOLS.has(t?.name)),
456
+ ...BUILTIN_TOOLS,
284
457
  ...PATCH_TOOL_DEFS,
285
458
  ...CODE_GRAPH_TOOL_DEFS,
286
459
  ]));
287
460
 
288
- function resolveSessionTools(toolSpec, skills, { ownerIsBridge = false } = {}) {
461
+ function resolveSessionTools(toolSpec, skills, { ownerIsAgentSession = false } = {}) {
289
462
  const mcp = _getMcpTools();
290
- // Bridge sessions freeze the 3 skill meta-tools into the schema
463
+ // Agent sessions freeze the skill meta-tool into the schema
291
464
  // unconditionally — concrete skill resolution is cwd-scoped at tool-call
292
465
  // time (loop.mjs), so the schema bytes stay bit-identical across roles /
293
466
  // cwds and the provider cache shard does not fragment.
294
- const skillTools = buildSkillToolDefs(skills, { ownerIsBridge });
467
+ const skillTools = buildSkillToolDefs(skills, { ownerIsAgentSession });
295
468
  return _computeBaseTools(toolSpec, mcp, skillTools);
296
469
  }
297
470
 
@@ -315,8 +488,8 @@ function _dedupByName(tools) {
315
488
  return [...seen.values()];
316
489
  }
317
490
 
318
- // Bridge visibility is declared per-tool via annotations.bridgeHidden.
319
- // 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
320
493
  // build time (see deny filtering below). No code-level name list needed.
321
494
 
322
495
  function _computeBaseTools(toolSpec, mcp, skillTools) {
@@ -357,7 +530,7 @@ function _computeBaseTools(toolSpec, mcp, skillTools) {
357
530
  // Name-pattern match: picks up `search` and any future tool
358
531
  // whose name contains `search`. `recall` and `explore` deliberately do NOT match
359
532
  // — they need `tools:mcp` (full mcp surface) or their own
360
- // toolset id if a role wants targeted retrieval. Public bridge
533
+ // toolset id if a role wants targeted retrieval. Public agent
361
534
  // roles never reach the wrapper bodies regardless: see the
362
535
  // isBlockedPublicWrapperCall guard in session/loop.mjs.
363
536
  addMany(mcp.filter(t => /search/i.test(t?.name || '')));
@@ -404,7 +577,7 @@ let nextId = Date.now();
404
577
  // this map trimmed to live models; older generations slow down reads
405
578
  // without buying anything.
406
579
  const CONTEXT_WINDOWS = {
407
- // OpenAI GPT-5.x family
580
+ // OpenAI GPT-5.x family (openai / openai-oauth)
408
581
  'gpt-5.5': 272000,
409
582
  'gpt-5.4': 272000,
410
583
  'gpt-5.4-mini': 272000,
@@ -419,12 +592,33 @@ const CONTEXT_WINDOWS = {
419
592
  'gemini-3-pro': 1000000,
420
593
  'gemini-3.5-flash': 1000000,
421
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,
422
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.
423
605
  function guessContextWindow(model) {
424
606
  if (CONTEXT_WINDOWS[model])
425
607
  return CONTEXT_WINDOWS[model];
426
- 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'))
427
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;
428
622
  return 128000;
429
623
  }
430
624
  function positiveContextWindow(value) {
@@ -445,26 +639,108 @@ function providerNameOf(provider) {
445
639
  if (typeof provider === 'string') return provider.toLowerCase();
446
640
  return String(provider?.name || provider?.id || '').toLowerCase();
447
641
  }
448
- function compactBufferRatioForSession(session) {
449
- const cfg = session?.compaction || {};
450
- return normalizeCompactionBufferRatio(
451
- cfg.bufferPercent
452
- ?? cfg.bufferPct
453
- ?? cfg.bufferRatio
454
- ?? cfg.bufferFraction
455
- ?? process.env.MIXDOG_BRIDGE_COMPACT_BUFFER_PERCENT
456
- ?? process.env.MIXDOG_BRIDGE_COMPACT_BUFFER_RATIO,
457
- 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],
458
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
+ };
459
735
  }
460
736
  function compactBufferTokensForSession(session, boundaryTokens) {
461
- const cfg = session?.compaction || {};
737
+ const cfg = compactBufferConfigForBoundary(session?.compaction || {}, boundaryTokens);
462
738
  const explicit = positiveContextWindow(cfg.bufferTokens ?? cfg.buffer)
463
- || positiveContextWindow(process.env.MIXDOG_BRIDGE_COMPACT_BUFFER_TOKENS)
739
+ || positiveContextWindow(process.env.MIXDOG_AGENT_COMPACT_BUFFER_TOKENS)
464
740
  || 0;
465
741
  return compactionBufferTokensForBoundary(boundaryTokens, {
466
742
  explicitTokens: explicit,
467
- ratio: compactBufferRatioForSession(session),
743
+ ratio: compactBufferRatioForConfig(cfg),
468
744
  maxRatio: 0.25,
469
745
  });
470
746
  }
@@ -472,8 +748,8 @@ const COMPACT_TARGET_RATIO = 0.02;
472
748
  const COMPACT_TARGET_MIN_TOKENS = 4_000;
473
749
  const COMPACT_TARGET_MAX_TOKENS = 16_000;
474
750
  function compactTargetRatio() {
475
- const raw = process.env.MIXDOG_COMPACT_TARGET_PERCENT
476
- ?? process.env.MIXDOG_BRIDGE_COMPACT_TARGET_PERCENT
751
+ const raw = process.env.MIXDOG_AGENT_COMPACT_TARGET_PERCENT
752
+ ?? process.env.MIXDOG_COMPACT_TARGET_PERCENT
477
753
  ?? COMPACT_TARGET_RATIO;
478
754
  const n = Number(raw);
479
755
  if (!Number.isFinite(n) || n <= 0) return COMPACT_TARGET_RATIO;
@@ -483,8 +759,8 @@ function compactTargetTokensForBoundary(boundaryTokens) {
483
759
  const boundary = positiveContextWindow(boundaryTokens);
484
760
  if (!boundary) return null;
485
761
  const explicit = positiveContextWindow(
486
- process.env.MIXDOG_COMPACT_TARGET_TOKENS
487
- ?? process.env.MIXDOG_BRIDGE_COMPACT_TARGET_TOKENS,
762
+ process.env.MIXDOG_AGENT_COMPACT_TARGET_TOKENS
763
+ ?? process.env.MIXDOG_COMPACT_TARGET_TOKENS,
488
764
  );
489
765
  if (explicit) return Math.max(1, Math.min(boundary, explicit));
490
766
  const minTarget = Math.min(boundary, positiveContextWindow(process.env.MIXDOG_COMPACT_TARGET_MIN_TOKENS) || COMPACT_TARGET_MIN_TOKENS);
@@ -493,10 +769,10 @@ function compactTargetTokensForBoundary(boundaryTokens) {
493
769
  return Math.max(1, Math.min(boundary, maxTarget, Math.max(minTarget, byRatio)));
494
770
  }
495
771
  function defaultEffectiveContextWindowPercent(provider) {
496
- // Gateway/statusline route metadata reserves a universal 5% headroom from
772
+ // Gateway/statusline route metadata reserves a universal 10% headroom from
497
773
  // the raw catalog window. Keep session compaction on the same effective
498
774
  // capacity so /context, the TUI statusline, and gateway telemetry agree.
499
- return 95;
775
+ return 90;
500
776
  }
501
777
  function resolveSessionContextMeta(provider, model, seed = {}) {
502
778
  const info = typeof provider?.getCachedModelInfo === 'function'
@@ -526,7 +802,8 @@ function resolveSessionContextMeta(provider, model, seed = {}) {
526
802
  );
527
803
  const pct = boundedPercent(effectiveContextWindowPercent, 100);
528
804
  const contextWindow = Math.max(1, Math.floor(rawContextWindow * pct / 100));
529
- const explicitCompactLimit = positiveContextWindow(
805
+ const compactBoundaryTokens = contextWindow;
806
+ const rawCompactLimit = positiveContextWindow(
530
807
  seed.autoCompactTokenLimit
531
808
  ?? seed.auto_compact_token_limit
532
809
  ?? info?.autoCompactTokenLimit
@@ -534,11 +811,27 @@ function resolveSessionContextMeta(provider, model, seed = {}) {
534
811
  ?? catalogInfo?.autoCompactTokenLimit
535
812
  ?? catalogInfo?.auto_compact_token_limit,
536
813
  );
537
- const derivedCompactLimit = contextWindow;
538
- const autoCompactTokenLimit = explicitCompactLimit && derivedCompactLimit
539
- ? Math.min(explicitCompactLimit, derivedCompactLimit)
540
- : (explicitCompactLimit || derivedCompactLimit);
541
- 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;
542
835
  return {
543
836
  contextWindow,
544
837
  rawContextWindow,
@@ -551,10 +844,36 @@ function compactTriggerForSession(session, boundaryTokens) {
551
844
  const boundary = positiveContextWindow(boundaryTokens);
552
845
  if (!boundary) return null;
553
846
  const autoLimit = positiveContextWindow(session?.autoCompactTokenLimit ?? session?.compaction?.autoCompactTokenLimit);
554
- 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);
555
852
  const buffer = compactBufferTokensForSession(session, boundary);
556
853
  return Math.max(1, boundary - buffer);
557
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
+ }
558
877
  function compactTargetBudget(boundaryTokens, reserveTokens, _sourceTokens = null, _ratio = null) {
559
878
  const boundary = positiveContextWindow(boundaryTokens);
560
879
  if (!boundary) return null;
@@ -564,12 +883,21 @@ function compactTargetBudget(boundaryTokens, reserveTokens, _sourceTokens = null
564
883
  }
565
884
  function semanticCompactionEnabledForSession(session) {
566
885
  const cfg = session?.compaction || {};
886
+ if (process.env.MIXDOG_AGENT_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_AGENT_COMPACT_SEMANTIC', true);
567
887
  if (process.env.MIXDOG_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_COMPACT_SEMANTIC', true);
568
- if (process.env.MIXDOG_BRIDGE_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_BRIDGE_COMPACT_SEMANTIC', true);
569
888
  if (cfg.semantic === false || cfg.semantic === 'false' || cfg.semantic === 'off') return false;
570
889
  if (cfg.semantic === true || cfg.semantic === 'true' || cfg.semantic === 'on' || cfg.semantic === 'auto') return true;
571
890
  return true;
572
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
+ }
573
901
  function addCompactUsageToSession(session, usage) {
574
902
  if (!session || !usage) return;
575
903
  const inputTokens = usage.inputTokens || 0;
@@ -582,6 +910,73 @@ function addCompactUsageToSession(session, usage) {
582
910
  session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + cacheWriteTokens;
583
911
  session.tokensCumulative = (session.tokensCumulative || 0) + inputTokens + outputTokens;
584
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
+ }
585
980
  async function runSessionCompaction(session, opts = {}) {
586
981
  if (!session || session.closed === true) return null;
587
982
  const mode = opts.mode === 'auto' ? 'auto' : 'manual';
@@ -598,17 +993,18 @@ async function runSessionCompaction(session, opts = {}) {
598
993
  }
599
994
  const reserveTokens = estimateRequestReserveTokens(session.tools || []);
600
995
  const beforeMessageTokens = estimateMessagesTokens(messages);
601
- const lastContextTokens = positiveContextWindow(session.lastContextTokens) || 0;
602
996
  const triggerTokens = compactTriggerForSession(session, boundary)
603
997
  || positiveContextWindow(session.compaction?.triggerTokens)
604
998
  || boundary;
605
999
  const bufferTokens = Math.max(0, boundary - triggerTokens);
606
1000
  const bufferRatio = boundary ? (bufferTokens / boundary) : compactBufferRatioForSession(session);
607
- const pressureTokens = Math.max(beforeMessageTokens + reserveTokens, lastContextTokens);
1001
+ const pressureTokens = estimateTranscriptContextUsage(messages, session.tools || []);
608
1002
  const beforeTokens = pressureTokens;
1003
+ const compactType = compactTypeForSession(session);
609
1004
  if (!force && pressureTokens < triggerTokens) return {
610
1005
  changed: false,
611
1006
  reason: 'below threshold',
1007
+ compactType,
612
1008
  beforeMessages: messages.length,
613
1009
  afterMessages: messages.length,
614
1010
  beforeTokens,
@@ -628,13 +1024,43 @@ async function runSessionCompaction(session, opts = {}) {
628
1024
  const budgetSourceTokens = force ? Math.max(pressureTokens, triggerTokens) : pressureTokens;
629
1025
  const compactBudget = compactTargetBudget(boundary, reserveTokens, budgetSourceTokens);
630
1026
  const budget = compactBudget || boundary;
1027
+ try { opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
631
1028
  const provider = opts.provider || getProvider(session.provider) || null;
632
1029
  let compacted;
633
1030
  let compactError = null;
634
1031
  let semanticCompactResult = null;
635
1032
  let semanticCompactError = null;
636
- 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)) {
637
1060
  try {
1061
+ if (!semanticCompactionEnabledForSession(session)) {
1062
+ throw new Error('semantic compact is disabled for this session');
1063
+ }
638
1064
  if (!provider || typeof provider.send !== 'function') {
639
1065
  throw new Error(`semantic compact provider unavailable: ${session.provider || 'unknown'}`);
640
1066
  }
@@ -663,22 +1089,14 @@ async function runSessionCompaction(session, opts = {}) {
663
1089
  }
664
1090
  } catch (err) {
665
1091
  semanticCompactError = err;
1092
+ compactError = err;
666
1093
  try {
667
- 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`);
668
1095
  } catch { /* best-effort */ }
669
1096
  }
670
1097
  }
671
- try {
672
- if (!compacted) compacted = compactMessages(messages, budget, { reserveTokens, force: true });
673
- } catch (err) {
674
- try {
675
- process.stderr.write(`[session] ${mode} compact fallback (sess=${session.id || 'unknown'}): ${err?.message || err}\n`);
676
- } catch { /* best-effort */ }
677
- try {
678
- compacted = compactActiveTurn(messages, budget, { reserveTokens, force: true });
679
- } catch (fallbackErr) {
680
- compactError = fallbackErr;
681
- }
1098
+ if (!compacted && !compactError) {
1099
+ compactError = new Error(`${compactType} compact produced no messages`);
682
1100
  }
683
1101
  if (!compacted) {
684
1102
  const now = Date.now();
@@ -698,13 +1116,19 @@ async function runSessionCompaction(session, opts = {}) {
698
1116
  lastPressureTokens: pressureTokens,
699
1117
  lastCheckedAt: now,
700
1118
  lastChanged: false,
1119
+ type: compactType,
1120
+ compactType,
1121
+ lastCompactType: compactType,
701
1122
  lastSemantic: false,
702
1123
  lastSemanticError: semanticCompactError?.message || null,
703
- 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'),
704
1127
  };
705
1128
  return {
706
1129
  changed: false,
707
1130
  error: session.compaction.lastError,
1131
+ compactType,
708
1132
  beforeMessages: messages.length,
709
1133
  afterMessages: messages.length,
710
1134
  beforeTokens,
@@ -721,6 +1145,8 @@ async function runSessionCompaction(session, opts = {}) {
721
1145
  reserveTokens,
722
1146
  semanticCompact: false,
723
1147
  semanticError: semanticCompactError?.message || null,
1148
+ recallFastTrack: false,
1149
+ recallFastTrackError: recallFastTrackError?.message || null,
724
1150
  };
725
1151
  }
726
1152
  let beforeEncoded = '';
@@ -732,6 +1158,7 @@ async function runSessionCompaction(session, opts = {}) {
732
1158
  const changed = beforeEncoded && afterEncoded
733
1159
  ? beforeEncoded !== afterEncoded
734
1160
  : (compacted.length !== messages.length || afterMessageTokens !== beforeMessageTokens);
1161
+ const unchangedReason = changed ? null : (force ? 'nothing to compact' : 'below threshold');
735
1162
  const now = Date.now();
736
1163
  session.messages = compacted;
737
1164
  session.providerState = undefined;
@@ -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,
@@ -766,6 +1199,8 @@ async function runSessionCompaction(session, opts = {}) {
766
1199
  if (changed && mode === 'auto') session.lastContextTokensStaleAfterCompact = true;
767
1200
  return {
768
1201
  changed,
1202
+ reason: unchangedReason,
1203
+ compactType,
769
1204
  beforeMessages: messages.length,
770
1205
  afterMessages: compacted.length,
771
1206
  beforeTokens,
@@ -782,20 +1217,20 @@ async function runSessionCompaction(session, opts = {}) {
782
1217
  reserveTokens,
783
1218
  semanticCompact: semanticCompactResult?.semantic === true,
784
1219
  semanticError: semanticCompactError?.message || null,
1220
+ recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
1221
+ recallFastTrackError: recallFastTrackError?.message || null,
785
1222
  usage: semanticCompactResult?.usage || null,
786
1223
  };
787
1224
  }
788
- async function autoCompactSessionAfterTurn(session, opts = {}) {
789
- return runSessionCompaction(session, { ...opts, mode: 'auto', force: false });
790
- }
791
1225
  // Provider-scoped unified cache key. Goal: all orchestrator-internal
792
- // dispatches (bridge/maintenance/mcp/scheduler/webhook) targeting the
1226
+ // dispatches (agent/maintenance/mcp/scheduler/webhook) targeting the
793
1227
  // same provider land in a single server-side cache shard, so the
794
1228
  // shared prefix (tools + system + pool system prompt) is reused
795
- // regardless of role. Per-role / per-session differentiation lives in
796
- // 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.
797
1232
  const PROVIDER_ALIAS = {
798
- 'openai-oauth': 'codex', // ChatGPT subscription (Codex backend)
1233
+ 'openai-oauth': 'codex', // ChatGPT subscription (OpenAI OAuth backend)
799
1234
  'anthropic-oauth': 'claude', // Claude Max subscription
800
1235
  'openai': 'openai',
801
1236
  'anthropic': 'anthropic',
@@ -810,9 +1245,10 @@ function providerCacheKey(provider, override) {
810
1245
  }
811
1246
 
812
1247
  // ── Prefetch permission guard ─────────────────────────────────────────────────
813
- // Mirrors _checkWorkerPermission in loop.mjs for tool calls that originate
814
- // in the prefetch path (outside the agent loop). Returns an error string if
815
- // 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.
816
1252
  const _permEvalForPrefetch = (() => {
817
1253
  const _req = createRequire(import.meta.url);
818
1254
  try {
@@ -823,9 +1259,8 @@ const _permEvalForPrefetch = (() => {
823
1259
  })();
824
1260
  function _guardedPrefetchTool(toolName, toolArgs, session) {
825
1261
  if (!_permEvalForPrefetch) return null;
826
- // Same baseline as _checkWorkerPermission: when no explicit mode is
827
- // attached to the session, run the evaluator under 'default' so the
828
- // 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.
829
1264
  const permissionMode = session?.permissionMode || 'default';
830
1265
  const projectDir = session?.cwd || undefined;
831
1266
  const userCwd = session?.cwd || undefined;
@@ -844,7 +1279,7 @@ function _guardedPrefetchTool(toolName, toolArgs, session) {
844
1279
 
845
1280
  async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
846
1281
  if (!explicitPrefetch || typeof explicitPrefetch !== 'object') return null;
847
- if (session?.owner !== 'bridge') return null;
1282
+ if (!isAgentOwner(session)) return null;
848
1283
  const parts = [];
849
1284
  const failed = [];
850
1285
  const totalEntries = [];
@@ -886,7 +1321,7 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
886
1321
  if (files.length > 0) {
887
1322
  const _pfGuard = _guardedPrefetchTool('read', { path: files }, session);
888
1323
  if (_pfGuard) {
889
- process.stderr.write(`[bridge-prefetch] files read blocked: ${_pfGuard}\n`);
1324
+ process.stderr.write(`[agent-prefetch] files read blocked: ${_pfGuard}\n`);
890
1325
  failed.push(...files);
891
1326
  totalEntries.push(...files);
892
1327
  } else {
@@ -930,7 +1365,7 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
930
1365
  readArgs.n = Number.isFinite(opts.n) ? opts.n : 120;
931
1366
  }
932
1367
  const out = await executeInternalTool('read', readArgs).catch((e) => {
933
- 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`);
934
1369
  return null;
935
1370
  });
936
1371
  if (out !== null) {
@@ -968,9 +1403,11 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
968
1403
  parts.push(`### prefetch files\nread ${readParts.length}\n\n${readParts.join('\n\n')}`);
969
1404
  }
970
1405
  // Log hit/miss counters so dispatch telemetry shows prefetch effectiveness.
971
- process.stderr.write(
972
- `[prefetch] files=${files.length} cached=${fileHits.length} miss=${fileMisses.length} failed=${failed.length}\n`
973
- );
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
+ }
974
1411
  // Attach stats to session so post-hoc analyzers (inspect-session.mjs)
975
1412
  // can see prefetch effectiveness without parsing stderr logs.
976
1413
  if (session && typeof session === 'object') {
@@ -992,13 +1429,13 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
992
1429
  totalEntries.push(symbol);
993
1430
  const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
994
1431
  if (blocked) {
995
- process.stderr.write(`[bridge-prefetch] callers(${symbol}) blocked: ${blocked}\n`);
1432
+ process.stderr.write(`[agent-prefetch] callers(${symbol}) blocked: ${blocked}\n`);
996
1433
  return Promise.resolve({ symbol, out: null, blocked: true });
997
1434
  }
998
- return executeCodeGraphTool('code_graph', cgArgs, session?.cwd)
1435
+ return _executeCodeGraphToolLazy('code_graph', cgArgs, session?.cwd)
999
1436
  .then(out => ({ symbol, out }))
1000
1437
  .catch(e => {
1001
- 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`);
1002
1439
  return { symbol, out: null };
1003
1440
  });
1004
1441
  });
@@ -1023,13 +1460,13 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
1023
1460
  totalEntries.push(symbol);
1024
1461
  const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
1025
1462
  if (blocked) {
1026
- process.stderr.write(`[bridge-prefetch] references(${symbol}) blocked: ${blocked}\n`);
1463
+ process.stderr.write(`[agent-prefetch] references(${symbol}) blocked: ${blocked}\n`);
1027
1464
  return Promise.resolve({ symbol, out: null, blocked: true });
1028
1465
  }
1029
- return executeCodeGraphTool('code_graph', cgArgs, session?.cwd)
1466
+ return _executeCodeGraphToolLazy('code_graph', cgArgs, session?.cwd)
1030
1467
  .then(out => ({ symbol, out }))
1031
1468
  .catch(e => {
1032
- 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`);
1033
1470
  return { symbol, out: null };
1034
1471
  });
1035
1472
  });
@@ -1064,29 +1501,29 @@ async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
1064
1501
  return `${warnLine}<prefetch>\n${parts.join('\n\n')}\n</prefetch>`;
1065
1502
  }
1066
1503
 
1067
- // --- bridge spawn (createSession) ---
1504
+ // --- agent spawn (createSession) ---
1068
1505
  // opts can pass either a `preset` object (from config.presets) or raw provider/model.
1069
1506
  // Preset shape: { name, provider, model, effort?, fast?, tools? }
1070
1507
  //
1071
- // Smart Bridge integration:
1508
+ // Agent Runtime integration:
1072
1509
  // opts.taskType / opts.role / opts.profileId — enables profile-aware routing.
1073
1510
  // Rule-based SmartRouter resolves these synchronously; the resolved
1074
1511
  // profile controls context filtering (skip.skills/memory/etc) and cache
1075
1512
  // strategy. If no rule matches, falls back to classic preset behavior.
1076
1513
  // opts.profile — pre-resolved profile (bypasses router; used by async
1077
- // callers who already ran SmartBridge.resolve()).
1514
+ // callers who already ran AgentRuntime.resolve()).
1078
1515
  // opts.providerCacheOpts — pre-resolved cache options merged into ask() sendOpts.
1079
1516
  export function createSession(opts) {
1080
1517
  const presetObj = opts.preset && typeof opts.preset === 'object' ? opts.preset : null;
1081
1518
 
1082
- // --- Smart Bridge profile resolution (best-effort, sync) ---
1519
+ // --- Agent Runtime profile resolution (best-effort, sync) ---
1083
1520
  let profile = opts.profile || null;
1084
1521
  let providerCacheOpts = opts.providerCacheOpts || null;
1085
1522
  if (!profile && (opts.taskType || opts.role || opts.profileId)) {
1086
- const smartBridge = getSmartBridgeSync();
1087
- if (smartBridge) {
1523
+ const agentRuntime = getAgentRuntimeSync();
1524
+ if (agentRuntime) {
1088
1525
  try {
1089
- const resolved = smartBridge.resolveSync({
1526
+ const resolved = agentRuntime.resolveSync({
1090
1527
  taskType: opts.taskType,
1091
1528
  role: opts.role,
1092
1529
  profileId: opts.profileId,
@@ -1098,10 +1535,10 @@ export function createSession(opts) {
1098
1535
  providerCacheOpts = resolved.providerCacheOpts;
1099
1536
  }
1100
1537
  } catch (e) {
1101
- // Smart Bridge error — log once, fall back to classic behavior.
1102
- if (!_smartBridgeWarned) {
1103
- _smartBridgeWarned = true;
1104
- 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`);
1105
1542
  }
1106
1543
  }
1107
1544
  }
@@ -1129,132 +1566,108 @@ export function createSession(opts) {
1129
1566
  throw new Error(`Provider "${providerName}" not found or not enabled`);
1130
1567
  const id = `sess_${process.pid}_${nextId++}_${Date.now()}_${randomBytes(16).toString('hex')}`;
1131
1568
  const messages = [];
1132
- const agentTemplate = opts.agent ? loadAgentTemplate(opts.agent, opts.cwd) : null;
1133
- const skills = opts.skipSkills ? [] : collectSkillsCached(opts.cwd);
1134
-
1135
- // Bridge shared prefix (bit-identical across roles). Hidden roles reuse the
1136
- // same shared bridge rules so the cache shard stays stable across bridge
1137
- // callers. User-defined data (DATA_DIR roles/schedules/webhooks) is baked
1138
- // into BP1 as a single fixed-value monolithic block so every role shares
1139
- // one cache shard. A user edit invalidates BP1 once and the new prefix
1140
- // re-warms across all roles together.
1141
- const bridgeRulesRole = opts.role || profile?.taskType || null;
1142
- const injectedRules = opts.skipBridgeRules ? '' : (opts.owner === 'bridge' ? _buildBridgeRules() : _buildLeadRules());
1143
- const roleSpecific = opts.owner === 'bridge' && !opts.skipBridgeRules ? _buildRoleSpecific(bridgeRulesRole) : '';
1144
- // mixdog.md user/project context (global + cwd ancestors, broad-to-specific).
1145
- const projectContext = collectMixdogMd(opts.cwd);
1146
-
1147
- // Role template (Phase B §4 — UI-managed). Reads <DATA_DIR>/roles/<role>.md
1148
- // and parses frontmatter (description, permission). The template is
1149
- // injected into the Tier 3 system-reminder so role differences never
1150
- // touch the BP_2 cache prefix.
1569
+ const ownerIsAgent = isAgentOwner(opts.owner);
1151
1570
  const resolvedRole = opts.role || profile?.taskType || null;
1152
- const dataDir = resolvePluginData();
1153
- const roleTemplate = resolvedRole && dataDir
1154
- ? loadRoleTemplate(resolvedRole, dataDir)
1155
- : 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);
1156
1577
 
1157
- // Bridge sessions must not inherit role/profile/preset tool narrowing: Pool
1158
- // B and Pool C share one bit-identical tool schema for BP_1/BP_2 cache
1159
- // reuse, and permission differences are enforced only at call time. Raw
1160
- // 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
1161
1593
  // behaviour.
1162
- const toolSpec = opts.owner === 'bridge'
1594
+ const toolSpec = ownerIsAgent
1163
1595
  ? 'full'
1164
1596
  : (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
1165
1597
 
1166
1598
  // Prompt permission is metadata only. Preset tool restrictions must NOT
1167
- // 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
1168
1600
  // to toolPermission below and are enforced only at call time.
1169
1601
  const permission = opts.permission
1170
- || roleTemplate?.permission
1171
1602
  || null;
1172
1603
  const toolPermission = opts.permission
1173
1604
  || profile?.permission
1174
- || roleTemplate?.permission
1175
1605
  || permissionFromToolSpec(toolPreset)
1176
1606
  || null;
1177
- let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsBridge: opts.owner === 'bridge' });
1178
- // Fail-closed permission intersection: when a role declares an explicit
1179
- // 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
1180
1610
  // resolved tool list with the permission's allow/deny lists. If the
1181
1611
  // intersection produces an empty set the permission config is broken —
1182
1612
  // fail closed (zero tools) rather than silently falling back to the full
1183
1613
  // preset, which would grant the role more surface than declared.
1184
- if (toolPermission && typeof toolPermission === 'object') {
1185
- const allowSet = Array.isArray(toolPermission.allow) && toolPermission.allow.length > 0
1186
- ? new Set(toolPermission.allow.map(n => String(n).toLowerCase()))
1187
- : null;
1188
- const denySet = Array.isArray(toolPermission.deny) && toolPermission.deny.length > 0
1189
- ? new Set(toolPermission.deny.map(n => String(n).toLowerCase()))
1190
- : null;
1191
- if (allowSet || denySet) {
1192
- const filtered = toolsForRouting.filter(t => {
1193
- const name = String(t?.name || '').toLowerCase();
1194
- if (denySet && denySet.has(name)) return false;
1195
- if (allowSet && !allowSet.has(name)) return false;
1196
- return true;
1197
- });
1198
- // Fail-closed: an empty intersection means the permission config is
1199
- // misconfigured — do not silently fall back to the full preset.
1200
- toolsForRouting = filtered;
1201
- if (filtered.length === 0) {
1202
- process.stderr.write(`[session] WARN: role permission intersection produced 0 tools — failing closed (role=${opts.role || 'unknown'})
1203
- `);
1204
- }
1205
- }
1614
+ if (ownerIsAgent) {
1615
+ toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, toolPermission, opts.role || null);
1206
1616
  }
1207
1617
 
1208
- const { baseRules, roleCatalog, sessionMarker, volatileTail } = composeSystemPrompt({
1618
+ const { baseRules, stableSystemContext, sessionMarker, volatileTail } = composeSystemPrompt({
1209
1619
  userPrompt: opts.systemPrompt,
1210
- bridgeRules: injectedRules || undefined,
1211
- roleSpecific: roleSpecific || undefined,
1212
- skipRoleCatalog: opts.owner !== 'bridge',
1213
- agentTemplate: agentTemplate || undefined,
1214
- roleTemplate: roleTemplate || undefined,
1215
- hasSkills: skills.length > 0,
1620
+ agentRules: injectedRules || undefined,
1621
+ roleRules: roleRules || undefined,
1622
+ metaContext: metaContext || undefined,
1623
+ skipRoleCatalog: !ownerIsAgent,
1216
1624
  profile: profile || undefined,
1217
1625
  role: resolvedRole,
1218
- skipRoleReminder: opts.skipRoleReminder || false,
1219
- permission,
1220
- taskBrief: opts.taskBrief || null,
1221
1626
  workflowContext: opts.workflowContext || null,
1222
1627
  workspaceContext: opts.workspaceContext || null,
1223
1628
  coreMemoryContext: opts.coreMemoryContext || null,
1224
- projectContext: projectContext || null,
1629
+ skillManifest: buildSkillManifest(skills),
1225
1630
  tools: toolsForRouting,
1226
- bashIsPersistent: opts.owner === 'bridge' && toolsForRouting.some(t => t?.name === 'shell'),
1631
+ bashIsPersistent: ownerIsAgent && toolsForRouting.some(t => t?.name === 'shell'),
1227
1632
  // Effective cwd rides in tier3Reminder so explore-like tools know
1228
1633
  // their search root without needing to shove "Override cwd:" into
1229
1634
  // the user message body (that used to fragment the shard prefix).
1230
1635
  cwd: opts.cwd || null,
1231
- // BP2 catalog policy — explicit-cache providers see the unified
1232
- // all-roles catalog; implicit-prefix-hash providers keep self-only.
1233
1636
  provider: providerName || null,
1234
1637
  });
1235
1638
  // 4-BP layout (see composeSystemPrompt docs):
1236
- // system block #1 = baseRules — BP1 (1h) shared across ALL roles
1237
- // system block #2 = roleCatalog — BP2 (1h) scoped role catalog
1238
- // first <system-reminder> user = sessionMarker — BP3 (1h) mixdog.md + stable role body
1239
- // second <system-reminder> user = volatileTail — rides near BP4 (5m)
1240
- // Anthropic multi-block system pins each block with cache_control.
1241
- // OpenAI gets a stable provider cache key/session prefix. Gemini relies
1242
- // on implicit prompt caching only, so hits are observed, not treated as a
1243
- // 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.
1244
1650
  if (baseRules) {
1245
1651
  messages.push({ role: 'system', content: baseRules });
1246
1652
  }
1247
- if (roleCatalog) {
1248
- messages.push({ role: 'system', content: roleCatalog });
1653
+ if (stableSystemContext) {
1654
+ messages.push({ role: 'system', content: stableSystemContext });
1249
1655
  }
1250
1656
  if (sessionMarker) {
1251
- messages.push({ role: 'user', content: `<system-reminder>\n${sessionMarker}\n</system-reminder>` });
1252
- 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' });
1253
1662
  }
1254
1663
  if (volatileTail) {
1255
1664
  messages.push({ role: 'user', content: `<system-reminder>\n${volatileTail}\n</system-reminder>` });
1256
1665
  messages.push({ role: 'assistant', content: '.' });
1257
1666
  }
1667
+ if (roleSpecific) {
1668
+ messages.push({ role: 'user', content: `<system-reminder>\n${roleSpecific}\n</system-reminder>` });
1669
+ messages.push({ role: 'assistant', content: '.' });
1670
+ }
1258
1671
  if (opts.files?.length) {
1259
1672
  const fileContext = opts.files
1260
1673
  .map(f => `### ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
@@ -1262,58 +1675,30 @@ export function createSession(opts) {
1262
1675
  messages.push({ role: 'user', content: `Reference files:\n\n${fileContext}` });
1263
1676
  messages.push({ role: 'assistant', content: '.' });
1264
1677
  }
1265
- let tools = toolsForRouting;
1266
-
1267
- // Schema filtering applied after schema build:
1268
- // - opts.schemaAllowedTools : declarative hidden-role schema profile
1269
- // allowlist for tiny specialist roles where one-shot tool routing
1270
- // beats the shared-schema cache win.
1271
- // - opts.disallowedTools : per-call caller override (Anthropic
1272
- // BuiltInAgentDefinition pattern)
1273
- // - annotations.bridgeHidden : declarative per-tool flag (tools.json
1274
- // and internal tool defs). Pool A (Lead) still sees all tools.
1275
- //
1276
1678
  const hasCallerAllow = Array.isArray(opts.schemaAllowedTools);
1277
- const callerAllow = hasCallerAllow ? opts.schemaAllowedTools.map(n => String(n).toLowerCase()) : [];
1278
- if (hasCallerAllow) {
1279
- const allowSet = new Set(callerAllow);
1280
- const before = tools.length;
1281
- tools = tools.filter(t => allowSet.has(String(t?.name || '').toLowerCase()));
1282
- if (tools.length !== before && !process.env.MIXDOG_QUIET_SESSION_LOG) {
1283
- process.stderr.write(`[session] schemaAllowedTools=${callerAllow.join(',')} kept ${tools.length}/${before} tools\n`);
1284
- }
1285
- }
1286
- const callerDeny = Array.isArray(opts.disallowedTools) ? opts.disallowedTools.map(n => String(n)) : [];
1287
- if (callerDeny.length) {
1288
- const denySet = new Set(callerDeny);
1289
- const before = tools.length;
1290
- tools = tools.filter(t => !denySet.has(String(t?.name || '').toLowerCase()));
1291
- if (tools.length !== before && !process.env.MIXDOG_QUIET_SESSION_LOG) {
1292
- process.stderr.write(`[session] disallowedTools=${callerDeny.join(',')} stripped ${before - tools.length} tools\n`);
1293
- }
1294
- }
1295
- if (opts.owner === 'bridge') {
1296
- const before = tools.length;
1297
- tools = tools.filter(t => !t?.annotations?.bridgeHidden);
1298
- if (tools.length !== before && !process.env.MIXDOG_QUIET_SESSION_LOG) {
1299
- process.stderr.write(`[session] bridgeHidden stripped ${before - tools.length} tools\n`);
1300
- }
1301
- }
1302
-
1303
- // Bridge tool canonicalization: keep route-sensitive tools in policy order
1304
- // while preserving deterministic MCP/skill order for BP1 shard stability.
1305
- if (opts.owner === 'bridge') {
1306
- tools = orderSessionTools(tools);
1307
- }
1679
+ const tools = finalizeSessionToolList(toolsForRouting, {
1680
+ schemaAllowedTools: hasCallerAllow ? opts.schemaAllowedTools : null,
1681
+ disallowedTools: opts.disallowedTools,
1682
+ ownerIsAgent,
1683
+ resolvedRole,
1684
+ });
1308
1685
 
1309
1686
  // Unified-shard policy — no broad role-specific schema filter. Keep
1310
- // bridge schemas shared unless a hidden-role schema profile explicitly
1687
+ // agent schemas shared unless a hidden-role schema profile explicitly
1311
1688
  // passes schemaAllowedTools for a small specialist; broad role
1312
1689
  // whitelists would fragment the cache shard.
1313
- if (resolvedRole && !process.env.MIXDOG_QUIET_SESSION_LOG) {
1690
+ if (resolvedRole && process.env.MIXDOG_DEBUG_SESSION_LOG) {
1314
1691
  process.stderr.write(`[session] role=${resolvedRole} permission=${permission || 'full'} toolPermission=${toolPermission || 'full'} tools=${tools.length}\n`);
1315
1692
  }
1316
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;
1317
1702
  const session = {
1318
1703
  id,
1319
1704
  provider: providerName,
@@ -1328,13 +1713,27 @@ export function createSession(opts) {
1328
1713
  auto: opts.compaction?.auto !== false,
1329
1714
  prune: opts.compaction?.prune === true,
1330
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),
1331
1718
  model: opts.compaction?.model || null,
1332
1719
  timeoutMs: positiveContextWindow(opts.compaction?.timeoutMs),
1333
1720
  tailTurns: positiveContextWindow(opts.compaction?.tailTurns),
1334
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),
1335
1727
  keepTokens: positiveContextWindow(opts.compaction?.keepTokens ?? opts.compaction?.keep?.tokens),
1336
1728
  preserveRecentTokens: positiveContextWindow(opts.compaction?.preserveRecentTokens),
1337
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),
1338
1737
  boundaryTokens: contextMeta.compactBoundaryTokens,
1339
1738
  },
1340
1739
  tools,
@@ -1346,40 +1745,42 @@ export function createSession(opts) {
1346
1745
  owner: opts.owner || 'user',
1347
1746
  mcpPid: process.pid,
1348
1747
  scopeKey: opts.scopeKey || null,
1349
- lane: opts.lane || 'bridge',
1748
+ lane: opts.lane || 'agent',
1350
1749
  cwd: opts.cwd,
1750
+ workflow: workflowMeta,
1351
1751
  createdAt: Date.now(),
1352
1752
  updatedAt: Date.now(),
1353
1753
  lastHeartbeatAt: null,
1354
1754
  totalInputTokens: 0,
1355
1755
  totalOutputTokens: 0,
1356
- // Refreshed on each completed ask() — surfaced by bridge type=list for
1756
+ // Refreshed on each completed ask() — surfaced by agent type=list for
1357
1757
  // debugging + consumed by store.mjs's idle-sweep to reclaim stalled
1358
- // bridge sessions past RUNNING_STALL_MS.
1758
+ // agent sessions past RUNNING_STALL_MS.
1359
1759
  lastUsedAt: Date.now(),
1360
1760
  tokensCumulative: 0,
1361
1761
  role: opts.role || null,
1362
1762
  taskType: opts.taskType || null,
1363
1763
  maxLoopIterations: Number.isFinite(opts.maxLoopIterations) ? opts.maxLoopIterations : null,
1364
- // 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
1365
1765
  // process (statusline) + aggregator can read it from the session JSON.
1366
1766
  // In-process send/close still resolve via _tagSessionRegistry.
1367
- bridgeTag: opts.bridgeTag || null,
1767
+ agentTag: opts.agentTag || null,
1368
1768
  // Prompt permission is separate from runtime toolPermission so preset
1369
- // restrictions do not fragment the bridge cache prefix.
1769
+ // restrictions do not fragment the agent cache prefix.
1370
1770
  permission: permission || null,
1371
1771
  toolPermission: toolPermission || null,
1372
- // 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
1373
1774
  // can slice by (sourceType, sourceName) — e.g. maintenance/cycle1,
1374
1775
  // scheduler/daily-standup, webhook/github-push, lead/worker.
1375
1776
  sourceType: opts.sourceType || null,
1376
1777
  sourceName: opts.sourceName || null,
1377
1778
  // Provider-scoped unified cache key — one shard per provider,
1378
- // shared across all roles / sources (bridge/maintenance/mcp/
1779
+ // shared across all roles / sources (agent/maintenance/mcp/
1379
1780
  // scheduler/webhook). Role or source-specific context must be
1380
1781
  // injected into the message tail, not the shared prefix.
1381
1782
  promptCacheKey: providerCacheKey(presetObj?.provider || opts.provider, opts.cacheKeyOverride),
1382
- // Bridge shell continuity: when a bridge session explicitly opts into
1783
+ // Agent shell continuity: when an agent session explicitly opts into
1383
1784
  // persistent shell state (`bash` with `persistent:true`, or direct
1384
1785
  // `bash_session`), the minted bash_session id is stored here so later
1385
1786
  // opted-in `bash` calls can reuse the same shell state.
@@ -1388,7 +1789,7 @@ export function createSession(opts) {
1388
1789
  // orchestrator session so closeSession can kill them all, not just
1389
1790
  // the most recently recorded one.
1390
1791
  allBashSessionIds: [],
1391
- // Smart Bridge metadata — optional. Applied on every ask() to merge
1792
+ // Agent Runtime metadata — optional. Applied on every ask() to merge
1392
1793
  // profile-driven cache settings into provider sendOpts.
1393
1794
  profileId: profile?.id || null,
1394
1795
  permissionMode: opts.permissionMode ?? null,
@@ -1404,7 +1805,7 @@ export function createSession(opts) {
1404
1805
  }
1405
1806
 
1406
1807
  // ── Runtime liveness map ──────────────────────────────────────────────
1407
- // 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
1408
1809
  // can surface whether a session is actually alive vs stuck. Never persisted —
1409
1810
  // heartbeats would otherwise churn the session JSON on every SSE delta.
1410
1811
  // Entry shape: {
@@ -1414,6 +1815,7 @@ export function createSession(opts) {
1414
1815
  // closed?: boolean, // flipped by closeSession()
1415
1816
  // }
1416
1817
  const _runtimeState = new Map();
1818
+ const _toolActivityHeartbeats = new Map();
1417
1819
  const VALID_STAGES = new Set([
1418
1820
  'connecting', 'requesting', 'streaming', 'tool_running', 'idle', 'error', 'done', 'cancelling',
1419
1821
  ]);
@@ -1425,6 +1827,33 @@ function _touchRuntime(id) {
1425
1827
  }
1426
1828
  return entry;
1427
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
+
1428
1857
  export function updateSessionStage(id, stage) {
1429
1858
  if (!id || !VALID_STAGES.has(stage)) return;
1430
1859
  const entry = _touchRuntime(id);
@@ -1435,6 +1864,7 @@ export function updateSessionStage(id, stage) {
1435
1864
  }
1436
1865
  entry.lastProgressAt = now;
1437
1866
  entry.updatedAt = now;
1867
+ if (stage !== 'tool_running') _stopToolActivityHeartbeat(id);
1438
1868
  }
1439
1869
 
1440
1870
  export function updateSessionRoute(id, route = {}) {
@@ -1500,7 +1930,11 @@ export function updateSessionRoute(id, route = {}) {
1500
1930
  */
1501
1931
  export function markSessionAskStart(id) {
1502
1932
  if (!id) return;
1933
+ _stopToolActivityHeartbeat(id);
1503
1934
  const entry = _touchRuntime(id);
1935
+ entry.usageMetricsTurnIncremental = false;
1936
+ const sessionForTurn = entry.session ?? loadSession(id);
1937
+ if (sessionForTurn) bumpUsageMetricsTurnId(sessionForTurn);
1504
1938
  entry.stage = 'connecting';
1505
1939
  entry.lastStreamDeltaAt = null;
1506
1940
  entry.lastToolCall = null;
@@ -1524,7 +1958,7 @@ export function markSessionAskStart(id) {
1524
1958
  // Publish heartbeat immediately so the status aggregator picks the
1525
1959
  // session up in the connecting / requesting window. Without this the
1526
1960
  // .hb file only landed on the first stream chunk — producing a 3–10s
1527
- // (xhigh: 30s+) invisible gap where bridge sessions ran but the CC
1961
+ // (xhigh: 30s+) invisible gap where agent sessions ran but the CC
1528
1962
  // statusline showed no maintenance/agent badge. STREAM_FRESH_MS (5 min)
1529
1963
  // still drops a session whose provider truly never returns a chunk;
1530
1964
  // markSessionStreamDelta keeps refreshing once chunks arrive.
@@ -1542,6 +1976,7 @@ export async function markSessionStreamDelta(id) {
1542
1976
  // path). Skip a missing, tombstoned, or aborted entry — never refresh liveness.
1543
1977
  const entry = _runtimeState.get(id);
1544
1978
  if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
1979
+ _stopToolActivityHeartbeat(id);
1545
1980
  const now = Date.now();
1546
1981
  entry.lastStreamDeltaAt = now;
1547
1982
  entry.lastProgressAt = now;
@@ -1571,9 +2006,14 @@ export function markSessionToolCall(id, toolName) {
1571
2006
  entry.lastProgressAt = entry.toolStartedAt;
1572
2007
  entry.updatedAt = entry.toolStartedAt;
1573
2008
  publishHeartbeat(id, entry.toolStartedAt);
2009
+ _startToolActivityHeartbeat(id);
1574
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.
1575
2014
  export function markSessionDone(id, { empty = false } = {}) {
1576
2015
  if (!id) return;
2016
+ _stopToolActivityHeartbeat(id);
1577
2017
  const entry = _touchRuntime(id);
1578
2018
  entry.stage = 'done';
1579
2019
  entry.lastError = null;
@@ -1608,6 +2048,7 @@ export function markSessionEmptyFinal(id) {
1608
2048
  }
1609
2049
  export function markSessionError(id, msg) {
1610
2050
  if (!id) return;
2051
+ _stopToolActivityHeartbeat(id);
1611
2052
  const entry = _touchRuntime(id);
1612
2053
  entry.stage = 'error';
1613
2054
  entry.lastError = msg ? String(msg).slice(0, 200) : null;
@@ -1622,9 +2063,11 @@ export function markSessionError(id, msg) {
1622
2063
  entry.lastProgressAt = errTs;
1623
2064
  entry.updatedAt = errTs;
1624
2065
  deleteHeartbeat(id);
2066
+ _unlinkParentAbortListener(entry);
1625
2067
  }
1626
2068
  export function markSessionCancelled(id) {
1627
2069
  if (!id) return;
2070
+ _stopToolActivityHeartbeat(id);
1628
2071
  const entry = _touchRuntime(id);
1629
2072
  entry.stage = 'done';
1630
2073
  entry.lastError = null;
@@ -1637,11 +2080,24 @@ export function markSessionCancelled(id) {
1637
2080
  entry.lastProgressAt = doneTs;
1638
2081
  entry.updatedAt = doneTs;
1639
2082
  deleteHeartbeat(id);
2083
+ _unlinkParentAbortListener(entry);
1640
2084
  }
1641
2085
  export function getSessionRuntime(id) {
1642
2086
  return id ? (_runtimeState.get(id) || null) : null;
1643
2087
  }
1644
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
+
1645
2101
  export function getSessionProgressSnapshot(sessionId) {
1646
2102
  const entry = _runtimeState.get(sessionId);
1647
2103
  if (!entry) return null;
@@ -1681,29 +2137,126 @@ export function forEachSessionRuntime() {
1681
2137
  }
1682
2138
 
1683
2139
  // --- Incremental metric persistence (fix A) ---
1684
- // Per-session idempotency tracking: sessionId → Set of seen iterationIndex keys.
2140
+ // Per-session idempotency tracking: sessionId → Set of seen turn:epoch:iteration:source keys.
1685
2141
  const _metricSeenIter = new Map();
1686
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
+
1687
2235
  /**
1688
2236
  * Persist incremental usage delta immediately after each provider.send iteration.
1689
- * Idempotency key `sessionId:iterationIndex` ensures a retry of the same iteration
1690
- * 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.
1691
2239
  */
1692
2240
  export async function persistIterationMetrics(delta) {
1693
2241
  if (!delta || !delta.sessionId) return;
1694
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
+ }
1695
2250
  let seen = _metricSeenIter.get(sessionId);
1696
2251
  if (!seen) {
1697
2252
  seen = new Set();
1698
2253
  _metricSeenIter.set(sessionId, seen);
1699
2254
  }
1700
- const ikey = `${sessionId}:${iterationIndex}`;
2255
+ const ikey = usageMetricsIdempotencyKey(sessionId, session, delta);
1701
2256
  const isReplay = seen.has(ikey);
1702
2257
  seen.add(ikey);
1703
- const runtimeEntry = _runtimeState.get(sessionId);
1704
- const session = runtimeEntry?.session ?? loadSession(sessionId);
1705
- if (!session || session.closed) return;
1706
2258
  if (!isReplay) {
2259
+ if (runtimeEntry) runtimeEntry.usageMetricsTurnIncremental = true;
1707
2260
  session.totalInputTokens = (session.totalInputTokens || 0) + (deltaInput || 0);
1708
2261
  session.totalOutputTokens = (session.totalOutputTokens || 0) + (deltaOutput || 0);
1709
2262
  session.tokensCumulative = (session.tokensCumulative || 0) + (deltaInput || 0) + (deltaOutput || 0);
@@ -1713,7 +2266,7 @@ export async function persistIterationMetrics(delta) {
1713
2266
  // includes cached_read / cache_write in its terminal usage rollup).
1714
2267
  session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (deltaCachedRead || 0);
1715
2268
  session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (deltaCacheWrite || 0);
1716
- // Window snapshot updated per iteration so bridge type=list reflects the
2269
+ // Window snapshot updated per iteration so agent type=list reflects the
1717
2270
  // most-recent provider-reported input size even for short dispatches
1718
2271
  // that finish before askSession's terminal save lands.
1719
2272
  session.lastInputTokens = deltaInput || 0;
@@ -1739,6 +2292,14 @@ export async function persistIterationMetrics(delta) {
1739
2292
 
1740
2293
  function standaloneStatusRouteInfo(session) {
1741
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;
1742
2303
  return {
1743
2304
  provider: session.provider,
1744
2305
  model: session.model,
@@ -1748,7 +2309,7 @@ function standaloneStatusRouteInfo(session) {
1748
2309
  contextWindow: session.contextWindow || null,
1749
2310
  rawContextWindow: session.rawContextWindow || session.contextWindow || null,
1750
2311
  effectiveContextWindowPercent: session.effectiveContextWindowPercent || null,
1751
- autoCompactTokenLimit: session.autoCompactTokenLimit || session.compactBoundaryTokens || null,
2312
+ autoCompactTokenLimit: explicitAutoCompactTokenLimit,
1752
2313
  presetId: session.presetId || null,
1753
2314
  presetName: session.presetName || null,
1754
2315
  };
@@ -1763,9 +2324,17 @@ function recordStandaloneStatusTelemetry(session, result, durationMs) {
1763
2324
  model: result.model,
1764
2325
  serviceTier: result.serviceTier,
1765
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 };
1766
2335
  try {
1767
2336
  const summary = {
1768
- ...summarizeGatewayUsage(routeInfo, providerOut, result.compact || null, durationMs),
2337
+ ...summarizeGatewayUsage(routeInfo, providerOut, _compactArg, durationMs),
1769
2338
  requestKind: 'chat',
1770
2339
  sessionId: session.id || null,
1771
2340
  toolCount: result.toolCallsTotal ?? null,
@@ -1828,6 +2397,7 @@ export function getSessionLastProgressAt(sessionId) {
1828
2397
  const entry = _runtimeState.get(sessionId);
1829
2398
  if (!entry) return 0;
1830
2399
  return Math.max(
2400
+ entry.lastProgressAt || 0,
1831
2401
  entry.lastStreamDeltaAt || 0,
1832
2402
  entry.toolStartedAt || 0,
1833
2403
  entry.askStartedAt || 0,
@@ -1836,8 +2406,8 @@ export function getSessionLastProgressAt(sessionId) {
1836
2406
 
1837
2407
  /**
1838
2408
  * Link a parent AbortSignal to a sub-session's controller so that aborting
1839
- * the parent (fan-out deadline or caller ESC) tears down the bridge role's
1840
- * 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
1841
2411
  * askSession completes. No-op if the session runtime isn't found.
1842
2412
  *
1843
2413
  * @param {string} sessionId — the sub-session to abort
@@ -1854,15 +2424,27 @@ export function linkParentSignalToSession(sessionId, parentSignal) {
1854
2424
  return new Error('parent signal aborted');
1855
2425
  };
1856
2426
  if (parentSignal.aborted) {
2427
+ _unlinkParentAbortListener(entry);
1857
2428
  try { entry.controller.abort(abortReason()); } catch { /* ignore */ }
1858
2429
  return;
1859
2430
  }
1860
- parentSignal.addEventListener('abort', () => {
2431
+ _unlinkParentAbortListener(entry);
2432
+ const onParentAbort = () => {
1861
2433
  try { entry.controller?.abort(abortReason()); } catch { /* ignore */ }
1862
- }, { 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;
1863
2443
  }
1864
2444
  function _clearSessionRuntime(id) {
1865
2445
  if (id) {
2446
+ _stopToolActivityHeartbeat(id);
2447
+ _unlinkParentAbortListener(_runtimeState.get(id));
1866
2448
  _runtimeState.delete(id);
1867
2449
  // R15: also drop the per-session metric-idempotency Set; otherwise it
1868
2450
  // grows O(sessions x iterations) for the whole server lifetime since
@@ -1914,8 +2496,8 @@ export async function _api_call_with_interrupt(sessionId, fn) {
1914
2496
 
1915
2497
  // Per-session mutex: queues concurrent askSession calls to prevent message loss
1916
2498
  const _sessionLocks = new Map();
1917
- // Per-session pending-message queue (Claude Code `pendingMessages` pattern).
1918
- // 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
1919
2501
  // message here instead of rejecting; askSession drains the queue after each
1920
2502
  // turn and runs the messages as the next user turn(s), preserving order — the
1921
2503
  // queued send runs AFTER the in-flight prompt, which also closes the spawn
@@ -1928,11 +2510,16 @@ const _sessionLocks = new Map();
1928
2510
  // Keeping the queue outside the session JSON avoids racing session saves that
1929
2511
  // loaded before the send arrived.
1930
2512
  //
1931
- // Map<sessionId, string[]>. Shared with index.mjs's bridge send handler via
1932
- // 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.
1933
2518
  const _sessionPendingMessages = new Map();
1934
2519
  const PENDING_MESSAGES_FILE = 'session-pending-messages.json';
1935
2520
  const PENDING_MESSAGES_MODE = 0o600;
2521
+ const _pendingPersistBuffers = new Map();
2522
+ let _pendingPersistImmediate = null;
1936
2523
 
1937
2524
  function pendingMessagesPath() {
1938
2525
  return join(resolvePluginData(), PENDING_MESSAGES_FILE);
@@ -1961,14 +2548,53 @@ function normalizePendingStore(raw) {
1961
2548
  return out;
1962
2549
  }
1963
2550
 
1964
- 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) {
1965
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;
1966
2592
  let depth = 0;
1967
2593
  try {
1968
2594
  updateJsonAtomicSync(pendingMessagesPath(), (raw) => {
1969
2595
  const next = normalizePendingStore(raw);
1970
2596
  const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
1971
- q.push(message);
2597
+ q.push(...persistedMessages);
1972
2598
  next.sessions[sessionId] = q;
1973
2599
  next.updatedAt = Date.now();
1974
2600
  depth = q.length;
@@ -1980,6 +2606,43 @@ function persistPendingMessage(sessionId, message) {
1980
2606
  return depth;
1981
2607
  }
1982
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
+
1983
2646
  function drainPersistedPendingMessages(sessionId) {
1984
2647
  if (!isValidPendingSessionId(sessionId)) return [];
1985
2648
  let drained = [];
@@ -2000,25 +2663,33 @@ function drainPersistedPendingMessages(sessionId) {
2000
2663
  }
2001
2664
 
2002
2665
  export function enqueuePendingMessage(sessionId, message) {
2003
- if (!sessionId || typeof message !== 'string' || !message) return 0;
2666
+ const entry = pendingMessageQueueEntry(message);
2667
+ if (!sessionId || !entry) return 0;
2004
2668
  let q = _sessionPendingMessages.get(sessionId);
2005
2669
  if (!q) { q = []; _sessionPendingMessages.set(sessionId, q); }
2006
- q.push(message);
2007
- const persistedDepth = persistPendingMessage(sessionId, message);
2008
- 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);
2009
2673
  }
2010
2674
  export function drainPendingMessages(sessionId) {
2011
2675
  const q = _sessionPendingMessages.get(sessionId);
2012
2676
  const memory = q && q.length > 0 ? q.slice() : [];
2013
2677
  _sessionPendingMessages.delete(sessionId);
2014
- const persisted = drainPersistedPendingMessages(sessionId);
2015
- if (memory.length === 0) return persisted;
2016
- if (persisted.length === 0) return memory;
2017
- const prefixMatches = memory.every((m, i) => persisted[i] === m);
2018
- if (prefixMatches) return [...memory, ...persisted.slice(memory.length)];
2019
- const out = persisted.slice();
2020
- for (const m of memory) {
2021
- 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);
2022
2693
  }
2023
2694
  return out;
2024
2695
  }
@@ -2036,6 +2707,10 @@ function promptContentText(content) {
2036
2707
  return String(content ?? '');
2037
2708
  }
2038
2709
 
2710
+ function hasModelVisiblePromptContent(prompt) {
2711
+ return !!promptContentText(prompt).trim();
2712
+ }
2713
+
2039
2714
  function promptContentBytes(content) {
2040
2715
  try {
2041
2716
  if (typeof content === 'string') return Buffer.byteLength(content, 'utf8');
@@ -2052,6 +2727,190 @@ function prefixUserTurnContent(content, contextBlock) {
2052
2727
  }
2053
2728
  return `${contextBlock}# Task\n${content}`;
2054
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
+
2055
2914
  function acquireSessionLock(sessionId) {
2056
2915
  let entry = _sessionLocks.get(sessionId);
2057
2916
  if (!entry) {
@@ -2071,19 +2930,114 @@ function acquireSessionLock(sessionId) {
2071
2930
  });
2072
2931
  }
2073
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
+
2074
3028
  export async function askSession(sessionId, prompt, context, onToolCall, cwdOverride, explicitPrefetch, askOpts = {}) {
2075
3029
  const _askStartedAt = Date.now();
2076
3030
  const _promptSrc = 'prompt';
2077
3031
  const _prefetchFiles = (explicitPrefetch?.files?.length) || 0;
2078
3032
  const _prefetchCallers = (explicitPrefetch?.callers?.length) || 0;
2079
3033
  const _prefetchRefs = (explicitPrefetch?.references?.length) || 0;
2080
- if (process.env.MIXDOG_DEBUG_BRIDGE) {
2081
- 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`);
2082
3036
  }
2083
3037
  const unlock = await acquireSessionLock(sessionId);
2084
3038
  const _lockWaitedMs = Date.now() - _askStartedAt;
2085
- if (process.env.MIXDOG_DEBUG_BRIDGE) {
2086
- 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`);
2087
3041
  }
2088
3042
  // The mutex is held for the WHOLE askSession call, including any follow-up
2089
3043
  // turns drained from the pending-message queue below — the single outer
@@ -2092,18 +3046,19 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2092
3046
  // result, mirroring how a live chat returns the latest turn).
2093
3047
  let _result;
2094
3048
  // Local FIFO of follow-up prompts drained from the pending-message queue
2095
- // after each turn — keeps queued `bridge type=send` messages in order.
3049
+ // after each turn — keeps queued `agent type=send` messages in order.
2096
3050
  const _pendingTail = [];
2097
3051
  // Hoisted so the outer finally (which runs once after the whole turn loop)
2098
3052
  // can compare against the last turn's generation.
2099
3053
  let askGeneration = 0;
2100
3054
  try {
2101
3055
  // Turn loop (pendingMessages pattern): run the current prompt, then drain
2102
- // 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
2103
3057
  // flight and run them — in order — as the next user turn(s). Because the
2104
3058
  // queued send always lands AFTER the in-flight prompt here, ordering is
2105
3059
  // preserved and the spawn/connecting startup race disappears.
2106
3060
  for (;;) {
3061
+ let _pwstTurnDrained = null;
2107
3062
  // After the first turn, the next prompt comes from the drained queue.
2108
3063
  // (On the first iteration _pendingTail is empty and `prompt` is the
2109
3064
  // caller's original message.)
@@ -2113,6 +3068,23 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2113
3068
  // prefetch is re-applied (those belonged to the original ask).
2114
3069
  context = null;
2115
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;
2116
3088
  }
2117
3089
  // ── Synchronous pre-await setup (must happen before any await so
2118
3090
  // closeSession() can't interleave between load and registration) ──
@@ -2123,6 +3095,11 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2123
3095
  if (preSession.closed === true) {
2124
3096
  throw new SessionClosedError(sessionId, 'session already closed');
2125
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);
2126
3103
  askGeneration = typeof preSession.generation === 'number' ? preSession.generation : 0;
2127
3104
  const runtime = _touchRuntime(sessionId);
2128
3105
  // Fresh controller per ask — the previous ask's controller may have aborted.
@@ -2136,6 +3113,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2136
3113
  // leaving stage='connecting' forever.
2137
3114
  let activeSession = preSession;
2138
3115
  let cancelledUserTurnContent = '';
3116
+ let _turnOutgoing = null;
2139
3117
  try {
2140
3118
  const session = activeSession;
2141
3119
  const provider = getProvider(session.provider);
@@ -2154,6 +3132,8 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2154
3132
  ...(session.compaction || {}),
2155
3133
  auto: session.compaction?.auto !== false,
2156
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),
2157
3137
  boundaryTokens: contextMeta.compactBoundaryTokens,
2158
3138
  bufferTokens: positiveContextWindow(session.compaction?.bufferTokens ?? session.compaction?.buffer) || session.compaction?.bufferTokens || null,
2159
3139
  keepTokens: positiveContextWindow(session.compaction?.keepTokens ?? session.compaction?.keep?.tokens) || session.compaction?.keepTokens || null,
@@ -2190,7 +3170,8 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2190
3170
  if (explicitPrefetchResult) {
2191
3171
  _contextBlock += `# Prefetch\n${_capCtx(explicitPrefetchResult)}\n\n`;
2192
3172
  }
2193
- const beforeCount = session.messages.length + 1;
3173
+ const historyMessages = sanitizeSessionMessagesForModel(session.messages);
3174
+ const beforeCount = historyMessages.length + 1;
2194
3175
  const promptTextForMetrics = promptContentText(prompt);
2195
3176
  // Soft warning only; real size management (compaction primary,
2196
3177
  // byte-budget trim as safety net) lives in agentLoop. Selecting a
@@ -2201,21 +3182,43 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2201
3182
  process.stderr.write(`[session] Warning: prompt is very large (est. ${Math.round(promptTokenEstimate)} tokens vs ${softBudget} soft budget)\n`);
2202
3183
  }
2203
3184
  const effectiveCwd = cwdOverride || session.cwd;
2204
- 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
+ }
2205
3200
  cancelledUserTurnContent = _userTurnContent;
2206
- 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;
2207
3210
  // Per-turn injected-context trace row (complements kind:"usage").
2208
3211
  // Cheap byte-length accounting — no hashing, no payload bodies.
2209
- // Honors the same MIXDOG_BRIDGE_TRACE_DISABLE gate as usage rows;
2210
- // 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.
2211
3214
  try {
2212
3215
  const _ctxBytes = Buffer.byteLength(context || '', 'utf8');
2213
3216
  const _prefetchBytes = Buffer.byteLength(explicitPrefetchResult || '', 'utf8');
2214
3217
  const _promptBytes = promptContentBytes(prompt);
2215
3218
  const _userTurnBytes = promptContentBytes(_userTurnContent);
2216
- const _messagesBytes = Buffer.byteLength(JSON.stringify(session.messages || []), 'utf8');
3219
+ const _messagesBytes = Buffer.byteLength(JSON.stringify(historyMessages || []), 'utf8');
2217
3220
  const _totalBytes = _userTurnBytes + _messagesBytes;
2218
- appendBridgeTrace({
3221
+ appendAgentTrace({
2219
3222
  kind: 'context',
2220
3223
  sessionId,
2221
3224
  model: session.model,
@@ -2227,26 +3230,36 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2227
3230
  promptBytes: _promptBytes,
2228
3231
  userTurnBytes: _userTurnBytes,
2229
3232
  messagesBytes: _messagesBytes,
2230
- messagesCount: Array.isArray(session.messages) ? session.messages.length : 0,
3233
+ messagesCount: historyMessages.length,
2231
3234
  },
2232
3235
  });
2233
3236
  } catch { /* trace must never break the ask path */ }
2234
- 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) =>
2235
3245
  agentLoop(provider, outgoing, session.model, session.tools, onToolCall, effectiveCwd, {
2236
3246
  effort: session.effort || null,
2237
3247
  fast: session.fast === true,
2238
3248
  sessionId,
2239
3249
  onTextDelta: typeof askOpts?.onTextDelta === 'function' ? askOpts.onTextDelta : undefined,
2240
3250
  onReasoningDelta: typeof askOpts?.onReasoningDelta === 'function' ? askOpts.onReasoningDelta : undefined,
3251
+ onAssistantText: typeof askOpts?.onAssistantText === 'function' ? askOpts.onAssistantText : undefined,
2241
3252
  onUsageDelta: (d) => {
2242
3253
  persistIterationMetrics(d).catch(() => {});
2243
3254
  try { askOpts?.onUsageDelta?.(d); } catch {}
2244
3255
  },
2245
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,
2246
3259
  // Mid-turn steering drain. agentLoop calls this at every
2247
3260
  // tool-batch boundary (before the next provider.send) and
2248
3261
  // injects any returned strings as user turns — so input
2249
- // (user typing, `bridge type=send`) that arrives WHILE a
3262
+ // (user typing, `agent type=send`) that arrives WHILE a
2250
3263
  // long multi-tool turn is in flight is picked up on the
2251
3264
  // model's very next iteration instead of waiting for the
2252
3265
  // whole task to finish. The post-turn _pendingTail drain
@@ -2265,6 +3278,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2265
3278
  return out;
2266
3279
  },
2267
3280
  onSteerMessage: typeof askOpts?.onSteerMessage === 'function' ? askOpts.onSteerMessage : undefined,
3281
+ notifyFn: typeof askOpts?.notifyFn === 'function' ? askOpts.notifyFn : undefined,
2268
3282
  promptCacheKey: session.promptCacheKey || sessionId,
2269
3283
  // Provider-scoped cache key (mixdog-codex, mixdog-claude…).
2270
3284
  // Distinct from sessionId — providers that pool sockets
@@ -2276,7 +3290,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2276
3290
  signal,
2277
3291
  providerState: session.providerState ?? undefined,
2278
3292
  session,
2279
- // Smart Bridge cache settings — merged last so session overrides
3293
+ // Agent Runtime cache settings — merged last so session overrides
2280
3294
  // don't get overridden by defaults. When session has no profile,
2281
3295
  // providerCacheOpts is null and this spread is a no-op.
2282
3296
  ...(session.providerCacheOpts || {}),
@@ -2290,6 +3304,13 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2290
3304
  },
2291
3305
  }),
2292
3306
  );
3307
+ } finally {
3308
+ if (priorToolApprovalHook === undefined) {
3309
+ delete session.toolApprovalHook;
3310
+ } else {
3311
+ session.toolApprovalHook = priorToolApprovalHook;
3312
+ }
3313
+ }
2293
3314
  // Post-loop validation: if closeSession() landed while we were awaiting,
2294
3315
  // drop the save so the tombstone on disk isn't overwritten.
2295
3316
  const currentRuntime = _runtimeState.get(sessionId);
@@ -2300,10 +3321,16 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2300
3321
  // Update and save. outgoing is mutated in place by agentLoop
2301
3322
  // (compaction + safety trim), so its length reflects post-loop state.
2302
3323
  const messagesDropped = Math.max(0, beforeCount - outgoing.length);
2303
- 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;
2304
3328
  if (result.content || result.reasoningContent) {
2305
3329
  session.messages.push({
2306
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).
2307
3334
  content: result.content || '',
2308
3335
  ...(typeof result.reasoningContent === 'string' && result.reasoningContent
2309
3336
  ? { reasoningContent: result.reasoningContent }
@@ -2353,46 +3380,17 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2353
3380
  }
2354
3381
  session.updatedAt = Date.now();
2355
3382
  session.lastUsedAt = Date.now();
2356
- if (result.usage) {
2357
- session.totalInputTokens += result.usage.inputTokens;
2358
- session.totalOutputTokens += result.usage.outputTokens;
2359
- session.tokensCumulative = (session.tokensCumulative || 0)
2360
- + (result.usage.inputTokens || 0)
2361
- + (result.usage.outputTokens || 0);
2362
- // Cache totals — same `||0` undefined-safe accumulation pattern as
2363
- // persistIterationMetrics so live + terminal paths stay in lock-step
2364
- // and legacy sessions migrate lazily on first iteration.
2365
- session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (result.usage.cachedTokens || 0);
2366
- session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (result.usage.cacheWriteTokens || 0);
2367
- // Window snapshot = the current context size, which is the LAST
2368
- // single call — NOT result.usage (that is lastUsage, the per-turn
2369
- // SUM accumulated with += across iterations in agentLoop). Use
2370
- // lastTurnUsage (the final iteration's raw usage) so this reflects
2371
- // "what's in the window now" rather than the lifetime sum.
2372
- const _lastTurn = result.lastTurnUsage || result.usage || {};
2373
- session.lastInputTokens = _lastTurn.inputTokens || 0;
2374
- session.lastOutputTokens = _lastTurn.outputTokens || 0;
2375
- session.lastCachedReadTokens = _lastTurn.cachedTokens || 0;
2376
- session.lastCacheWriteTokens = _lastTurn.cacheWriteTokens || 0;
2377
- // Provider-normalized footprint, identical formula to
2378
- // persistIterationMetrics so both writers agree: Anthropic
2379
- // input_tokens excludes cache (add it back), openai/grok/gemini
2380
- // already include it.
2381
- const _inputExcludesCache = providerInputExcludesCache(session.provider);
2382
- session.lastContextTokens = _inputExcludesCache
2383
- ? (_lastTurn.inputTokens || 0) + (_lastTurn.cachedTokens || 0)
2384
- : (_lastTurn.inputTokens || 0);
2385
- session.lastContextTokensUpdatedAt = Date.now();
2386
- session.lastContextTokensStaleAfterCompact = false;
2387
- }
2388
- // Smart Bridge cache stats — record hit/miss after every successful
2389
- // ask so the registry reflects all bridge traffic, not just
2390
- // 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
2391
3389
  // metric recording never breaks the ask itself.
2392
3390
  let prefixHashForLog = null;
2393
- if (session.profileId && result.usage && _smartBridgeApi) {
3391
+ if (session.profileId && result.usage && _agentRuntimeApi) {
2394
3392
  try {
2395
- const profile = _smartBridgeApi.getProfile(session.profileId);
3393
+ const profile = _agentRuntimeApi.getProfile(session.profileId);
2396
3394
  if (profile) {
2397
3395
  // Collect every leading system-role message (BP1, BP2, ...)
2398
3396
  // until the first non-system message so the registry hash
@@ -2402,24 +3400,24 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2402
3400
  if (m?.role !== 'system') break;
2403
3401
  systemMsgs.push(typeof m.content === 'string' ? m.content : '');
2404
3402
  }
2405
- _smartBridgeApi.recordCall(profile, session.provider, {
3403
+ _agentRuntimeApi.recordCall(profile, session.provider, {
2406
3404
  systemPrompt: systemMsgs,
2407
3405
  tools: session.tools || [],
2408
3406
  usage: result.usage,
2409
3407
  });
2410
- const entry = _smartBridgeApi.registry?.data?.profiles?.[session.profileId]?.[session.provider];
3408
+ const entry = _agentRuntimeApi.registry?.data?.profiles?.[session.profileId]?.[session.provider];
2411
3409
  prefixHashForLog = entry?.prefixHash || null;
2412
3410
  }
2413
3411
  } catch {}
2414
3412
  }
2415
- // Append to bridge-trace.jsonl with the rich bridge usage fields.
3413
+ // Append to the agent trace store with rich usage fields.
2416
3414
  if (result.usage) {
2417
3415
  const inputTokens = result.usage.inputTokens || 0;
2418
3416
  const outputTokens = result.usage.outputTokens || 0;
2419
3417
  const cacheReadTokens = result.usage.cachedTokens || 0;
2420
3418
  const cacheWriteTokens = result.usage.cacheWriteTokens || 0;
2421
3419
  // Unified total-prompt field. Anthropic = input+cache_read+cache_write
2422
- // (additive); OpenAI/Codex/Gemini = input_tokens already includes the
3420
+ // (additive); OpenAI OAuth/API/Gemini = input_tokens already includes the
2423
3421
  // cached portion (inclusive), so the fallback must not double-count.
2424
3422
  const { isInclusiveProvider, computeCostUsd } = await import('../../../shared/llm/cost.mjs');
2425
3423
  const inclusive = isInclusiveProvider(session.provider);
@@ -2459,29 +3457,61 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2459
3457
  recordStandaloneStatusTelemetry(session, result, Date.now() - _askStartedAt);
2460
3458
  }
2461
3459
  // Persist opaque providerState for future stateful providers.
2462
- // No provider currently emits it (Codex OAuth is stateless per
3460
+ // No provider currently emits it (openai-oauth is stateless per
2463
3461
  // contract), so this branch is dormant — kept so a future
2464
3462
  // Responses-API provider with stable continuation can plug in
2465
3463
  // without reworking the session shape.
2466
3464
  if (result.providerState !== undefined) {
2467
3465
  session.providerState = result.providerState;
2468
3466
  }
2469
- const postTurnCompact = await autoCompactSessionAfterTurn(session, {
2470
- provider,
2471
- model: session.model,
2472
- sessionId,
2473
- signal: getSessionAbortSignal(sessionId),
2474
- });
2475
- 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') {
2476
3474
  try {
2477
- process.stderr.write(
2478
- `[session] auto compacted after turn sessionId=${sessionId} ` +
2479
- `${postTurnCompact.beforeTokens}->${postTurnCompact.afterTokens} ` +
2480
- `pressure=${postTurnCompact.pressureTokens} trigger=${postTurnCompact.triggerTokens}\n`,
2481
- );
2482
- } catch { /* best-effort */ }
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
+ });
3499
+ try {
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
+ }
2483
3514
  }
2484
- await saveSessionAsync(session, { expectedGeneration: askGeneration });
2485
3515
  activeSession = session;
2486
3516
  runtime.session = session;
2487
3517
  // Tag empty-synthesis BEFORE markSessionDone so the watchdog
@@ -2492,47 +3522,63 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2492
3522
  markSessionEmptyFinal(sessionId);
2493
3523
  }
2494
3524
  markSessionDone(sessionId, { empty: isEmptyFinal });
2495
- _result = {
2496
- ...result,
2497
- trimmed: messagesDropped > 0,
2498
- messagesDropped,
2499
- postTurnCompact: postTurnCompact?.changed ? postTurnCompact : null,
2500
- };
3525
+ _result = terminalResultPreview;
2501
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;
2502
3531
  if (err instanceof SessionClosedError) {
2503
3532
  const currentRuntime = _runtimeState.get(sessionId);
2504
- if (!currentRuntime?.closed && activeSession && cancelledUserTurnContent) {
2505
- activeSession.messages = [
2506
- ...(Array.isArray(activeSession.messages) ? activeSession.messages : []),
2507
- { role: 'user', content: cancelledUserTurnContent },
2508
- {
2509
- role: 'assistant',
2510
- content: '[cancelled] This turn was interrupted before completion. Preserve the user request above as the active task context if the user asks to continue.',
2511
- cancelled: true,
2512
- ts: Date.now(),
2513
- },
2514
- ];
2515
- activeSession.updatedAt = Date.now();
2516
- activeSession.lastUsedAt = Date.now();
2517
- try {
2518
- await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
2519
- } 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
+ }
2520
3558
  markSessionCancelled(sessionId);
2521
3559
  }
2522
3560
  // Cancellation is not an error; propagate silently so callers
2523
3561
  // can render it as "cancelled" rather than a red failure.
2524
3562
  throw err;
2525
3563
  }
3564
+ await persistCompactedOutgoingAfterAskFailure({
3565
+ sessionId,
3566
+ activeSession,
3567
+ askGeneration,
3568
+ turnOutgoing: _turnOutgoing,
3569
+ error: err,
3570
+ });
2526
3571
  markSessionError(sessionId, err && err.message ? err.message : String(err));
2527
3572
  throw err;
2528
3573
  }
2529
- // ── Turn complete. Drain the pending-message queue (Claude Code
2530
- // 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
2531
3575
  // turn was in flight runs next, in order, as a follow-up user turn.
2532
3576
  // The mutex is still held, so a send racing this drain either landed
2533
3577
  // before (picked up here) or enqueues for the next loop. When the
2534
3578
  // queue is empty we return the latest turn's result. ──
2535
- const _drained = drainPendingMessages(sessionId);
3579
+ const _drained = (_pwstTurnDrained && _pwstTurnDrained.length > 0)
3580
+ ? _pwstTurnDrained
3581
+ : drainPendingMessages(sessionId);
2536
3582
  if (_drained.length > 0) {
2537
3583
  // Same merge rule as the mid-turn steering drain (loop.mjs) and
2538
3584
  // the TUI engine.mjs drain(): a single drain batch is joined with
@@ -2540,11 +3586,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2540
3586
  // Keeps every steering/follow-up path on identical
2541
3587
  // merge-then-deliver semantics. Anything that arrives AFTER this
2542
3588
  // drain enqueues for the next loop pass and is merged there.
2543
- const _mergedTail = _drained
2544
- .filter((m) => typeof m === 'string' && m.length > 0)
2545
- .join('\n');
2546
- if (_mergedTail.length > 0) {
2547
- _pendingTail.push(_mergedTail);
3589
+ const _mergedTail = _mergePendingMessageEntries(_drained);
3590
+ if (_mergedTail?.content) {
3591
+ _pendingTail.push(_mergedTail.content);
2548
3592
  const refreshed = loadSession(sessionId);
2549
3593
  if (refreshed && refreshed.closed !== true) {
2550
3594
  activeSession = refreshed;
@@ -2553,14 +3597,16 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2553
3597
  continue;
2554
3598
  }
2555
3599
  }
3600
+ _unlinkParentAbortListener(_runtimeState.get(sessionId));
2556
3601
  return _result;
2557
3602
  }
2558
3603
  } finally {
2559
3604
  // Clear the controller only if it's still ours (closeSession may have
2560
- // 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
2561
3606
  // can still surface the final stage (done/error/cancelling).
2562
3607
  const entry = _runtimeState.get(sessionId);
2563
3608
  if (entry && entry.generation === askGeneration) {
3609
+ _unlinkParentAbortListener(entry);
2564
3610
  entry.controller = null;
2565
3611
  // Detach the live session reference; ask is over.
2566
3612
  entry.session = null;
@@ -2568,16 +3614,17 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
2568
3614
  unlock();
2569
3615
  }
2570
3616
  }
2571
- // 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
2572
3618
  // scope session when the caller passes --scope (agent/<name>).
2573
3619
  export function findSessionByScopeKey(scopeKey) {
2574
3620
  if (!scopeKey) return null;
2575
- const sessions = listStoredSessions();
3621
+ const summaries = listStoredSessionSummaries();
2576
3622
  // Exclude tombstoned sessions (`closed === true`) so callers never receive
2577
3623
  // a session whose controller was aborted by closeSession(). The `closed`
2578
3624
  // bit is the authoritative tombstone flag; `status === 'error'` is not,
2579
3625
  // since transient-error sessions remain resumable.
2580
- return sessions.find(s => s.scopeKey === scopeKey && s.closed !== true) || null;
3626
+ const summary = summaries.find(s => s.scopeKey === scopeKey && s.closed !== true) || null;
3627
+ return summary?.id ? loadSession(summary.id) : null;
2581
3628
  }
2582
3629
  // --- resume (reload tools for a stored session) ---
2583
3630
  export async function resumeSession(sessionId, preset) {
@@ -2589,20 +3636,38 @@ export async function resumeSession(sessionId, preset) {
2589
3636
  // than silently dropping the tool-refresh side effects.
2590
3637
  if (session.closed === true) return null;
2591
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);
2592
3647
  // Refresh tools (MCP connections may have changed).
2593
3648
  // Re-resolve from profile.tools when the session stored a profileId —
2594
3649
  // otherwise fall back to preset.tools. Same resolution order as
2595
3650
  // createSession so resume and spawn produce identical BP_1 shapes.
2596
3651
  const oldTools = session.tools || [];
2597
- const skills = collectSkillsCached(session.cwd);
2598
- let toolSpec = preset || session.preset || 'full';
2599
- 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) {
2600
3656
  try {
2601
- const profile = _smartBridgeApi.getProfile(session.profileId);
2602
- 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;
2603
3659
  } catch { /* ignore lookup failures, keep preset fallback */ }
2604
3660
  }
2605
- 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
+ });
2606
3671
  const newTools = session.tools;
2607
3672
  const missing = oldTools.filter(t => !newTools.find(n => n.name === t.name));
2608
3673
  if (missing.length) {
@@ -2617,47 +3682,92 @@ export function getSession(id) {
2617
3682
  }
2618
3683
  export function listSessions(opts = {}) {
2619
3684
  const includeClosed = opts.includeClosed === true;
2620
- const sessions = listStoredSessions();
3685
+ const sessions = listStoredSessionSummaries();
2621
3686
  const hiddenIds = new Set([..._runtimeState.entries()].filter(([, e]) => e.listHidden).map(([id]) => id));
2622
3687
  // Tombstoned sessions (closed===true) are excluded unless the caller opts in
2623
- // (e.g. bridge list includeClosed:true).
3688
+ // (e.g. agent list includeClosed:true).
2624
3689
  return sessions.filter(s => !hiddenIds.has(s.id) && (includeClosed || s.closed !== true));
2625
3690
  }
2626
3691
  // --- Clear messages (keep system prompt + provider/model/cwd) ---
2627
- export async function clearSessionMessages(sessionId) {
3692
+ export async function clearSessionMessages(sessionId, options = {}) {
2628
3693
  const session = loadSession(sessionId);
2629
3694
  if (!session)
2630
3695
  return false;
2631
3696
  // Don't resurrect a closed session just to clear its messages.
2632
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() !== '';
2633
3701
  const keep = [];
2634
- const messages = Array.isArray(session.messages) ? session.messages : [];
3702
+ let messages = Array.isArray(session.messages) ? session.messages : [];
2635
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;
2636
3750
  for (let i = 0; i < messages.length; i += 1) {
2637
3751
  const m = messages[i];
2638
3752
  if (!m) continue;
2639
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.
2640
3758
  keep.push(m);
2641
3759
  continue;
2642
3760
  }
2643
- const stableContext =
2644
- m.role === 'user'
3761
+ if (preserveCompactSummary
3762
+ && m.role === 'user'
2645
3763
  && typeof m.content === 'string'
2646
- && m.content.startsWith('<system-reminder>')
2647
- && m.content.includes('<!-- bp3-sentinel -->');
2648
- if (stableContext) {
3764
+ && m.content.startsWith(SUMMARY_PREFIX)) {
2649
3765
  keep.push(m);
2650
- const next = messages[i + 1];
2651
- if (next?.role === 'assistant' && String(next.content || '').trim() === '.') {
2652
- keep.push(next);
2653
- i += 1;
2654
- }
2655
3766
  }
2656
3767
  }
2657
3768
  const afterMessageTokens = estimateMessagesTokens(keep);
2658
- const reserveTokens = estimateRequestReserveTokens(session.tools || []);
2659
- const beforeTokens = Math.max(beforeMessageTokens + reserveTokens, positiveContextWindow(session.lastContextTokens) || 0);
2660
- const afterTokens = afterMessageTokens + reserveTokens;
3769
+ const beforeTokens = estimateTranscriptContextUsage(messages, session.tools || []);
3770
+ const afterTokens = estimateTranscriptContextUsage(keep, session.tools || []);
2661
3771
  const now = Date.now();
2662
3772
  session.messages = keep;
2663
3773
  session.totalInputTokens = 0;
@@ -2687,6 +3797,8 @@ export async function clearSessionMessages(sessionId) {
2687
3797
  lastClearAfterTokens: afterTokens,
2688
3798
  lastClearBeforeMessageTokens: beforeMessageTokens,
2689
3799
  lastClearAfterMessageTokens: afterMessageTokens,
3800
+ lastClearCompactType: clearCompactType || session.compaction?.compactType || null,
3801
+ lastClearCompactError: clearCompactError?.message || null,
2690
3802
  };
2691
3803
  session.updatedAt = now;
2692
3804
  await saveSessionAsync(session, { expectedGeneration: session.generation });
@@ -2696,6 +3808,9 @@ export async function compactSessionMessages(sessionId) {
2696
3808
  const session = loadSession(sessionId);
2697
3809
  if (!session) return null;
2698
3810
  if (session.closed === true) return null;
3811
+ if (isSessionCompactionBlocked(sessionId)) {
3812
+ return { changed: false, reason: 'compact skipped: turn in progress' };
3813
+ }
2699
3814
  const result = await runSessionCompaction(session, {
2700
3815
  mode: 'manual',
2701
3816
  force: true,
@@ -2723,7 +3838,7 @@ export async function updateSessionStatus(id, status) {
2723
3838
  const session = loadSession(id);
2724
3839
  if (!session) return false;
2725
3840
  // Respect tombstones — don't resurrect a closed session just to update a
2726
- // status label (bridge handler emits running→idle/error around askSession).
3841
+ // status label (agent handler emits running→idle/error around askSession).
2727
3842
  if (session.closed === true) return false;
2728
3843
  session.status = status;
2729
3844
  session.updatedAt = Date.now();
@@ -2746,6 +3861,7 @@ export async function updateSessionStatus(id, status) {
2746
3861
  */
2747
3862
  export function closeSession(id, reason = 'manual') {
2748
3863
  if (!id) return false;
3864
+ _stopToolActivityHeartbeat(id);
2749
3865
  // Prefer in-memory runtime session — allBashSessionIds may not be persisted
2750
3866
  // yet for shells opened in the current turn (BL-bash-disk-sync).
2751
3867
  const inMemory = _runtimeState.get(id)?.session;
@@ -2782,10 +3898,10 @@ export function closeSession(id, reason = 'manual') {
2782
3898
  const durationMs = (typeof askStartedAt === 'number') ? (Date.now() - askStartedAt) : null;
2783
3899
  const parts = [`session=${id}`, `reason=${reason}`];
2784
3900
  if (durationMs != null) parts.push(`duration=${durationMs}ms`);
2785
- 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`);
2786
3902
  } catch { /* best-effort */ }
2787
3903
  for (const bsid of allBashIds) {
2788
- try { closeBashSession(bsid, `bridge-close:${id}`); } catch { /* ignore */ }
3904
+ try { _closeBashSessionLazy(bsid, `agent-close:${id}`); } catch { /* ignore */ }
2789
3905
  }
2790
3906
  // Drop session-scoped read dedup cache so the Map doesn't accumulate
2791
3907
  // entries across mcp-server lifetime.
@@ -2805,6 +3921,7 @@ export function closeSession(id, reason = 'manual') {
2805
3921
  }
2806
3922
  export function abortSessionTurn(id, reason = 'turn-abort') {
2807
3923
  if (!id) return false;
3924
+ _stopToolActivityHeartbeat(id);
2808
3925
  const entry = _runtimeState.get(id);
2809
3926
  if (!entry || entry.closed) return false;
2810
3927
  entry.stage = 'cancelling';
@@ -2818,7 +3935,7 @@ export function abortSessionTurn(id, reason = 'turn-abort') {
2818
3935
 
2819
3936
  // --- Periodic idle session cleanup ---
2820
3937
  const CLEANUP_INTERVAL_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INTERVAL_MS', 5 * 60 * 1000); // check every 5 minutes
2821
- const CLEANUP_INITIAL_DELAY_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INITIAL_DELAY_MS', 60 * 1000);
3938
+ const CLEANUP_INITIAL_DELAY_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_INITIAL_DELAY_MS', CLEANUP_INTERVAL_MS > 0 ? CLEANUP_INTERVAL_MS : 0);
2822
3939
  const CLEANUP_SLOW_LOG_MS = nonNegativeIntEnv('MIXDOG_SESSION_CLEANUP_SLOW_LOG_MS', 250);
2823
3940
  const TOMBSTONE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h — far longer than any realistic ask race window
2824
3941
  let _cleanupTimer = null;
@@ -2861,12 +3978,12 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
2861
3978
  } else {
2862
3979
  _clearSessionRuntime(d.id);
2863
3980
  if (d.bashSessionId) {
2864
- try { closeBashSession(d.bashSessionId, `idle-sweep:${d.id}`); } catch { /* ignore */ }
3981
+ try { _closeBashSessionLazy(d.bashSessionId, `idle-sweep:${d.id}`); } catch { /* ignore */ }
2865
3982
  }
2866
3983
  }
2867
- 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`);
2868
3985
  }
2869
- 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`);
2870
3987
  }
2871
3988
  if (tombstonesCleaned > 0) {
2872
3989
  for (const d of tombstoneDetails) {
@@ -2883,7 +4000,7 @@ function sweepIdleSessions({ includeTombstones = true } = {}) {
2883
4000
  process.stderr.write(`[session-sweep] cleanup took ${elapsed}ms (idle=${cleaned}, tombstones=${tombstonesCleaned}, remaining=${remaining})\n`);
2884
4001
  }
2885
4002
  } catch (e) {
2886
- 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`);
2887
4004
  }
2888
4005
  }
2889
4006
 
@@ -2922,7 +4039,17 @@ export function sweepTombstones() {
2922
4039
  }
2923
4040
  }
2924
4041
 
4042
+ function hasActiveRuntimeWork() {
4043
+ for (const [, entry] of _runtimeState) {
4044
+ if (!entry || entry.closed === true) continue;
4045
+ if (entry.controller && !entry.controller.signal?.aborted) return true;
4046
+ if (['connecting', 'requesting', 'streaming', 'tool_running', 'cancelling'].includes(entry.stage)) return true;
4047
+ }
4048
+ return false;
4049
+ }
4050
+
2925
4051
  function _runCleanupCycle() {
4052
+ if (hasActiveRuntimeWork()) return;
2926
4053
  sweepIdleSessions({ includeTombstones: true });
2927
4054
  }
2928
4055