mixdog 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. package/README.md +47 -23
  2. package/package.json +34 -28
  3. package/scripts/_test-folder-dialog.mjs +30 -0
  4. package/scripts/agent-parallel-smoke.mjs +388 -0
  5. package/scripts/agent-tag-reuse-smoke.mjs +183 -0
  6. package/scripts/background-task-meta-smoke.mjs +38 -0
  7. package/scripts/boot-smoke.mjs +52 -9
  8. package/scripts/build-runtime-linux.sh +348 -0
  9. package/scripts/build-runtime-macos.sh +217 -0
  10. package/scripts/build-runtime-windows.ps1 +242 -0
  11. package/scripts/compact-active-turn-test.mjs +68 -0
  12. package/scripts/compact-smoke.mjs +859 -129
  13. package/scripts/compact-trigger-migration-smoke.mjs +187 -0
  14. package/scripts/fix-brief-fn.mjs +35 -0
  15. package/scripts/fix-format-tool-surface.mjs +24 -0
  16. package/scripts/fix-tool-exec-visible.mjs +42 -0
  17. package/scripts/generate-runtime-manifest.mjs +166 -0
  18. package/scripts/hook-bus-test.mjs +330 -0
  19. package/scripts/lead-workflow-smoke.mjs +33 -39
  20. package/scripts/live-worker-smoke.mjs +43 -37
  21. package/scripts/llm-trace-summary.mjs +315 -0
  22. package/scripts/memory-meta-concurrency-test.mjs +20 -0
  23. package/scripts/output-style-smoke.mjs +56 -15
  24. package/scripts/parent-abort-link-test.mjs +44 -0
  25. package/scripts/patch-agent-brief.mjs +48 -0
  26. package/scripts/patch-app.mjs +21 -0
  27. package/scripts/patch-app2.mjs +18 -0
  28. package/scripts/patch-dist-brief.mjs +96 -0
  29. package/scripts/patch-tool-exec.mjs +70 -0
  30. package/scripts/pretool-ask-runtime-test.mjs +54 -0
  31. package/scripts/provider-toolcall-test.mjs +376 -0
  32. package/scripts/reactive-compact-persist-smoke.mjs +124 -0
  33. package/scripts/sanitize-tool-pairs-test.mjs +260 -0
  34. package/scripts/session-context-bench.mjs +205 -33
  35. package/scripts/session-ingest-smoke.mjs +177 -0
  36. package/scripts/set-effort-config-test.mjs +41 -0
  37. package/scripts/smoke-runtime-negative.ps1 +106 -0
  38. package/scripts/smoke-runtime-negative.sh +97 -0
  39. package/scripts/smoke.mjs +25 -0
  40. package/scripts/tool-result-hook-test.mjs +48 -0
  41. package/scripts/tool-smoke.mjs +1222 -90
  42. package/scripts/toolcall-args-test.mjs +150 -0
  43. package/scripts/tui-background-failure-smoke.mjs +73 -0
  44. package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
  45. package/src/agents/debugger/AGENT.md +8 -0
  46. package/src/agents/explore/AGENT.md +4 -0
  47. package/src/agents/heavy-worker/AGENT.md +9 -3
  48. package/src/agents/maintainer/AGENT.md +4 -0
  49. package/src/agents/reviewer/AGENT.md +8 -0
  50. package/src/agents/scheduler-task/AGENT.md +12 -0
  51. package/src/agents/scheduler-task/agent.json +6 -0
  52. package/src/agents/webhook-handler/AGENT.md +12 -0
  53. package/src/agents/webhook-handler/agent.json +6 -0
  54. package/src/agents/worker/AGENT.md +9 -3
  55. package/src/app.mjs +77 -3
  56. package/src/defaults/hidden-roles.json +17 -12
  57. package/src/headless-role.mjs +117 -0
  58. package/src/help.mjs +30 -0
  59. package/src/hooks/lib/permission-evaluator.cjs +11 -475
  60. package/src/lib/keychain-cjs.cjs +9 -1
  61. package/src/lib/mixdog-debug.cjs +0 -29
  62. package/src/lib/plugin-paths.cjs +1 -7
  63. package/src/lib/rules-builder.cjs +242 -98
  64. package/src/lib/text-utils.cjs +1 -1
  65. package/src/mixdog-session-runtime.mjs +2211 -446
  66. package/src/output-styles/default.md +12 -28
  67. package/src/output-styles/extreme-simple.md +9 -6
  68. package/src/output-styles/simple.md +22 -9
  69. package/src/repl.mjs +108 -59
  70. package/src/rules/agent/00-common.md +15 -0
  71. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  72. package/src/rules/agent/30-explorer.md +22 -0
  73. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  75. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  76. package/src/rules/lead/01-general.md +9 -5
  77. package/src/rules/lead/04-workflow.md +51 -12
  78. package/src/rules/lead/lead-tool.md +6 -0
  79. package/src/rules/shared/01-tool.md +12 -1
  80. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  81. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  82. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  85. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  86. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  87. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  88. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  89. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  90. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  92. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  93. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  94. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  95. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +380 -268
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +131 -93
  97. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  98. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  100. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  101. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  102. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  103. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  104. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  107. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +203 -71
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +512 -312
  109. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -518
  110. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  111. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +289 -11
  113. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  114. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  115. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  116. package/src/runtime/agent/orchestrator/session/loop.mjs +840 -612
  117. package/src/runtime/agent/orchestrator/session/manager.mjs +1574 -459
  118. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  119. package/src/runtime/agent/orchestrator/session/store.mjs +67 -29
  120. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  121. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  122. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  123. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +35 -8
  124. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -8
  125. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -53
  126. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  127. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  130. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  133. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  138. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  139. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  140. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  141. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  142. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  143. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  146. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  147. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  148. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  149. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  150. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  151. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  152. package/src/runtime/channels/index.mjs +149 -51
  153. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  154. package/src/runtime/channels/lib/config.mjs +0 -1
  155. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  156. package/src/runtime/channels/lib/executor.mjs +0 -3
  157. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  158. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  159. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  160. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  161. package/src/runtime/channels/lib/session-discovery.mjs +3 -6
  162. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -12
  164. package/src/runtime/channels/lib/webhook.mjs +1 -1
  165. package/src/runtime/channels/tool-defs.mjs +29 -29
  166. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  167. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  168. package/src/runtime/memory/index.mjs +519 -118
  169. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  170. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  171. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  172. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  173. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  174. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  175. package/src/runtime/memory/lib/memory-cycle1.mjs +28 -7
  176. package/src/runtime/memory/lib/memory-cycle2.mjs +4 -5
  177. package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
  178. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -2
  179. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  180. package/src/runtime/memory/lib/memory.mjs +88 -9
  181. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  182. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  183. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  184. package/src/runtime/memory/lib/runtime-fetcher.mjs +80 -21
  185. package/src/runtime/memory/lib/session-ingest.mjs +196 -0
  186. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  187. package/src/runtime/memory/tool-defs.mjs +46 -37
  188. package/src/runtime/search/index.mjs +104 -473
  189. package/src/runtime/search/lib/config.mjs +0 -4
  190. package/src/runtime/search/lib/state.mjs +1 -15
  191. package/src/runtime/search/lib/web-tools.mjs +44 -25
  192. package/src/runtime/search/tool-defs.mjs +16 -23
  193. package/src/runtime/shared/abort-controller.mjs +1 -1
  194. package/src/runtime/shared/atomic-file.mjs +4 -3
  195. package/src/runtime/shared/background-tasks.mjs +122 -11
  196. package/src/runtime/shared/child-spawn-gate.mjs +139 -0
  197. package/src/runtime/shared/config.mjs +7 -4
  198. package/src/runtime/shared/err-text.mjs +131 -4
  199. package/src/runtime/shared/llm/cost.mjs +2 -2
  200. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  201. package/src/runtime/shared/llm/index.mjs +34 -11
  202. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  203. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  204. package/src/runtime/shared/singleton-owner.mjs +104 -0
  205. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  206. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  207. package/src/runtime/shared/tool-surface.mjs +624 -95
  208. package/src/runtime/shared/user-data-guard.mjs +0 -2
  209. package/src/standalone/agent-task-status.mjs +203 -0
  210. package/src/standalone/agent-task-status.test.mjs +76 -0
  211. package/src/standalone/agent-tool.mjs +1913 -0
  212. package/src/standalone/channel-worker.mjs +370 -14
  213. package/src/standalone/explore-tool.mjs +165 -70
  214. package/src/standalone/folder-dialog.mjs +314 -0
  215. package/src/standalone/hook-bus.mjs +898 -22
  216. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  217. package/src/standalone/projects.mjs +226 -0
  218. package/src/standalone/provider-admin.mjs +41 -24
  219. package/src/standalone/seeds.mjs +1 -78
  220. package/src/standalone/usage-dashboard.mjs +96 -8
  221. package/src/tui/App.jsx +4800 -2140
  222. package/src/tui/components/AnsiText.jsx +39 -28
  223. package/src/tui/components/ContextPanel.jsx +14 -4
  224. package/src/tui/components/Markdown.jsx +43 -77
  225. package/src/tui/components/MarkdownTable.jsx +9 -184
  226. package/src/tui/components/Message.jsx +28 -11
  227. package/src/tui/components/Picker.jsx +95 -56
  228. package/src/tui/components/PromptInput.jsx +428 -239
  229. package/src/tui/components/QueuedCommands.jsx +1 -1
  230. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  231. package/src/tui/components/Spinner.jsx +67 -38
  232. package/src/tui/components/StatusLine.jsx +606 -38
  233. package/src/tui/components/TextEntryPanel.jsx +128 -9
  234. package/src/tui/components/ToolExecution.jsx +597 -362
  235. package/src/tui/components/TurnDone.jsx +3 -3
  236. package/src/tui/components/UsagePanel.jsx +3 -5
  237. package/src/tui/components/tool-output-format.mjs +499 -0
  238. package/src/tui/components/tool-output-format.test.mjs +312 -0
  239. package/src/tui/dist/index.mjs +9192 -2399
  240. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  241. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  242. package/src/tui/engine.mjs +1455 -279
  243. package/src/tui/figures.mjs +21 -40
  244. package/src/tui/index.jsx +75 -31
  245. package/src/tui/input-editing.mjs +25 -0
  246. package/src/tui/markdown/format-token.mjs +671 -69
  247. package/src/tui/markdown/format-token.test.mjs +312 -0
  248. package/src/tui/markdown/render-ansi.mjs +94 -0
  249. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  250. package/src/tui/markdown/stream-fence.mjs +34 -0
  251. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  252. package/src/tui/markdown/table-layout.mjs +250 -0
  253. package/src/tui/paste-attachments.mjs +0 -7
  254. package/src/tui/spinner-verbs.mjs +1 -2
  255. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  256. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  257. package/src/tui/theme.mjs +756 -24
  258. package/src/tui/time-format.mjs +1 -1
  259. package/src/tui/transcript-tool-failures.mjs +67 -0
  260. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  261. package/src/ui/ansi.mjs +1 -2
  262. package/src/ui/markdown.mjs +85 -26
  263. package/src/ui/markdown.test.mjs +70 -0
  264. package/src/ui/model-display.mjs +121 -0
  265. package/src/ui/session-stats.mjs +44 -0
  266. package/src/ui/statusline-context-label.test.mjs +15 -0
  267. package/src/ui/statusline.mjs +386 -178
  268. package/src/ui/tool-card.mjs +2 -13
  269. package/src/vendor/statusline/bin/statusline-lib.mjs +1 -620
  270. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  271. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  272. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  273. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  274. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  275. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  276. package/src/workflows/default/WORKFLOW.md +12 -5
  277. package/src/workflows/default/workflow.json +0 -1
  278. package/src/workflows/solo/WORKFLOW.md +15 -0
  279. package/src/workflows/solo/workflow.json +7 -0
  280. package/vendor/ink/build/ink.js +54 -8
  281. package/vendor/ink/build/output.js +6 -1
  282. package/src/agents/scheduler-task.md +0 -3
  283. package/src/agents/web-researcher/AGENT.md +0 -3
  284. package/src/agents/web-researcher/agent.json +0 -6
  285. package/src/agents/webhook-handler.md +0 -3
  286. package/src/hooks/lib/permission-rules.cjs +0 -170
  287. package/src/hooks/lib/settings-loader.cjs +0 -112
  288. package/src/lib/hook-pipe-path.cjs +0 -10
  289. package/src/rules/bridge/00-common.md +0 -5
  290. package/src/rules/bridge/30-explorer.md +0 -4
  291. package/src/rules/lead/00-tool-lead.md +0 -5
  292. package/src/rules/shared/00-language.md +0 -3
  293. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  294. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  295. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  296. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  297. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  298. package/src/standalone/bridge-tool.mjs +0 -1414
  299. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -0,0 +1,107 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ _applyCompactFailurePersistToSession,
6
+ _sessionMessagesAdvancedBeyondCompactedOutgoing,
7
+ } from './manager.mjs';
8
+
9
+ const compacted = [
10
+ { role: 'user', content: 'summary turn' },
11
+ { role: 'user', content: 'current task' },
12
+ ];
13
+
14
+ test('sessionMessagesAdvancedBeyondCompactedOutgoing: true for compacted prefix plus final assistant', () => {
15
+ const current = [
16
+ ...compacted,
17
+ { role: 'assistant', content: 'done' },
18
+ ];
19
+ assert.equal(_sessionMessagesAdvancedBeyondCompactedOutgoing(current, compacted), true);
20
+ });
21
+
22
+ test('sessionMessagesAdvancedBeyondCompactedOutgoing: true for compacted prefix plus empty-final assistant', () => {
23
+ const current = [
24
+ ...compacted,
25
+ { role: 'assistant', content: '', emptyFinal: true },
26
+ ];
27
+ assert.equal(_sessionMessagesAdvancedBeyondCompactedOutgoing(current, compacted), true);
28
+ });
29
+
30
+ test('sessionMessagesAdvancedBeyondCompactedOutgoing: false when transcript has not advanced', () => {
31
+ assert.equal(_sessionMessagesAdvancedBeyondCompactedOutgoing(compacted, compacted), false);
32
+ });
33
+
34
+ test('sessionMessagesAdvancedBeyondCompactedOutgoing: false when extra message is not a final assistant', () => {
35
+ const current = [
36
+ ...compacted,
37
+ { role: 'user', content: 'steering follow-up' },
38
+ ];
39
+ assert.equal(_sessionMessagesAdvancedBeyondCompactedOutgoing(current, compacted), false);
40
+ });
41
+
42
+ test('sessionMessagesAdvancedBeyondCompactedOutgoing: false when prefix diverges from compacted outgoing', () => {
43
+ const current = [
44
+ { role: 'user', content: 'different summary' },
45
+ { role: 'user', content: 'current task' },
46
+ { role: 'assistant', content: 'done' },
47
+ ];
48
+ assert.equal(_sessionMessagesAdvancedBeyondCompactedOutgoing(current, compacted), false);
49
+ });
50
+
51
+ test('applyCompactFailurePersistToSession: preserves providerState when transcript already advanced', () => {
52
+ const priorSanitized = [
53
+ ...compacted,
54
+ { role: 'assistant', content: 'done' },
55
+ ];
56
+ const session = {
57
+ messages: priorSanitized,
58
+ providerState: { continuation: 'fresh' },
59
+ compaction: { lastStage: 'compacting' },
60
+ };
61
+ const applied = _applyCompactFailurePersistToSession(session, {
62
+ priorSanitized,
63
+ sanitized: compacted,
64
+ messagesAdvanced: true,
65
+ error: new Error('ask failed'),
66
+ });
67
+ assert.equal(applied, true);
68
+ assert.deepEqual(session.messages, priorSanitized);
69
+ assert.deepEqual(session.providerState, { continuation: 'fresh' });
70
+ assert.equal(session.compaction.lastStage, 'failed');
71
+ });
72
+
73
+ test('applyCompactFailurePersistToSession: clears providerState when replacing messages with compacted outgoing', () => {
74
+ const priorSanitized = [
75
+ { role: 'user', content: 'old long history' },
76
+ ];
77
+ const session = {
78
+ messages: priorSanitized,
79
+ providerState: { stale: true },
80
+ compaction: { lastStage: 'compacting' },
81
+ };
82
+ const applied = _applyCompactFailurePersistToSession(session, {
83
+ priorSanitized,
84
+ sanitized: compacted,
85
+ messagesAdvanced: false,
86
+ error: { code: 'AGENT_CONTEXT_OVERFLOW' },
87
+ });
88
+ assert.equal(applied, true);
89
+ assert.deepEqual(session.messages, compacted);
90
+ assert.equal(session.providerState, undefined);
91
+ assert.equal(session.compaction.lastStage, 'overflow_failed');
92
+ });
93
+
94
+ test('applyCompactFailurePersistToSession: no-op when messages unchanged and not advanced', () => {
95
+ const session = {
96
+ messages: compacted,
97
+ providerState: { keep: true },
98
+ };
99
+ const applied = _applyCompactFailurePersistToSession(session, {
100
+ priorSanitized: compacted,
101
+ sanitized: compacted,
102
+ messagesAdvanced: false,
103
+ });
104
+ assert.equal(applied, false);
105
+ assert.deepEqual(session.providerState, { keep: true });
106
+ });
107
+
@@ -9,11 +9,49 @@ import { randomBytes } from 'crypto';
9
9
  import { join } from 'path';
10
10
  import { Worker } from 'worker_threads';
11
11
  import { getPluginData } from '../config.mjs';
12
+ import { isAgentOwner } from '../agent-owner.mjs';
12
13
  import { renameWithRetrySync, updateJsonAtomicSync, writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
14
+ import { sanitizeContentForStoredHistory } from '../providers/media-normalization.mjs';
13
15
 
14
16
  const _lastSaveError = new Map(); // id -> { message, at }
15
17
  const SESSION_SUMMARY_INDEX_VERSION = 1;
16
18
 
19
+ // The live in-memory session (and every model request)
20
+ // retains attached image bytes across turns so multi-turn recognition works.
21
+ // The persisted session JSON, however, replaces image content with a short
22
+ // text placeholder at serialization time — keeping session files small without
23
+ // starving the model of the image mid-conversation. Returns the same object
24
+ // reference when nothing changed (no-image sessions pay only a shallow scan).
25
+ function _sessionForDisk(session) {
26
+ // Strip the in-flight live-turn alias (askSession sets session.liveTurnMessages
27
+ // for the turn duration so contextStatus() can estimate live context growth).
28
+ // It is a transient duplicate of the working transcript and must never be
29
+ // serialized: mid-turn saves (persistIterationMetrics) would otherwise bloat
30
+ // the session file and persist a non-canonical message array.
31
+ const hasLiveTurn = session && typeof session === 'object'
32
+ && Object.prototype.hasOwnProperty.call(session, 'liveTurnMessages');
33
+ const messages = Array.isArray(session?.messages) ? session.messages : null;
34
+ if (!messages || messages.length === 0) {
35
+ if (!hasLiveTurn) return session;
36
+ const { liveTurnMessages: _drop, ...rest } = session;
37
+ return rest;
38
+ }
39
+ let changed = false;
40
+ const out = messages.map((m) => {
41
+ if (!m || typeof m !== 'object') return m;
42
+ const content = sanitizeContentForStoredHistory(m.content);
43
+ if (content !== m.content) { changed = true; return { ...m, content }; }
44
+ return m;
45
+ });
46
+ if (!changed) {
47
+ if (!hasLiveTurn) return session;
48
+ const { liveTurnMessages: _drop, ...rest } = session;
49
+ return rest;
50
+ }
51
+ const { liveTurnMessages: _drop, ...rest } = session;
52
+ return { ...rest, messages: out };
53
+ }
54
+
17
55
  function _renameWithRetrySync(tmp, target) {
18
56
  return renameWithRetrySync(tmp, target);
19
57
  }
@@ -77,7 +115,6 @@ function _isPreviewNoise(text) {
77
115
  if (!value) return true;
78
116
  if (value.startsWith('<system-reminder>')) return true;
79
117
  if (/^Reference files:/i.test(value)) return true;
80
- if (value.includes('<!-- bp3-sentinel -->')) return true;
81
118
  return false;
82
119
  }
83
120
 
@@ -133,7 +170,7 @@ function _sessionSummary(session) {
133
170
  cwd: session.cwd || '',
134
171
  provider: session.provider || null,
135
172
  model: session.model || null,
136
- bridgeTag: session.bridgeTag || null,
173
+ agentTag: session.agentTag || null,
137
174
  task_id: session.task_id || session.taskId || null,
138
175
  permission: session.permission || null,
139
176
  toolPermission: session.toolPermission || null,
@@ -163,7 +200,7 @@ function _normalizeSummaryRow(row) {
163
200
  cwd: row.cwd || '',
164
201
  provider: row.provider || null,
165
202
  model: row.model || null,
166
- bridgeTag: row.bridgeTag || null,
203
+ agentTag: row.agentTag || null,
167
204
  task_id: row.task_id || null,
168
205
  permission: row.permission || null,
169
206
  toolPermission: row.toolPermission || null,
@@ -278,19 +315,6 @@ function _clearDebounce(id) {
278
315
  if (t) { clearTimeout(t); _debounceTimers.delete(id); }
279
316
  }
280
317
 
281
- // Flush all debounced sessions synchronously on process exit / SIGTERM.
282
- // This prevents losing the last turn's tool-result writes.
283
- function _drainAllDebounced() {
284
- for (const [id, t] of _debounceTimers) {
285
- clearTimeout(t);
286
- _debounceTimers.delete(id);
287
- const cur = _savePending.get(id);
288
- if (cur && cur.debouncing && cur.payload) {
289
- try { _doSaveSync(cur.payload); } catch { /* best-effort */ }
290
- _savePending.delete(id);
291
- }
292
- }
293
- }
294
318
  // Self-registered exit drain; bare 'exit' hook stays as idempotent backup. Use the more comprehensive
295
319
  // drainSessionStore so debounce + scheduled + writing payloads all flush.
296
320
  process.on('exit', drainSessionStore);
@@ -468,7 +492,7 @@ function _doSaveSync(payload) {
468
492
  const target = sessionPath(id);
469
493
  const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
470
494
  try {
471
- writeFileSync(tmp, JSON.stringify(session), 'utf-8');
495
+ writeFileSync(tmp, JSON.stringify(_sessionForDisk(session)), 'utf-8');
472
496
  if (_shouldDrop(id, opts)) {
473
497
  try { unlinkSync(tmp); } catch { /* ignore cleanup failure */ }
474
498
  return;
@@ -490,7 +514,21 @@ function _shouldDrop(id, opts) {
490
514
  const target = sessionPath(id);
491
515
  if (!existsSync(target)) return false;
492
516
  try {
493
- const onDisk = JSON.parse(readFileSync(target, 'utf-8'));
517
+ const raw = readFileSync(target, 'utf-8');
518
+ // Tombstone check only needs `closed` and `generation`. Avoid a full
519
+ // JSON.parse of the entire session (which can be MBs on long chats and
520
+ // ran on every save). Fast-path a regex scan; only fall back to a full
521
+ // parse if the lightweight scan is inconclusive.
522
+ const closedMatch = /"closed"\s*:\s*(true|false)/.exec(raw);
523
+ if (closedMatch && closedMatch[1] === 'false') return false;
524
+ if (closedMatch && closedMatch[1] === 'true') {
525
+ const genMatch = /"generation"\s*:\s*(-?\d+)/.exec(raw);
526
+ const diskGen = genMatch ? Number(genMatch[1]) : 0;
527
+ return diskGen >= expected;
528
+ }
529
+ // No `closed` key found by scan — not a tombstone, so nothing to drop.
530
+ if (!closedMatch && !/"closed"/.test(raw)) return false;
531
+ const onDisk = JSON.parse(raw);
494
532
  const diskGen = typeof onDisk.generation === 'number' ? onDisk.generation : 0;
495
533
  return onDisk.closed === true && diskGen >= expected;
496
534
  } catch {
@@ -557,7 +595,7 @@ async function _doSave(payload) {
557
595
  const target = sessionPath(id);
558
596
  const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
559
597
  try {
560
- await fsp.writeFile(tmp, JSON.stringify(session), 'utf-8');
598
+ await fsp.writeFile(tmp, JSON.stringify(_sessionForDisk(session)), 'utf-8');
561
599
  // Second check: between the temp write and the rename, closeSession()
562
600
  // may have planted a tombstone. Re-check on disk; if a newer tombstone
563
601
  // now exists, discard our temp file rather than let rename clobber it.
@@ -596,7 +634,7 @@ export function markSessionClosed(id, reason = 'manual') {
596
634
  const target = sessionPath(id);
597
635
  const tmp = target + '.' + randomBytes(6).toString('hex') + '.tmp';
598
636
  try {
599
- writeFileSync(tmp, JSON.stringify(tombstone), 'utf-8');
637
+ writeFileSync(tmp, JSON.stringify(_sessionForDisk(tombstone)), 'utf-8');
600
638
  _renameWithRetrySync(tmp, target);
601
639
  } catch {
602
640
  try { unlinkSync(tmp); } catch { /* ignore */ }
@@ -672,11 +710,11 @@ export function deleteSession(id) {
672
710
  return removed;
673
711
  }
674
712
  const DEFAULT_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes idle — aligned with Anthropic 5m messages tier and OpenAI in-memory cache window
675
- // Completed bridge agents (idle/done/error) live until terminal reap must
713
+ // Completed agents (idle/done/error) live until terminal reap - must
676
714
  // match TERMINAL_REAP_MS / _scheduleBridgeReap (3_600_000) in index.mjs and
677
- // bridge-stall-watchdog.mjs so the store sweep is the durable 1h reaper.
678
- const BRIDGE_TERMINAL_TTL_MS = 60 * 60 * 1000;
679
- const BRIDGE_TERMINAL_STATUSES = new Set(['idle', 'done', 'error']);
715
+ // agent stall watchdog, so the store sweep is the durable 1h reaper.
716
+ const AGENT_TERMINAL_TTL_MS = 60 * 60 * 1000;
717
+ const AGENT_TERMINAL_STATUSES = new Set(['idle', 'done', 'error']);
680
718
  // Hard wall-clock ceiling for sessions stuck in status='running'. The
681
719
  // stream-watchdog should abort stalled streams within ~120s, but if it misses
682
720
  // one (process crash, watchdog not started, provider never returned), this
@@ -807,9 +845,9 @@ export function sweepStaleSessions(ttlMs, options = {}) {
807
845
  remaining++;
808
846
  continue;
809
847
  }
810
- // Sweep bridge-owned and ownerless (legacy) sessions; skip explicit
848
+ // Sweep agent-owned and ownerless (legacy) sessions; skip explicit
811
849
  // user sessions before touching heartbeat sidecars.
812
- if (typeof row.owner === 'string' && row.owner.length > 0 && row.owner !== 'bridge') {
850
+ if (typeof row.owner === 'string' && row.owner.length > 0 && !isAgentOwner(row)) {
813
851
  remaining++;
814
852
  continue;
815
853
  }
@@ -829,9 +867,9 @@ export function sweepStaleSessions(ttlMs, options = {}) {
829
867
  remaining++;
830
868
  continue;
831
869
  }
832
- const isCompletedBridge = row.owner === 'bridge'
833
- && BRIDGE_TERMINAL_STATUSES.has(row.status);
834
- const sessionMaxAge = isCompletedBridge ? BRIDGE_TERMINAL_TTL_MS : maxAge;
870
+ const isCompletedAgent = isAgentOwner(row)
871
+ && AGENT_TERMINAL_STATUSES.has(row.status);
872
+ const sessionMaxAge = isCompletedAgent ? AGENT_TERMINAL_TTL_MS : maxAge;
835
873
  if (now - lastActive > sessionMaxAge) {
836
874
  try { markSessionClosed(row.id, 'idle-sweep'); }
837
875
  catch (err) {
@@ -12,7 +12,7 @@ const TOOL_RESULT_SEARCH_THRESHOLD_CHARS = 50_000;
12
12
  const TOOL_RESULT_GREP_THRESHOLD_CHARS = 20_000;
13
13
  export const TOOL_RESULT_OFFLOAD_PREFIX = '[tool output offloaded:';
14
14
 
15
- // Per-tool persistence limits mirror Claude Code's per-tool maxResultSizeChars
15
+ // Per-tool persistence limits mirror reference per-tool maxResultSizeChars
16
16
  // rather than a single global value: Grep persists at 20k (CC GrepTool), Glob
17
17
  // and list/find_* at the 50k system default (CC DEFAULT_MAX_RESULT_SIZE_CHARS;
18
18
  // tighter than CC Glob's 100k by design), and shell/bash_session/task at 30k
@@ -127,7 +127,7 @@ export async function maybeOffloadToolResult(sessionId, toolCallId, toolName, re
127
127
  // tool-results/<sessionId>/*.txt entry, rmdirs the directory, and drops
128
128
  // the sessionId entry from the module-level counter Map so a long-running
129
129
  // mcp-server process doesn't accumulate per-session state across the
130
- // lifetime of the bridge.
130
+ // lifetime of the agent runtime.
131
131
  export async function clearOffloadSession(sessionId) {
132
132
  if (!sessionId) return;
133
133
  _offloadCounters.delete(sessionId);
@@ -6,12 +6,12 @@ const MIN_PROVIDER_TIMEOUT_MS = 30_000;
6
6
  export const STALL_TICK_MS = 15_000;
7
7
  export const DEFAULT_STALL_WARN_S = 300;
8
8
  export const DEFAULT_STALL_ABORT_S = 600;
9
- // First-byte (no-stream-delta) abort for the bridge stall watchdog. A wedged
9
+ // First-byte (no-stream-delta) abort for the agent stall watchdog. A wedged
10
10
  // socket can sit at stage=requesting with zero server events. The 30s deadline
11
11
  // trialed here false-aborted slow high-reasoning first bytes (e.g. gpt-5.5
12
12
  // XHIGH, which can legitimately think >30s before the first delta) and, paired
13
13
  // with dispatch auto-retry, produced premature aborts + duplicate re-dispatches.
14
- // Auto-retry is now removed (bridge-retry.mjs MAX_ATTEMPTS = 1), so a single
14
+ // Auto-retry is now removed, so a single
15
15
  // attempt must get a generous first-byte window: 300s (5 min). Env-overridable.
16
16
  export const DEFAULT_STALL_FIRST_BYTE_ABORT_S = (() => {
17
17
  const raw = process.env.MIXDOG_STALL_FIRST_BYTE_ABORT_S;
@@ -58,6 +58,14 @@ export function resolveTimeoutMs(envNames, fallbackMs, { minMs = 1_000, maxMs =
58
58
  return Math.min(Math.max(fallbackMs, minMs), maxMs);
59
59
  }
60
60
 
61
+ // While a tool call is in flight, bump lastProgressAt on this cadence so long
62
+ // executions stay "active" without emitting model-visible content.
63
+ export const DEFAULT_ACTIVITY_HEARTBEAT_MS = resolveTimeoutMs(
64
+ 'MIXDOG_AGENT_ACTIVITY_HEARTBEAT_MS',
65
+ 30_000,
66
+ { minMs: 5_000, maxMs: 300_000 },
67
+ );
68
+
61
69
  export const PROVIDER_FIRST_BYTE_TIMEOUT_MS = resolveTimeoutMs(
62
70
  'MIXDOG_PROVIDER_FIRST_BYTE_TIMEOUT_MS',
63
71
  Math.min(120_000, PROVIDER_MAX_BEFORE_WARN_MS),
@@ -94,19 +102,26 @@ export const PROVIDER_HTTP_RESPONSE_TIMEOUT_MS = resolveTimeoutMs(
94
102
  { minMs: 10_000, maxMs: PROVIDER_MAX_BEFORE_WARN_MS },
95
103
  );
96
104
 
97
- // Stream idle watchdog is OFF by default matches Claude Code native
98
- // behaviour (its watchdog is gated behind CLAUDE_ENABLE_STREAM_WATCHDOG).
99
- // The bridge stall watchdog (STALL_ABORT_S, 600s) is the backstop for
100
- // genuinely dead streams; this short inter-chunk idle watchdog only adds
101
- // value when explicitly enabled, and otherwise prematurely kills slow
102
- // high-reasoning streams. Enable with MIXDOG_ENABLE_STREAM_WATCHDOG=1.
105
+ // Stream idle watchdog is ON by default. Earlier it was OFF (reference-agent
106
+ // parity) on the theory that a short inter-chunk timer would false-abort slow
107
+ // high-reasoning streams but every provider's SSE loop resets the idle timer
108
+ // on EVERY chunk, including content-free keepalives (Anthropic `:ping`,
109
+ // OpenAI/compat reasoning_content deltas, Gemini chunks). So a live
110
+ // extended-thinking stream keeps the timer fresh; only a genuinely silent
111
+ // socket (no bytes at all for the whole window) trips it. With the watchdog
112
+ // OFF, such a wedged socket sat until the 600s agent stall backstop, which the
113
+ // trace logs showed as the dominant mid-stream hang (sse_parse_ms p99 ~183s,
114
+ // max ~411s). Defaulting ON with a generous 120s window cuts that tail ~5x
115
+ // while leaving genuine reasoning pauses (kept alive by keepalive frames)
116
+ // untouched. Force OFF with MIXDOG_ENABLE_STREAM_WATCHDOG=0.
103
117
  const _sseWatchdogRaw = process.env.MIXDOG_ENABLE_STREAM_WATCHDOG;
104
- export const PROVIDER_SSE_IDLE_WATCHDOG_ENABLED =
105
- _sseWatchdogRaw === '1' || _sseWatchdogRaw === 'true' || _sseWatchdogRaw === 'yes';
118
+ export const PROVIDER_SSE_IDLE_WATCHDOG_ENABLED = _sseWatchdogRaw === undefined || _sseWatchdogRaw === ''
119
+ ? true
120
+ : (_sseWatchdogRaw === '1' || _sseWatchdogRaw === 'true' || _sseWatchdogRaw === 'yes');
106
121
 
107
122
  export const PROVIDER_SSE_IDLE_TIMEOUT_MS = resolveTimeoutMs(
108
123
  'MIXDOG_PROVIDER_SSE_IDLE_TIMEOUT_MS',
109
- 90_000,
124
+ 120_000,
110
125
  { minMs: 10_000, maxMs: STALL_WARN_MS },
111
126
  );
112
127
 
@@ -143,15 +158,15 @@ export const PROVIDER_RETRY_JITTER_RATIO = (() => {
143
158
  return 0.2;
144
159
  })();
145
160
 
146
- export function resolveBridgeStallThresholds(role, env = process.env) {
161
+ export function resolveAgentStallThresholds(role, env = process.env) {
147
162
  const cfg = role ? getHiddenRole(role) : null;
148
163
  const cfgAbort = cfg?.stallCap?.idleSeconds > 0 ? cfg.stallCap.idleSeconds : STALL_ABORT_S;
149
164
  const envOverride = envThresholdSeconds(env);
150
165
  const abort = envOverride != null ? envOverride : cfgAbort;
151
- // Mid-stream "slow" warning disabled — a bridge stall now notifies ONLY at
166
+ // Mid-stream "slow" warning disabled — an agent stall now notifies ONLY at
152
167
  // the abort deadline (10 min default). warn === abort means the watchdog's
153
168
  // warn branch (verdict 'ok' && stale >= warn) can never fire before 'stall',
154
- // so the only notification a stalled bridge worker emits is at the deadline.
169
+ // so the only notification a stalled agent worker emits is at the deadline.
155
170
  const warn = abort;
156
171
  // First-byte deadline: a request still in 'requesting' that never produced a
157
172
  // single SSE delta is hung, not slow-reasoning. Abort on this shorter deadline
@@ -160,7 +175,7 @@ export function resolveBridgeStallThresholds(role, env = process.env) {
160
175
  return { warn, abort, firstByteAbort };
161
176
  }
162
177
 
163
- export function resolveBridgeToolThresholdSeconds(role, thresholdSeconds) {
178
+ export function resolveAgentToolThresholdSeconds(role, thresholdSeconds) {
164
179
  const cfg = role ? getHiddenRole(role) : null;
165
180
  if (cfg?.stallCap?.toolRunningSeconds > 0) return cfg.stallCap.toolRunningSeconds;
166
181
  return thresholdSeconds;
@@ -206,7 +221,7 @@ export function createTimeoutSignal(parentSignal, timeoutMs, label) {
206
221
  // replaced-by-newer-request). Used for the streaming generation phase, where a fixed
207
222
  // total-lifetime cap would false-abort a stream that is still emitting SSE deltas; the
208
223
  // streaming phase is bounded instead by the per-attempt first-byte timeout, the parent
209
- // signal, and the bridge stall watchdog (STALL_ABORT_S, progress-based). cleanup()
224
+ // signal, and the agent stall watchdog (STALL_ABORT_S, progress-based). cleanup()
210
225
  // detaches the parent listener so no listener leaks on the (long-lived) parent signal.
211
226
  export function createPassthroughSignal(parentSignal) {
212
227
  if (!parentSignal) return { signal: null, cleanup: () => {} };
@@ -1,229 +1,13 @@
1
1
  /**
2
- * Shell file-lookup classifier + soft-warn marker stripper.
2
+ * Soft-warn marker stripper.
3
3
  *
4
4
  * The tool-loop guard subsystem (repeated/fanned-out tool-use detection and its
5
- * soft-warn sidecars) was removed once every warn path had been disabled. Two
6
- * standalone utilities remain:
7
- * • classifyBashFileLookupCommand — detects a `bash` call whose first token is
8
- * a file-lookup that has a dedicated in-process tool (read/grep/glob/list).
9
- * Used by the bridge-worker permission gate to block the shell route.
10
- * • classifyBridgeWorkerGitMutationCommand — detects bridge-worker shell
11
- * calls that try to run git operations reserved for Lead.
5
+ * soft-warn sidecars) was removed once every warn path had been disabled. This
6
+ * module now retains a single utility:
12
7
  * • stripSoftWarns — strips legacy soft-warn marker blocks from outbound
13
8
  * bodies so older transcripts that still carry them stay clean.
14
9
  */
15
10
 
16
- // File-lookup-via-shell detector. Matches a `bash` command whose first token
17
- // is a unix find/grep/cat/ls/head/tail OR a powershell file-discovery cmdlet
18
- // (Get-ChildItem / Select-String / Get-Content). These have dedicated tools
19
- // (glob / grep / read / list) per rules/shared/01-tool.md; using bash routes
20
- // around the in-process tool cache and the path-permission check, and the
21
- // outputs were observed in PG telemetry running 100x slower than the dedicated
22
- // path. Get-CimInstance / Win32_Process etc. are NOT matched — process
23
- // enumeration genuinely needs the shell. One advisory per session per matched
24
- // command class; subsequent matches stay silent so the model isn't drowned.
25
- const SHELL_FILE_LOOKUP_COMMANDS = [
26
- 'find', 'ls', 'grep', 'rg', 'fd', 'cat', 'head', 'tail',
27
- 'sed', 'awk', 'less', 'more', 'wc', 'tree',
28
- // File-metadata mirror paths called out in rules/shared/01-tool.md
29
- // Tool Routing section. Added after probe HS-A3 surfaced that the
30
- // rule explicitly lists `stat`, `du`, `file`, and `ls -l` as
31
- // forbidden mirror paths but the policy under-enforced them.
32
- 'stat', 'du', 'file',
33
- 'dir', 'type', 'findstr',
34
- 'Get-ChildItem', 'gci', 'Select-String', 'sls', 'Get-Content', 'gc',
35
- ].join('|');
36
- const BASH_FILE_LOOKUP_RE = new RegExp(
37
- `^\\s*(?:git\\s+(?:grep|ls-files)\\b|(?:${SHELL_FILE_LOOKUP_COMMANDS})\\b)`,
38
- 'i',
39
- );
40
- // Wrapper / eval forms that route the same file-lookup intent through a host
41
- // shell (`bash -c "grep ..."`, `pwsh -Command Get-Content ...`) or an
42
- // inline interpreter eval (`node -e fs.readFileSync(...)`,
43
- // `python -c "open(...)"`). Without these the advisory was bypassable just
44
- // by quoting the call inside another command.
45
- const BASH_WRAPPER_FILE_LOOKUP_RE = new RegExp(
46
- `^\\s*(?:bash|sh|zsh|fish|dash)\\b[^\\n]*\\s-\\w*c\\w*\\s+["']?\\s*(?:${SHELL_FILE_LOOKUP_COMMANDS})\\b`,
47
- 'i',
48
- );
49
- const CMD_WRAPPER_FILE_LOOKUP_RE = /^\s*cmd(?:\.exe)?\s+\/[cs]\s+["']?\s*(?:dir|type|findstr)\b/i;
50
- const PWSH_WRAPPER_FILE_LOOKUP_RE = /^\s*(?:pwsh|powershell)(?:\.exe)?\b[\s\S]*?(?:-|\/)(?:Command|c)\s+["']?\s*(?:Get-ChildItem|gci|dir|ls|Select-String|sls|Get-Content|gc|cat|type)\b/i;
51
- const NODE_FS_EVAL_RE = /^\s*node(?:\.exe)?\b[\s\S]*?\s-e\s+["'](?![\s\S]*\bJSON\.parse\s*\()[\s\S]*?\b(?:fs\.|readFileSync|readFile|readdirSync|readdir|statSync|stat)\b/i;
52
- const PYTHON_OPEN_EVAL_RE = /^\s*python3?(?:\.exe)?\b[\s\S]*?\s-c\s+["'][\s\S]*?\b(?:open\s*\(|Path\s*\(|read_text\s*\(|read_bytes\s*\(|iterdir\s*\()/i;
53
- function _classifyBashFileLookup(command) {
54
- if (typeof command !== 'string' || !command) return null;
55
- const direct = command.match(BASH_FILE_LOOKUP_RE);
56
- if (direct) return direct[0].trim().toLowerCase();
57
- if (BASH_WRAPPER_FILE_LOOKUP_RE.test(command)) return 'bash-wrapped';
58
- if (CMD_WRAPPER_FILE_LOOKUP_RE.test(command)) return 'cmd-wrapped';
59
- if (PWSH_WRAPPER_FILE_LOOKUP_RE.test(command)) return 'pwsh-wrapped';
60
- if (NODE_FS_EVAL_RE.test(command)) return 'node-fs-eval';
61
- if (PYTHON_OPEN_EVAL_RE.test(command)) return 'python-open-eval';
62
- return null;
63
- }
64
-
65
- export function classifyBashFileLookupCommand(command) {
66
- return _classifyBashFileLookup(command);
67
- }
68
-
69
- const SHELL_WRAPPER_COMMANDS = new Set([
70
- 'bash', 'sh', 'zsh', 'fish', 'dash',
71
- 'cmd', 'cmd.exe',
72
- 'pwsh', 'pwsh.exe', 'powershell', 'powershell.exe',
73
- ]);
74
- const POSIX_SHELL_WRAPPER_COMMANDS = new Set(['bash', 'sh', 'zsh', 'fish', 'dash']);
75
- const CMD_SHELL_WRAPPER_COMMANDS = new Set(['cmd', 'cmd.exe']);
76
- const POWERSHELL_WRAPPER_COMMANDS = new Set(['pwsh', 'pwsh.exe', 'powershell', 'powershell.exe']);
77
- const SHELL_SEPARATORS = new Set([';', '\n', '&&', '||', '|', '&']);
78
- const READONLY_GIT_SUBCOMMANDS = new Set([
79
- 'status', 'diff', 'show', 'log', 'rev-parse', 'ls-files', 'grep', 'blame', 'describe', 'merge-base',
80
- ]);
81
-
82
- function shellTokenizeLoose(command) {
83
- const tokens = [];
84
- let current = '';
85
- let quote = null;
86
- let escape = false;
87
- const push = () => {
88
- if (current !== '') tokens.push(current);
89
- current = '';
90
- };
91
- const pushSep = (sep) => {
92
- push();
93
- tokens.push(sep);
94
- };
95
- for (let i = 0; i < command.length; i += 1) {
96
- const ch = command[i];
97
- if (escape) {
98
- current += ch;
99
- escape = false;
100
- continue;
101
- }
102
- if (ch === '\\') {
103
- escape = true;
104
- continue;
105
- }
106
- if (quote) {
107
- if (ch === quote) quote = null;
108
- else current += ch;
109
- continue;
110
- }
111
- if (ch === '\'' || ch === '"') {
112
- quote = ch;
113
- continue;
114
- }
115
- if (ch === '\n') {
116
- pushSep('\n');
117
- continue;
118
- }
119
- if (ch === ';') {
120
- pushSep(';');
121
- continue;
122
- }
123
- if ((ch === '&' || ch === '|') && command[i + 1] === ch) {
124
- pushSep(`${ch}${ch}`);
125
- i += 1;
126
- continue;
127
- }
128
- if (ch === '|' || ch === '&') {
129
- pushSep(ch);
130
- continue;
131
- }
132
- if (/\s/.test(ch)) {
133
- push();
134
- continue;
135
- }
136
- current += ch;
137
- }
138
- push();
139
- return tokens;
140
- }
141
-
142
- function isCommandPosition(tokens, index) {
143
- for (let i = index - 1; i >= 0; i -= 1) {
144
- if (!tokens[i]) continue;
145
- return SHELL_SEPARATORS.has(tokens[i]);
146
- }
147
- return true;
148
- }
149
-
150
- function skipGitGlobalOptions(tokens, index) {
151
- let i = index;
152
- while (i < tokens.length) {
153
- const token = String(tokens[i] || '');
154
- const lower = token.toLowerCase();
155
- if (!token || SHELL_SEPARATORS.has(token)) return i;
156
- if (lower === '-c' || lower === '-C' || lower === '--git-dir' || lower === '--work-tree' || lower === '--namespace') {
157
- i += 2;
158
- continue;
159
- }
160
- if (/^(?:--git-dir|--work-tree|--namespace)=/i.test(token)) {
161
- i += 1;
162
- continue;
163
- }
164
- if (lower === '--no-pager' || lower === '-p' || lower === '--literal-pathspecs' || lower === '--glob-pathspecs' || lower === '--noglob-pathspecs' || lower === '--icase-pathspecs') {
165
- i += 1;
166
- continue;
167
- }
168
- if (/^-/.test(token)) {
169
- i += 1;
170
- continue;
171
- }
172
- return i;
173
- }
174
- return i;
175
- }
176
-
177
- function readonlyGitInvocation(tokens, subIndex) {
178
- const sub = String(tokens[subIndex] || '').toLowerCase();
179
- if (!READONLY_GIT_SUBCOMMANDS.has(sub)) return false;
180
- if (sub === 'remote') {
181
- const next = String(tokens[subIndex + 1] || '').toLowerCase();
182
- return !next || next === '-v' || next === '--verbose' || next === 'show';
183
- }
184
- if (sub === 'branch') {
185
- const rest = tokens.slice(subIndex + 1).filter((token) => token && !SHELL_SEPARATORS.has(token));
186
- return rest.length === 0 || rest.every((token) => ['--show-current', '-a', '-r', '-v', '-vv', '--all', '--remotes', '--verbose', '--contains', '--merged', '--no-merged'].includes(String(token).toLowerCase()));
187
- }
188
- return true;
189
- }
190
-
191
- function isShellCommandArgFlag(wrapper, flag) {
192
- const lower = String(flag || '').toLowerCase();
193
- if (POSIX_SHELL_WRAPPER_COMMANDS.has(wrapper)) return /^-\w*c\w*$/.test(lower);
194
- if (CMD_SHELL_WRAPPER_COMMANDS.has(wrapper)) return lower === '/c';
195
- if (POWERSHELL_WRAPPER_COMMANDS.has(wrapper)) return lower === '-command' || lower === '/command' || lower === '-c';
196
- return false;
197
- }
198
-
199
- export function classifyBridgeWorkerGitMutationCommand(command) {
200
- if (typeof command !== 'string' || !command.trim()) return null;
201
- const tokens = shellTokenizeLoose(command);
202
- for (let i = 0; i < tokens.length; i += 1) {
203
- const token = String(tokens[i] || '').toLowerCase();
204
- if (!token || SHELL_SEPARATORS.has(token) || !isCommandPosition(tokens, i)) continue;
205
- if (SHELL_WRAPPER_COMMANDS.has(token)) {
206
- for (let j = i + 1; j < tokens.length; j += 1) {
207
- const flag = String(tokens[j] || '').toLowerCase();
208
- if (SHELL_SEPARATORS.has(flag)) break;
209
- if (!isShellCommandArgFlag(token, flag)) continue;
210
- const nested = tokens.slice(j + 1).join(' ');
211
- const nestedHit = classifyBridgeWorkerGitMutationCommand(nested || '');
212
- if (nestedHit) return nestedHit;
213
- break;
214
- }
215
- continue;
216
- }
217
- if (token !== 'git' && token !== 'git.exe') continue;
218
- const subIndex = skipGitGlobalOptions(tokens, i + 1);
219
- const sub = String(tokens[subIndex] || '').toLowerCase();
220
- if (!sub || SHELL_SEPARATORS.has(sub)) return 'git';
221
- if (readonlyGitInvocation(tokens, subIndex)) continue;
222
- return `git ${sub}`;
223
- }
224
- return null;
225
- }
226
-
227
11
  // Strip soft-warn marker blocks (header ⚠ <label> through next blank line / EOF)
228
12
  // from outbound bodies. Never call on tool-result bodies fed back to the model.
229
13
  // New compact format: each warn is a single line starting with `⚠ <Label>(` and ending at newline.