@chengchenccc/oh-my-agent 0.1.1-rc.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 (276) hide show
  1. package/README.md +79 -0
  2. package/dist/cli/args.d.ts +24 -0
  3. package/dist/cli/args.d.ts.map +1 -0
  4. package/dist/cli/args.js +120 -0
  5. package/dist/cli/initial-input.d.ts +32 -0
  6. package/dist/cli/initial-input.d.ts.map +1 -0
  7. package/dist/cli/initial-input.js +131 -0
  8. package/dist/cli.d.ts +3 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +3 -0
  11. package/dist/core/__fixtures__/echo-model.d.ts +6 -0
  12. package/dist/core/__fixtures__/echo-model.d.ts.map +1 -0
  13. package/dist/core/__fixtures__/echo-model.js +10 -0
  14. package/dist/core/__fixtures__/mcp-echo-server.d.ts +2 -0
  15. package/dist/core/__fixtures__/mcp-echo-server.d.ts.map +1 -0
  16. package/dist/core/__fixtures__/mcp-echo-server.js +54 -0
  17. package/dist/core/agent-runtime.d.ts +21 -0
  18. package/dist/core/agent-runtime.d.ts.map +1 -0
  19. package/dist/core/agent-runtime.js +14 -0
  20. package/dist/core/memory/autonomous-memory.d.ts +21 -0
  21. package/dist/core/memory/autonomous-memory.d.ts.map +1 -0
  22. package/dist/core/memory/autonomous-memory.js +202 -0
  23. package/dist/core/persistence/in-memory-session-store.d.ts +3 -0
  24. package/dist/core/persistence/in-memory-session-store.d.ts.map +1 -0
  25. package/dist/core/persistence/in-memory-session-store.js +109 -0
  26. package/dist/core/persistence/session-store.contract.d.ts +5 -0
  27. package/dist/core/persistence/session-store.contract.d.ts.map +1 -0
  28. package/dist/core/persistence/session-store.contract.js +183 -0
  29. package/dist/core/persistence/session-store.d.ts +33 -0
  30. package/dist/core/persistence/session-store.d.ts.map +1 -0
  31. package/dist/core/persistence/session-store.js +22 -0
  32. package/dist/core/persistence/session-tree.d.ts +49 -0
  33. package/dist/core/persistence/session-tree.d.ts.map +1 -0
  34. package/dist/core/persistence/session-tree.js +1 -0
  35. package/dist/core/plugins/plugin-code.d.ts +13 -0
  36. package/dist/core/plugins/plugin-code.d.ts.map +1 -0
  37. package/dist/core/plugins/plugin-code.js +62 -0
  38. package/dist/core/plugins/plugin-marketplace.d.ts +72 -0
  39. package/dist/core/plugins/plugin-marketplace.d.ts.map +1 -0
  40. package/dist/core/plugins/plugin-marketplace.js +340 -0
  41. package/dist/core/plugins/plugin-resolve.d.ts +32 -0
  42. package/dist/core/plugins/plugin-resolve.d.ts.map +1 -0
  43. package/dist/core/plugins/plugin-resolve.js +101 -0
  44. package/dist/core/plugins/plugin-trust.d.ts +24 -0
  45. package/dist/core/plugins/plugin-trust.d.ts.map +1 -0
  46. package/dist/core/plugins/plugin-trust.js +89 -0
  47. package/dist/core/runtime/agent-event.d.ts +98 -0
  48. package/dist/core/runtime/agent-event.d.ts.map +1 -0
  49. package/dist/core/runtime/agent-event.js +1 -0
  50. package/dist/core/runtime/agent-loop.d.ts +142 -0
  51. package/dist/core/runtime/agent-loop.d.ts.map +1 -0
  52. package/dist/core/runtime/agent-loop.js +1008 -0
  53. package/dist/core/runtime/approval.d.ts +23 -0
  54. package/dist/core/runtime/approval.d.ts.map +1 -0
  55. package/dist/core/runtime/approval.js +25 -0
  56. package/dist/core/runtime/compaction.d.ts +22 -0
  57. package/dist/core/runtime/compaction.d.ts.map +1 -0
  58. package/dist/core/runtime/compaction.js +90 -0
  59. package/dist/core/runtime/context-estimate.d.ts +42 -0
  60. package/dist/core/runtime/context-estimate.d.ts.map +1 -0
  61. package/dist/core/runtime/context-estimate.js +34 -0
  62. package/dist/core/runtime/create-runtime.d.ts +81 -0
  63. package/dist/core/runtime/create-runtime.d.ts.map +1 -0
  64. package/dist/core/runtime/create-runtime.js +250 -0
  65. package/dist/core/runtime/fake-provider.d.ts +3 -0
  66. package/dist/core/runtime/fake-provider.d.ts.map +1 -0
  67. package/dist/core/runtime/fake-provider.js +87 -0
  68. package/dist/core/runtime/logger.d.ts +13 -0
  69. package/dist/core/runtime/logger.d.ts.map +1 -0
  70. package/dist/core/runtime/logger.js +38 -0
  71. package/dist/core/runtime/loop-input.d.ts +39 -0
  72. package/dist/core/runtime/loop-input.d.ts.map +1 -0
  73. package/dist/core/runtime/loop-input.js +38 -0
  74. package/dist/core/runtime/message-cache.d.ts +16 -0
  75. package/dist/core/runtime/message-cache.d.ts.map +1 -0
  76. package/dist/core/runtime/message-cache.js +23 -0
  77. package/dist/core/runtime/model-catalog.d.ts +10 -0
  78. package/dist/core/runtime/model-catalog.d.ts.map +1 -0
  79. package/dist/core/runtime/model-catalog.js +14 -0
  80. package/dist/core/runtime/plugin-runtime.d.ts +41 -0
  81. package/dist/core/runtime/plugin-runtime.d.ts.map +1 -0
  82. package/dist/core/runtime/plugin-runtime.js +1 -0
  83. package/dist/core/runtime/plugin.d.ts +86 -0
  84. package/dist/core/runtime/plugin.d.ts.map +1 -0
  85. package/dist/core/runtime/plugin.js +23 -0
  86. package/dist/core/runtime/process-tree.d.ts +12 -0
  87. package/dist/core/runtime/process-tree.d.ts.map +1 -0
  88. package/dist/core/runtime/process-tree.js +40 -0
  89. package/dist/core/runtime/prompt.d.ts +23 -0
  90. package/dist/core/runtime/prompt.d.ts.map +1 -0
  91. package/dist/core/runtime/prompt.js +29 -0
  92. package/dist/core/runtime/prompts.d.ts +16 -0
  93. package/dist/core/runtime/prompts.d.ts.map +1 -0
  94. package/dist/core/runtime/prompts.js +36 -0
  95. package/dist/core/runtime/retry.d.ts +20 -0
  96. package/dist/core/runtime/retry.d.ts.map +1 -0
  97. package/dist/core/runtime/retry.js +60 -0
  98. package/dist/core/runtime/run-runtime.d.ts +93 -0
  99. package/dist/core/runtime/run-runtime.d.ts.map +1 -0
  100. package/dist/core/runtime/run-runtime.js +664 -0
  101. package/dist/core/runtime/runtime-catalog.d.ts +12 -0
  102. package/dist/core/runtime/runtime-catalog.d.ts.map +1 -0
  103. package/dist/core/runtime/runtime-catalog.js +89 -0
  104. package/dist/core/runtime/stream-rules.d.ts +9 -0
  105. package/dist/core/runtime/stream-rules.d.ts.map +1 -0
  106. package/dist/core/runtime/stream-rules.js +44 -0
  107. package/dist/core/runtime/title.d.ts +7 -0
  108. package/dist/core/runtime/title.d.ts.map +1 -0
  109. package/dist/core/runtime/title.js +114 -0
  110. package/dist/core/runtime/todo.d.ts +16 -0
  111. package/dist/core/runtime/todo.d.ts.map +1 -0
  112. package/dist/core/runtime/todo.js +40 -0
  113. package/dist/core/runtime/tool-filter.d.ts +12 -0
  114. package/dist/core/runtime/tool-filter.d.ts.map +1 -0
  115. package/dist/core/runtime/tool-filter.js +23 -0
  116. package/dist/core/runtime/tool-pruning.d.ts +29 -0
  117. package/dist/core/runtime/tool-pruning.d.ts.map +1 -0
  118. package/dist/core/runtime/tool-pruning.js +105 -0
  119. package/dist/core/session/input-history.d.ts +8 -0
  120. package/dist/core/session/input-history.d.ts.map +1 -0
  121. package/dist/core/session/input-history.js +41 -0
  122. package/dist/core/session/session-file.d.ts +84 -0
  123. package/dist/core/session/session-file.d.ts.map +1 -0
  124. package/dist/core/session/session-file.js +392 -0
  125. package/dist/core/session/session-loop.d.ts +33 -0
  126. package/dist/core/session/session-loop.d.ts.map +1 -0
  127. package/dist/core/session/session-loop.js +30 -0
  128. package/dist/core/settings/project-settings.d.ts +46 -0
  129. package/dist/core/settings/project-settings.d.ts.map +1 -0
  130. package/dist/core/settings/project-settings.js +85 -0
  131. package/dist/core/settings/workspace-context.d.ts +10 -0
  132. package/dist/core/settings/workspace-context.d.ts.map +1 -0
  133. package/dist/core/settings/workspace-context.js +48 -0
  134. package/dist/core/tools/agent-fs-like.d.ts +15 -0
  135. package/dist/core/tools/agent-fs-like.d.ts.map +1 -0
  136. package/dist/core/tools/agent-fs-like.js +4 -0
  137. package/dist/core/tools/ask-question.d.ts +5 -0
  138. package/dist/core/tools/ask-question.d.ts.map +1 -0
  139. package/dist/core/tools/ask-question.js +51 -0
  140. package/dist/core/tools/bash.d.ts +5 -0
  141. package/dist/core/tools/bash.d.ts.map +1 -0
  142. package/dist/core/tools/bash.js +109 -0
  143. package/dist/core/tools/eval.d.ts +10 -0
  144. package/dist/core/tools/eval.d.ts.map +1 -0
  145. package/dist/core/tools/eval.js +83 -0
  146. package/dist/core/tools/file-tools.d.ts +18 -0
  147. package/dist/core/tools/file-tools.d.ts.map +1 -0
  148. package/dist/core/tools/file-tools.js +249 -0
  149. package/dist/core/tools/glob.d.ts +5 -0
  150. package/dist/core/tools/glob.d.ts.map +1 -0
  151. package/dist/core/tools/glob.js +54 -0
  152. package/dist/core/tools/grep.d.ts +5 -0
  153. package/dist/core/tools/grep.d.ts.map +1 -0
  154. package/dist/core/tools/grep.js +61 -0
  155. package/dist/core/tools/index.d.ts +14 -0
  156. package/dist/core/tools/index.d.ts.map +1 -0
  157. package/dist/core/tools/index.js +14 -0
  158. package/dist/core/tools/ls-tree.d.ts +10 -0
  159. package/dist/core/tools/ls-tree.d.ts.map +1 -0
  160. package/dist/core/tools/ls-tree.js +177 -0
  161. package/dist/core/tools/mcp-mount.d.ts +63 -0
  162. package/dist/core/tools/mcp-mount.d.ts.map +1 -0
  163. package/dist/core/tools/mcp-mount.js +287 -0
  164. package/dist/core/tools/read-image.d.ts +6 -0
  165. package/dist/core/tools/read-image.d.ts.map +1 -0
  166. package/dist/core/tools/read-image.js +98 -0
  167. package/dist/core/tools/skill.d.ts +11 -0
  168. package/dist/core/tools/skill.d.ts.map +1 -0
  169. package/dist/core/tools/skill.js +65 -0
  170. package/dist/core/tools/skills.d.ts +10 -0
  171. package/dist/core/tools/skills.d.ts.map +1 -0
  172. package/dist/core/tools/skills.js +116 -0
  173. package/dist/core/tools/todo-store.d.ts +8 -0
  174. package/dist/core/tools/todo-store.d.ts.map +1 -0
  175. package/dist/core/tools/todo-store.js +100 -0
  176. package/dist/core/tools/todo.d.ts +8 -0
  177. package/dist/core/tools/todo.d.ts.map +1 -0
  178. package/dist/core/tools/todo.js +91 -0
  179. package/dist/core/tools/url-guard.d.ts +12 -0
  180. package/dist/core/tools/url-guard.d.ts.map +1 -0
  181. package/dist/core/tools/url-guard.js +58 -0
  182. package/dist/core/tools/web-ports-std.d.ts +6 -0
  183. package/dist/core/tools/web-ports-std.d.ts.map +1 -0
  184. package/dist/core/tools/web-ports-std.js +145 -0
  185. package/dist/core/tools/web-ports.d.ts +56 -0
  186. package/dist/core/tools/web-ports.d.ts.map +1 -0
  187. package/dist/core/tools/web-ports.js +62 -0
  188. package/dist/core/tools/workspace-sandbox.d.ts +20 -0
  189. package/dist/core/tools/workspace-sandbox.d.ts.map +1 -0
  190. package/dist/core/tools/workspace-sandbox.js +84 -0
  191. package/dist/core/workflow/subagent-registry.d.ts +40 -0
  192. package/dist/core/workflow/subagent-registry.d.ts.map +1 -0
  193. package/dist/core/workflow/subagent-registry.js +118 -0
  194. package/dist/core/workflow/workflow-evaluator.d.ts +23 -0
  195. package/dist/core/workflow/workflow-evaluator.d.ts.map +1 -0
  196. package/dist/core/workflow/workflow-evaluator.js +21 -0
  197. package/dist/core/workflow/workflow-executor.d.ts +108 -0
  198. package/dist/core/workflow/workflow-executor.d.ts.map +1 -0
  199. package/dist/core/workflow/workflow-executor.js +549 -0
  200. package/dist/core/workflow/workflow-tools.d.ts +50 -0
  201. package/dist/core/workflow/workflow-tools.d.ts.map +1 -0
  202. package/dist/core/workflow/workflow-tools.js +217 -0
  203. package/dist/index.d.ts +14 -0
  204. package/dist/index.d.ts.map +1 -0
  205. package/dist/index.js +13 -0
  206. package/dist/main.d.ts +9 -0
  207. package/dist/main.d.ts.map +1 -0
  208. package/dist/main.js +97 -0
  209. package/dist/modes/json-mode.d.ts +5 -0
  210. package/dist/modes/json-mode.d.ts.map +1 -0
  211. package/dist/modes/json-mode.js +46 -0
  212. package/dist/modes/print-mode.d.ts +20 -0
  213. package/dist/modes/print-mode.d.ts.map +1 -0
  214. package/dist/modes/print-mode.js +82 -0
  215. package/dist/modes/rpc/jsonl.d.ts +23 -0
  216. package/dist/modes/rpc/jsonl.d.ts.map +1 -0
  217. package/dist/modes/rpc/jsonl.js +88 -0
  218. package/dist/modes/rpc/rpc-mode.d.ts +18 -0
  219. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -0
  220. package/dist/modes/rpc/rpc-mode.js +326 -0
  221. package/dist/modes/tui/settings-overlay.d.ts +23 -0
  222. package/dist/modes/tui/settings-overlay.d.ts.map +1 -0
  223. package/dist/modes/tui/settings-overlay.js +136 -0
  224. package/dist/modes/tui/tui-commands-session.d.ts +3 -0
  225. package/dist/modes/tui/tui-commands-session.d.ts.map +1 -0
  226. package/dist/modes/tui/tui-commands-session.js +191 -0
  227. package/dist/modes/tui/tui-commands.d.ts +52 -0
  228. package/dist/modes/tui/tui-commands.d.ts.map +1 -0
  229. package/dist/modes/tui/tui-commands.js +373 -0
  230. package/dist/modes/tui/tui-components.d.ts +81 -0
  231. package/dist/modes/tui/tui-components.d.ts.map +1 -0
  232. package/dist/modes/tui/tui-components.js +248 -0
  233. package/dist/modes/tui/tui-format.d.ts +65 -0
  234. package/dist/modes/tui/tui-format.d.ts.map +1 -0
  235. package/dist/modes/tui/tui-format.js +246 -0
  236. package/dist/modes/tui/tui-frame-provider.d.ts +16 -0
  237. package/dist/modes/tui/tui-frame-provider.d.ts.map +1 -0
  238. package/dist/modes/tui/tui-frame-provider.js +31 -0
  239. package/dist/modes/tui/tui-interactive.d.ts +16 -0
  240. package/dist/modes/tui/tui-interactive.d.ts.map +1 -0
  241. package/dist/modes/tui/tui-interactive.js +123 -0
  242. package/dist/modes/tui/tui-io.d.ts +12 -0
  243. package/dist/modes/tui/tui-io.d.ts.map +1 -0
  244. package/dist/modes/tui/tui-io.js +620 -0
  245. package/dist/modes/tui/tui-mode.d.ts +120 -0
  246. package/dist/modes/tui/tui-mode.d.ts.map +1 -0
  247. package/dist/modes/tui/tui-mode.js +391 -0
  248. package/dist/modes/tui/tui-render.d.ts +54 -0
  249. package/dist/modes/tui/tui-render.d.ts.map +1 -0
  250. package/dist/modes/tui/tui-render.js +399 -0
  251. package/dist/modes/tui/tui-slash.d.ts +12 -0
  252. package/dist/modes/tui/tui-slash.d.ts.map +1 -0
  253. package/dist/modes/tui/tui-slash.js +64 -0
  254. package/dist/modes/tui/tui-tool-render.d.ts +6 -0
  255. package/dist/modes/tui/tui-tool-render.d.ts.map +1 -0
  256. package/dist/modes/tui/tui-tool-render.js +79 -0
  257. package/dist/modes/tui/tui-transcript-reconciler.d.ts +25 -0
  258. package/dist/modes/tui/tui-transcript-reconciler.d.ts.map +1 -0
  259. package/dist/modes/tui/tui-transcript-reconciler.js +97 -0
  260. package/dist/modes/tui/view-state.d.ts +67 -0
  261. package/dist/modes/tui/view-state.d.ts.map +1 -0
  262. package/dist/modes/tui/view-state.js +353 -0
  263. package/dist/prompts/agents/subagent.md +26 -0
  264. package/dist/prompts/system/base.md +20 -0
  265. package/dist/prompts/system/memory.md +23 -0
  266. package/dist/prompts/system/safety.md +12 -0
  267. package/dist/protocol/index.d.ts +5 -0
  268. package/dist/protocol/index.d.ts.map +1 -0
  269. package/dist/protocol/index.js +2 -0
  270. package/dist/protocol/mapping.d.ts +25 -0
  271. package/dist/protocol/mapping.d.ts.map +1 -0
  272. package/dist/protocol/mapping.js +115 -0
  273. package/dist/protocol/transport.d.ts +1396 -0
  274. package/dist/protocol/transport.d.ts.map +1 -0
  275. package/dist/protocol/transport.js +170 -0
  276. package/package.json +49 -0
@@ -0,0 +1,1008 @@
1
+ import { debugLog } from "@chengchenccc/agent-contract";
2
+ import { ProviderError } from "@chengchenccc/ai";
3
+ import { compactSession } from "./compaction.js";
4
+ import { estimateContextTokens, isSilentContextOverflow, usageTotalTokens, } from "./context-estimate.js";
5
+ import { buildLoopInput } from "./loop-input.js";
6
+ import { TokenEstimateCache } from "./message-cache.js";
7
+ import { collectTools, validatePlugins } from "./plugin.js";
8
+ import { renderLoopMeta } from "./prompt.js";
9
+ import { retryStream } from "./retry.js";
10
+ import { buildTitleContext, generateTitle } from "./title.js";
11
+ import { pruneOldToolResults } from "./tool-pruning.js";
12
+ export function createOmaSession(opts) {
13
+ validatePlugins(opts.plugins);
14
+ const listeners = new Set();
15
+ // Resolve the plugin runtime: opts.pluginRuntime from run-runtime, or a
16
+ // minimal stub for tests that only need emit (backward-compatible).
17
+ let rt = opts.pluginRuntime ?? {
18
+ streamModel: async function* () { },
19
+ store: opts.store,
20
+ sessionId: opts.sessionId,
21
+ workspaceRoot: "",
22
+ emit: (event) => {
23
+ void emit(event);
24
+ },
25
+ signal: new AbortController().signal,
26
+ };
27
+ // Static plugin tools + per-run resolved tools (Product Tool manifest).
28
+ const baseTools = collectTools(opts.plugins);
29
+ let toolMap = new Map(baseTools.map((t) => [t.name, t]));
30
+ let status = "idle";
31
+ let runUsage;
32
+ let active = false;
33
+ let controller = null;
34
+ const steerQueue = [];
35
+ let acceptingSteer = false;
36
+ let debugModelId = "";
37
+ let debugTurn = 0;
38
+ let debugRunId = "";
39
+ const runIdForDebug = () => debugRunId;
40
+ // TTSR stream-rule injection counts (per Run, reset in runLoop).
41
+ const streamRuleInjections = new Map();
42
+ const tokenEstimateCache = new TokenEstimateCache();
43
+ async function emit(event) {
44
+ for (const l of listeners) {
45
+ try {
46
+ await l(event);
47
+ }
48
+ catch {
49
+ /* listener error is logged but not fatal */
50
+ }
51
+ }
52
+ }
53
+ const SESSION_MESSAGE_SOURCES = new Set([
54
+ "prompt",
55
+ "steer",
56
+ "follow_up",
57
+ "assistant",
58
+ "tool_result",
59
+ "system_reminder",
60
+ ]);
61
+ function persist(entries) {
62
+ if (opts.onPersistMessages) {
63
+ const conversational = entries
64
+ .filter((e) => e.type === "message" &&
65
+ typeof e.source === "string" &&
66
+ SESSION_MESSAGE_SOURCES.has(e.source) &&
67
+ typeof e.message === "object" &&
68
+ e.message !== null)
69
+ .map((e) => e.message);
70
+ if (conversational.length > 0)
71
+ opts.onPersistMessages(conversational);
72
+ }
73
+ return opts.store.appendBatch(opts.sessionId, { entries });
74
+ }
75
+ /** One thinking block from a turn's raw thinking (single assembly point:
76
+ * both the text turn and the tool turn persist through here). An empty
77
+ * thinking text with a signature (display: "omitted") still persists
78
+ * the signature must be replayed unchanged in tool-use turns. */
79
+ function buildThinkingBlock(turn) {
80
+ if (!turn.thinking && !turn.thinkingSignature)
81
+ return [];
82
+ // Collapse the interleaved thinking strands into one thinking block for
83
+ // replay: Anthropic requires a single <thinking> per assistant message,
84
+ // and signature/redacted attach at the end. Text passages stay in the
85
+ // ordered blocks when the turn persists them (tool turns keep them).
86
+ const text = turn.thinkingRedacted ? "[reasoning redacted]" : turn.thinking;
87
+ return [
88
+ {
89
+ type: "thinking",
90
+ text,
91
+ ...(turn.thinkingSignature ? { signature: turn.thinkingSignature } : {}),
92
+ ...(turn.thinkingRedacted ? { redacted: true } : {}),
93
+ },
94
+ ];
95
+ }
96
+ /**
97
+ * runLoop: inner loop runs model turns and tool calls until the model
98
+ * stops. Each model turn is accumulated purely (streamModelTurn) and
99
+ * then persisted as canonical messages — persistence is a turn-level
100
+ * decision, never inside the stream accumulation. Steer inputs drain
101
+ * at safe boundaries; runEphemeralTurn is a side channel that never
102
+ * persists. Follow-up inputs require a separate startFollowUp() call
103
+ * (the backend orchestrates follow-ups via branch_input_queue).
104
+ */
105
+ async function runLoop(codingInput, mode) {
106
+ if (active)
107
+ throw new Error("Loop already active");
108
+ active = true;
109
+ status = "running";
110
+ controller = new AbortController();
111
+ // Bind rt.signal to THIS run's controller so plugin model calls
112
+ // (side-channel summaries) honor stop()/abort().
113
+ rt = {
114
+ ...rt,
115
+ signal: controller.signal,
116
+ // Ephemeral side-channel turn: shares system prompt + branch messages
117
+ // + tool catalog (for prompt cache) but never persists. Tool calls
118
+ // from the model are discarded — an ephemeral side channel.
119
+ runEphemeralTurn: async (promptText, ephemeralOpts) => {
120
+ const branch = await readBranchMessages();
121
+ const ephemeralMessages = [
122
+ ...((codingInput.run.systemPrompt ?? "")
123
+ ? [{ role: "system", text: codingInput.run.systemPrompt ?? "" }]
124
+ : []),
125
+ ...branch,
126
+ { role: "user", text: promptText },
127
+ ];
128
+ let text = "";
129
+ for await (const chunk of opts.modelStream(ephemeralMessages, ephemeralOpts?.signal ?? controller?.signal, [...toolMap.values()])) {
130
+ if (chunk.delta?.type === "text")
131
+ text += chunk.delta.text;
132
+ }
133
+ return text;
134
+ },
135
+ };
136
+ streamRuleInjections.clear();
137
+ debugModelId = codingInput.run.model.modelId;
138
+ debugTurn = 0;
139
+ debugRunId = codingInput.run.runId;
140
+ runUsage = undefined;
141
+ let runError;
142
+ const runTools = opts.resolveTools ? await opts.resolveTools(codingInput) : [];
143
+ toolMap = new Map([...baseTools, ...runTools].map((t) => [t.name, t]));
144
+ await emit({ type: "agent_start" });
145
+ try {
146
+ const model = opts.resolveModel
147
+ ? await opts.resolveModel(codingInput.run.model.modelId)
148
+ : undefined;
149
+ const metaText = renderLoopMeta({
150
+ plugins: opts.plugins,
151
+ workspace: { root: codingInput.workspace.root },
152
+ model,
153
+ });
154
+ const systemPrompt = codingInput.run.systemPrompt ?? "";
155
+ const built = buildLoopInput({
156
+ systemPrompt,
157
+ metaText,
158
+ input: codingInput.input,
159
+ history: codingInput.history,
160
+ }, mode);
161
+ await persist(built.batch.entries);
162
+ let messages = await readBranchMessages();
163
+ let step = 0;
164
+ let forceContinues = 0;
165
+ let overflowCompacted = false;
166
+ let thresholdCompacted = false;
167
+ let usageAnchor = null;
168
+ let naturalStop = false;
169
+ for (const p of opts.plugins) {
170
+ if (p.hooks?.beforeRun) {
171
+ try {
172
+ await p.hooks.beforeRun(messages, rt);
173
+ }
174
+ catch {
175
+ /* plugin setup errors never fail the run */
176
+ }
177
+ }
178
+ }
179
+ while (step < opts.maxSteps && !naturalStop) {
180
+ if (controller?.signal.aborted)
181
+ break;
182
+ step++;
183
+ acceptingSteer = step < opts.maxSteps;
184
+ // Drain steer queue at safe boundary (steer appends only the input
185
+ // message — no Meta, no new Loop).
186
+ if (steerQueue.length > 0) {
187
+ const steers = steerQueue.splice(0);
188
+ await persist(steers.map((s) => ({
189
+ type: "message",
190
+ productEntryId: s.productEntryId ?? null,
191
+ role: s.message.role,
192
+ source: "steer",
193
+ message: s.message,
194
+ createdAt: Date.now(),
195
+ })));
196
+ messages = await readBranchMessages();
197
+ const drained = steers
198
+ .map((s) => (s.message.role === "user" ? (s.message.text ?? "") : ""))
199
+ .filter((t) => t.length > 0);
200
+ await emit({
201
+ type: "queue_update",
202
+ ...(drained.length > 0 ? { drained } : {}),
203
+ });
204
+ }
205
+ // One step = at most one model call. Overflow recovery stays INSIDE
206
+ while (true) {
207
+ // Prune old tool-result content: a lighter
208
+ // pass that runs BEFORE compaction checks. Old results outside the
209
+ // protect window are truncated to a summary; protected tools are
210
+ // never pruned. May reduce context enough to avoid compaction
211
+ // entirely (omp docs/compaction.md).
212
+ const modelMessages0 = opts.pruneConfig
213
+ ? pruneOldToolResults(messages, opts.pruneConfig).messages
214
+ : messages;
215
+ // Snapshot for this model call + proactive (threshold) compaction.
216
+ // Estimation is anchored on the previous call's real usage
217
+ // (oh-my-pi): only entries persisted since the anchor boundary are
218
+ // per-message estimated, so the estimate tracks the provider's
219
+ // own accounting instead of drifting with chars/4. The
220
+ // TokenEstimateCache still avoids re-estimating settled entries.
221
+ let callBoundaryId = null;
222
+ if (opts.contextBudget) {
223
+ const branch = await opts.store.readBranch(opts.sessionId);
224
+ const msgEntries = branch.filter((e) => e.type === "message");
225
+ callBoundaryId = msgEntries.at(-1)?.entryId ?? null;
226
+ if (!thresholdCompacted) {
227
+ const totalTokens = estimateContextTokens(msgEntries, usageAnchor, (e) => tokenEstimateCache.estimate(e.entryId, e.message, opts.contextBudget.estimate));
228
+ if (totalTokens > opts.contextBudget.limit * opts.contextBudget.triggerRatio) {
229
+ thresholdCompacted = true;
230
+ usageAnchor = null;
231
+ tokenEstimateCache.clear();
232
+ await emit({ type: "compaction_start" });
233
+ await compactSession(opts.store, opts.sessionId, opts.summarize, controller?.signal, opts.contextBudget);
234
+ await emit({ type: "compaction_end" });
235
+ messages = await readBranchMessages();
236
+ }
237
+ }
238
+ }
239
+ // beforeModel hook.
240
+ const transformed = [...modelMessages0];
241
+ for (const p of opts.plugins) {
242
+ if (p.hooks?.beforeModel) {
243
+ const result = p.hooks.beforeModel(transformed, rt);
244
+ transformed.length = 0;
245
+ transformed.push(...result);
246
+ }
247
+ }
248
+ try {
249
+ const modelMessages = systemPrompt
250
+ ? [{ role: "system", text: systemPrompt }, ...transformed]
251
+ : transformed;
252
+ const turn = await streamModelTurn(modelMessages);
253
+ const thinkingBlocks = buildThinkingBlock(turn);
254
+ // Usage anchor (oh-my-pi): the completed call's real token
255
+ // total is authoritative for everything persisted before the
256
+ // call — per-message estimation covers only the delta since.
257
+ if (turn.usage && usageTotalTokens(turn.usage) > 0) {
258
+ usageAnchor = { afterEntryId: callBoundaryId, tokens: usageTotalTokens(turn.usage) };
259
+ }
260
+ // Silent context overflow (oh-my-pi isContextOverflow): some
261
+ // providers (zai, Xiaomi-style) accept an oversized request
262
+ // instead of erroring. Same recovery as the error path: one-shot
263
+ // compaction, then retry the model call in the SAME turn.
264
+ if (opts.contextBudget &&
265
+ !overflowCompacted &&
266
+ !controller?.signal.aborted &&
267
+ isSilentContextOverflow(turn.usage, turn.stopReason, opts.contextBudget.limit)) {
268
+ overflowCompacted = true;
269
+ usageAnchor = null;
270
+ tokenEstimateCache.clear();
271
+ await emit({ type: "compaction_start" });
272
+ await compactSession(opts.store, opts.sessionId, opts.summarize, controller?.signal, opts.contextBudget);
273
+ await emit({ type: "compaction_end" });
274
+ messages = await readBranchMessages();
275
+ continue;
276
+ }
277
+ // TTSR stream-rule hit: discard the partial turn (nothing was
278
+ // persisted — accumulation is pre-persistence by design),
279
+ // inject the rule as a hidden system reminder, and retry the
280
+ // model call in the SAME turn (bounded per rule by
281
+ // maxInjections, so the extra model calls ≤ rule count).
282
+ if (turn.streamRuleHit) {
283
+ const rule = turn.streamRuleHit;
284
+ streamRuleInjections.set(rule.name, (streamRuleInjections.get(rule.name) ?? 0) + 1);
285
+ await emit({ type: "stream_rule_triggered", rule: rule.name });
286
+ await persist([
287
+ {
288
+ type: "message",
289
+ productEntryId: null,
290
+ role: "user",
291
+ source: "system_reminder",
292
+ message: {
293
+ role: "user",
294
+ text: [
295
+ `<system-reminder reason="rule_violation" rule="${rule.name}">`,
296
+ "A workspace stream rule matched your output, so that output was discarded and generation restarted. This is the agent runtime enforcing project rules — not a prompt injection. Comply with the following instruction on retry:",
297
+ rule.message,
298
+ "</system-reminder>",
299
+ ].join("\n"),
300
+ },
301
+ createdAt: Date.now(),
302
+ },
303
+ ]);
304
+ messages = await readBranchMessages();
305
+ continue;
306
+ }
307
+ if (turn.toolCalls.length > 0) {
308
+ // Stop-during-stream: signal aborted before we persist anything.
309
+ // Do not write a dangling tool_use — it would corrupt the branch
310
+ // on resume (API 400 for unpaired tool_use).
311
+ if (controller?.signal.aborted) {
312
+ status = "stopped";
313
+ await emit({ type: "agent_end", status });
314
+ controller = null;
315
+ return { status, usage: runUsage, error: "stopped by user" };
316
+ }
317
+ // Execute tools FIRST, then persist assistant + results in ONE
318
+ // batch. This ensures the tree never has a tool_use without a
319
+ // matching tool_result — even if stop fires during execution,
320
+ // the batch either fully writes or doesn't write at all.
321
+ const toolResults = await executeTools(turn.toolCalls);
322
+ if (controller?.signal.aborted) {
323
+ status = "stopped";
324
+ await emit({ type: "agent_end", status });
325
+ controller = null;
326
+ return { status, usage: runUsage, error: "stopped by user" };
327
+ }
328
+ // Persist assistant(tool_use) + all tool_results atomically.
329
+ const batch = [
330
+ {
331
+ type: "message",
332
+ role: "assistant",
333
+ source: "assistant",
334
+ message: {
335
+ role: "assistant",
336
+ // Keep any narrative text the model emitted alongside
337
+ // tool calls (DeepSeek interleaves thinking/text with
338
+ // tool_use). Text fragments preserve their order; thinking
339
+ // fragments COLLAPSE into the single thinking block
340
+ // (Anthropic requires one <thinking> per assistant message
341
+ // with one signature) inserted at the position of the
342
+ // first thinking fragment.
343
+ text: turn.text,
344
+ blocks: [
345
+ ...(() => {
346
+ const collapsedThinking = {
347
+ type: "thinking",
348
+ text: turn.thinkingRedacted ? "[reasoning redacted]" : turn.thinking,
349
+ ...(turn.thinkingSignature ? { signature: turn.thinkingSignature } : {}),
350
+ ...(turn.thinkingRedacted ? { redacted: true } : {}),
351
+ };
352
+ const out = [];
353
+ let thinkingInserted = false;
354
+ for (const b of turn.ordered) {
355
+ if (b.type === "thinking") {
356
+ if (!thinkingInserted) {
357
+ out.push(collapsedThinking);
358
+ thinkingInserted = true;
359
+ }
360
+ continue;
361
+ }
362
+ out.push(b);
363
+ }
364
+ if (!thinkingInserted && turn.thinking) {
365
+ out.push(collapsedThinking);
366
+ }
367
+ return out;
368
+ })(),
369
+ ...turn.toolCalls.map((tc) => ({
370
+ type: "tool_use",
371
+ id: tc.id,
372
+ name: tc.name,
373
+ input: tc.input,
374
+ })),
375
+ ],
376
+ },
377
+ createdAt: Date.now(),
378
+ },
379
+ ...toolResults.map((result) => {
380
+ // Vision passthrough: a tool result carrying `images`
381
+ // (read_image) keeps them on the tool_result block so
382
+ // providers map them onto the wire content array.
383
+ const imgs = result.result?.images;
384
+ const images = Array.isArray(imgs) && imgs.length > 0
385
+ ? {
386
+ images: imgs,
387
+ }
388
+ : {};
389
+ // Tool result content contract (spec): a string `content`
390
+ // field is the model-visible text verbatim (tool-formatted);
391
+ // everything else stays the JSON dump for both model and UI.
392
+ const res = result.result;
393
+ const raw = typeof res?.content === "string" ? res.content : JSON.stringify(result.result);
394
+ // Tool-failure system reminder (absorbed from oh-my-pi):
395
+ // in-band on the failing result so it survives into the
396
+ // canonical ledger — "the fix sticks" across runs. The
397
+ // message `text` stays the clean JSON for UI display.
398
+ const content = result.isError && opts.toolFailureReminder !== false
399
+ ? `${TOOL_FAILURE_REMINDER}\n\n${raw}`
400
+ : raw;
401
+ return {
402
+ type: "message",
403
+ role: "tool",
404
+ source: "tool_result",
405
+ message: {
406
+ role: "tool",
407
+ text: raw,
408
+ blocks: [
409
+ {
410
+ type: "tool_result",
411
+ tool_use_id: result.id,
412
+ content,
413
+ ...(result.isError ? { is_error: true } : {}),
414
+ ...images,
415
+ },
416
+ ],
417
+ },
418
+ createdAt: Date.now(),
419
+ };
420
+ }),
421
+ ];
422
+ await persist(batch);
423
+ messages = await readBranchMessages();
424
+ if (toolResults.some((r) => r.terminate) && steerQueue.length === 0) {
425
+ naturalStop = true;
426
+ }
427
+ break;
428
+ }
429
+ // Text turn: persist assistant(text) + thinking. Thinking alone
430
+ // is not a message — a thinking-only turn with no text and no
431
+ // tool calls contributed nothing replayable, and empty content
432
+ // breaks strict model APIs.
433
+ // An abort during the stream discards the partial output: an
434
+ // uncompleted turn never enters the canonical tree.
435
+ if (turn.text && !controller?.signal.aborted) {
436
+ await persist([
437
+ {
438
+ type: "message",
439
+ role: "assistant",
440
+ source: "assistant",
441
+ message: {
442
+ role: "assistant",
443
+ text: turn.text,
444
+ // Preserve the interleaved thinking/text order from the
445
+ // stream. The single collapsed thinking block (with
446
+ // signature) is still emitted for replay compatibility
447
+ // when the stream had a signature, but the ordered list
448
+ // keeps the trace faithful.
449
+ blocks: turn.ordered.length > 0
450
+ ? turn.ordered.map((b) => b.type === "thinking"
451
+ ? {
452
+ type: "thinking",
453
+ text: turn.thinkingRedacted ? "[reasoning redacted]" : b.text,
454
+ ...(turn.thinkingSignature
455
+ ? { signature: turn.thinkingSignature }
456
+ : {}),
457
+ ...(turn.thinkingRedacted ? { redacted: true } : {}),
458
+ }
459
+ : b)
460
+ : thinkingBlocks.length > 0
461
+ ? thinkingBlocks
462
+ : undefined,
463
+ },
464
+ createdAt: Date.now(),
465
+ },
466
+ ]);
467
+ }
468
+ // Natural stop: let plugins veto.
469
+ let stopped = true;
470
+ for (const p of opts.plugins) {
471
+ if (p.hooks?.beforeStop) {
472
+ let vetoed = false;
473
+ try {
474
+ p.hooks.beforeStop(() => {
475
+ vetoed = true;
476
+ }, rt);
477
+ }
478
+ catch {
479
+ /* plugin errors never fail the run */
480
+ }
481
+ if (vetoed && forceContinues < opts.maxForceContinues) {
482
+ forceContinues++;
483
+ stopped = false;
484
+ break;
485
+ }
486
+ }
487
+ }
488
+ // max_tokens/pause_turn truncation semantics: the model ran out
489
+ // of output budget (or paused a long turn) mid-answer — force
490
+ // one continuation when capacity remains, bounded by
491
+ // maxForceContinues.
492
+ const truncated = turn.stopReason === "max_tokens" || turn.stopReason === "pause_turn";
493
+ if (stopped && truncated && forceContinues < opts.maxForceContinues) {
494
+ forceContinues++;
495
+ stopped = false;
496
+ }
497
+ naturalStop = stopped;
498
+ for (const p of opts.plugins) {
499
+ if (p.hooks?.afterStop) {
500
+ try {
501
+ p.hooks.afterStop(!stopped, rt);
502
+ }
503
+ catch {
504
+ /* plugin errors never affect the loop */
505
+ }
506
+ }
507
+ }
508
+ // Accepted-but-late steer: force one more turn to drain it.
509
+ if (naturalStop && steerQueue.length > 0) {
510
+ naturalStop = false;
511
+ }
512
+ break;
513
+ }
514
+ catch (err) {
515
+ // Explicit stop/abort is a distinct terminal state.
516
+ if (controller?.signal.aborted ||
517
+ (err instanceof ProviderError && err.kind === "aborted")) {
518
+ status = "stopped";
519
+ runError ??= err instanceof Error ? err.message : "stopped by user";
520
+ await emit({ type: "agent_end", status });
521
+ controller = null;
522
+ return { status, usage: runUsage, error: runError };
523
+ }
524
+ // Overflow: one-shot compaction recovery inside the same turn.
525
+ if (err instanceof ProviderError && err.kind === "overflow" && !overflowCompacted) {
526
+ overflowCompacted = true;
527
+ usageAnchor = null;
528
+ tokenEstimateCache.clear();
529
+ await emit({ type: "compaction_start" });
530
+ await compactSession(opts.store, opts.sessionId, opts.summarize, controller?.signal, opts.contextBudget);
531
+ await emit({ type: "compaction_end" });
532
+ messages = await readBranchMessages();
533
+ continue; // retry model call in the SAME turn, no extra step
534
+ }
535
+ // Anything else is terminal: retryStream already applied its
536
+ // bounded policy.
537
+ runError ??= err instanceof Error ? err.message : String(err);
538
+ status = "failed";
539
+ await emit({ type: "agent_end", status });
540
+ controller = null;
541
+ return { status, usage: runUsage, error: runError };
542
+ }
543
+ }
544
+ // afterModel hook: after the turn's output + tool results are
545
+ // persisted, before turn_end.
546
+ for (const p of opts.plugins) {
547
+ if (p.hooks?.afterModel) {
548
+ try {
549
+ await p.hooks.afterModel(messages, rt);
550
+ }
551
+ catch {
552
+ /* plugin errors never fail the run */
553
+ }
554
+ }
555
+ }
556
+ await emit({ type: "turn_end", turn: step });
557
+ if (naturalStop)
558
+ break;
559
+ }
560
+ if (controller?.signal.aborted) {
561
+ status = "stopped";
562
+ }
563
+ else if (!naturalStop && step >= opts.maxSteps && status === "running") {
564
+ status = "failed";
565
+ runError ??= `max steps exceeded (${opts.maxSteps})`;
566
+ }
567
+ else if (status === "running") {
568
+ status = "completed";
569
+ }
570
+ for (const p of opts.plugins) {
571
+ if (p.hooks?.afterRun) {
572
+ try {
573
+ await p.hooks.afterRun(status, messages, rt);
574
+ }
575
+ catch {
576
+ /* plugin errors never fail the run */
577
+ }
578
+ }
579
+ }
580
+ let title;
581
+ // Auto-title retries on EVERY completed turn while the conversation is
582
+ // still untitled (OMA_CONV_TITLED=1 marks it titled — the backend sets
583
+ // it at spawn and re-checks on commit). The first turn may be low
584
+ // signal ("hi") and must not permanently suppress the title.
585
+ if (status === "completed" &&
586
+ process.env.OMA_CONV_TITLED !== "1" &&
587
+ process.env.OMA_TITLE_ENABLED !== "0") {
588
+ const titleBranch = await readBranchMessages();
589
+ const titleCtx = buildTitleContext(titleBranch);
590
+ if (titleCtx) {
591
+ title = (await generateTitle(rt, titleCtx)) ?? undefined;
592
+ }
593
+ }
594
+ await emit({ type: "agent_end", status });
595
+ // Canonical output (ADR 0017): the run's full message sequence.
596
+ // Every terminal status returns the persisted assistant/tool messages:
597
+ // a failed run (e.g. max steps exceeded) must still surface what it
598
+ // did so a follow-up turn ("continue") has the context.
599
+ const entries = await opts.store.readBranch(opts.sessionId);
600
+ const runMessages = entries
601
+ .filter((e) => e.type === "message" && (e.source === "assistant" || e.source === "tool_result"))
602
+ .map((e) => e.message);
603
+ return { status, messages: runMessages, usage: runUsage, error: runError, title };
604
+ }
605
+ catch (err) {
606
+ // Setup/persistence failure: settle to a terminal state so listeners
607
+ // always receive agent_end and the loop is reusable.
608
+ runError ??= err instanceof Error ? err.message : String(err);
609
+ status = controller?.signal.aborted ? "stopped" : "failed";
610
+ await emit({ type: "agent_end", status });
611
+ return { status, usage: runUsage, error: runError };
612
+ }
613
+ finally {
614
+ active = false;
615
+ controller = null;
616
+ steerQueue.length = 0;
617
+ acceptingSteer = false;
618
+ }
619
+ }
620
+ /** Accumulate one model turn from the stream — pure, no persistence
621
+ * The caller decides what to persist. */
622
+ async function streamModelTurn(messages) {
623
+ let text = "";
624
+ let thinking = "";
625
+ const ordered = [];
626
+ let thinkingSignature;
627
+ let thinkingRedacted = false;
628
+ const toolCallBuilders = new Map();
629
+ debugTurn++;
630
+ debugLog("oma", `model_start runId=${runIdForDebug()} turn=${debugTurn} model=${debugModelId}`);
631
+ await emit({ type: "message_start" });
632
+ const stream = retryStream((signal) => opts.modelStream(messages, signal, [...toolMap.values()]), {
633
+ maxAttempts: opts.maxRetries ?? 3,
634
+ baseDelayMs: 1000,
635
+ onRetryStart: (attempt) => emit({ type: "retry_start", attempt }),
636
+ onRetryEnd: () => emit({ type: "retry_end" }),
637
+ }, controller?.signal);
638
+ let stopReason;
639
+ let streamRuleHit;
640
+ let turnUsage;
641
+ try {
642
+ for await (const chunk of stream) {
643
+ if (controller?.signal.aborted)
644
+ break;
645
+ if (chunk.stopReason)
646
+ stopReason = chunk.stopReason;
647
+ if (chunk.usage) {
648
+ // Accumulate across all model calls in the Run (not last-wins)...
649
+ runUsage = {
650
+ inputTokens: (runUsage?.inputTokens ?? 0) + (chunk.usage.input ?? 0),
651
+ outputTokens: (runUsage?.outputTokens ?? 0) + (chunk.usage.output ?? 0),
652
+ cacheReadTokens: (runUsage?.cacheReadTokens ?? 0) + (chunk.usage.cacheRead ?? 0),
653
+ cacheWriteTokens: (runUsage?.cacheWriteTokens ?? 0) + (chunk.usage.cacheCreate ?? 0),
654
+ };
655
+ // ...and per turn: this call's own total anchors context
656
+ // estimation and silent-overflow detection (oh-my-pi).
657
+ turnUsage = {
658
+ inputTokens: (turnUsage?.inputTokens ?? 0) + (chunk.usage.input ?? 0),
659
+ outputTokens: (turnUsage?.outputTokens ?? 0) + (chunk.usage.output ?? 0),
660
+ cacheReadTokens: (turnUsage?.cacheReadTokens ?? 0) + (chunk.usage.cacheRead ?? 0),
661
+ cacheWriteTokens: (turnUsage?.cacheWriteTokens ?? 0) + (chunk.usage.cacheCreate ?? 0),
662
+ };
663
+ }
664
+ if (chunk.delta?.type === "text") {
665
+ text += chunk.delta.text;
666
+ ordered.push({ type: "text", text: chunk.delta.text });
667
+ await emit({ type: "message_update", text: chunk.delta.text });
668
+ const hit = opts.streamRules
669
+ ? matchStreamRule(opts.streamRules, text, streamRuleInjections)
670
+ : undefined;
671
+ if (hit) {
672
+ streamRuleHit = hit;
673
+ break;
674
+ }
675
+ }
676
+ if (chunk.delta?.type === "reasoning") {
677
+ thinking += chunk.delta.text;
678
+ ordered.push({ type: "thinking", text: chunk.delta.text });
679
+ await emit({ type: "thinking_update", text: chunk.delta.text });
680
+ }
681
+ if (chunk.delta?.type === "reasoning_signature") {
682
+ thinkingSignature = chunk.delta.signature;
683
+ thinkingRedacted = chunk.delta.redacted === true;
684
+ }
685
+ if (chunk.delta?.type === "tool_use") {
686
+ const id = chunk.delta.id;
687
+ if (!toolCallBuilders.has(id)) {
688
+ toolCallBuilders.set(id, { id, name: chunk.delta.name, jsonParts: [] });
689
+ }
690
+ }
691
+ if (chunk.delta?.type === "input_json_delta") {
692
+ const builder = toolCallBuilders.get(chunk.delta.id);
693
+ if (builder)
694
+ builder.jsonParts.push(chunk.delta.partial_json);
695
+ }
696
+ }
697
+ }
698
+ finally {
699
+ // message_end always pairs with message_start, even on failure/abort.
700
+ await emit({ type: "message_end" });
701
+ }
702
+ debugLog("oma", `model_end runId=${runIdForDebug()} turn=${debugTurn} stopReason=${stopReason ?? "none"}`);
703
+ return {
704
+ text,
705
+ thinking,
706
+ ordered,
707
+ ...(thinkingSignature ? { thinkingSignature } : {}),
708
+ ...(thinkingRedacted ? { thinkingRedacted: true } : {}),
709
+ toolCalls: Array.from(toolCallBuilders.values()).map((b) => ({
710
+ id: b.id,
711
+ name: b.name,
712
+ input: b.jsonParts.length > 0 ? safeParseJson(b.jsonParts.join("")) : {},
713
+ })),
714
+ stopReason,
715
+ ...(turnUsage ? { usage: turnUsage } : {}),
716
+ ...(streamRuleHit ? { streamRuleHit } : {}),
717
+ };
718
+ }
719
+ async function executeTools(calls) {
720
+ const results = [];
721
+ // Batch execution: consecutive concurrent tools run in parallel via
722
+ // Promise.all; a serial tool acts as a barrier. Results preserve the
723
+ // original tool-call order regardless of completion order.
724
+ async function runOne(call) {
725
+ const tool = toolMap.get(call.name);
726
+ debugLog("oma", `tool_start runId=${runIdForDebug()} name=${call.name} callId=${call.id}`);
727
+ await emit({
728
+ type: "tool_execution_start",
729
+ toolName: call.name,
730
+ callId: call.id,
731
+ // Original model call args (pre-plugin-transform) so the transcript
732
+ // can show what the model asked for.
733
+ input: call.input,
734
+ ...(tool?.timeoutMs !== undefined ? { timeoutMs: tool.timeoutMs } : {}),
735
+ });
736
+ let result;
737
+ let isError = false;
738
+ let terminate = false;
739
+ let input = call.input;
740
+ if (tool) {
741
+ // transformToolArgs: rewrite call args before execution.
742
+ // transformToolCallArguments).
743
+ for (const p of opts.plugins) {
744
+ if (p.hooks?.transformToolArgs) {
745
+ try {
746
+ const transformed = p.hooks.transformToolArgs(call.name, input, rt);
747
+ if (transformed && typeof transformed === "object") {
748
+ input = transformed;
749
+ }
750
+ }
751
+ catch {
752
+ /* plugin transform errors never block execution */
753
+ }
754
+ }
755
+ }
756
+ // beforeTool: observe or block. A block
757
+ // result emits an error tool result instead of executing.
758
+ let blocked = false;
759
+ let blockReason = `Blocked by plugin`;
760
+ for (const p of opts.plugins) {
761
+ if (p.hooks?.beforeTool) {
762
+ try {
763
+ const ret = p.hooks.beforeTool(call.name, input, rt);
764
+ if (ret?.block) {
765
+ blocked = true;
766
+ if (ret.reason)
767
+ blockReason = ret.reason;
768
+ break;
769
+ }
770
+ }
771
+ catch {
772
+ /* plugin errors never block execution */
773
+ }
774
+ }
775
+ }
776
+ // Native-tool permission gate (ADR 0020): runs AFTER plugin
777
+ // beforeTool hooks so a plugin block always wins; ask/deny here
778
+ // apply to native high-risk tools too.
779
+ if (!blocked && opts.permissionGate) {
780
+ try {
781
+ const verdict = await opts.permissionGate(call.name, input);
782
+ if (verdict?.block) {
783
+ blocked = true;
784
+ if (verdict.reason)
785
+ blockReason = verdict.reason;
786
+ }
787
+ }
788
+ catch {
789
+ /* permission gate errors never crash the run */
790
+ }
791
+ }
792
+ if (blocked) {
793
+ result = { error: blockReason };
794
+ isError = true;
795
+ }
796
+ else {
797
+ try {
798
+ result = await tool.execute(input, controller?.signal, {
799
+ callId: call.id,
800
+ onOutput: (text) => {
801
+ void emit({
802
+ type: "tool_output",
803
+ toolName: call.name,
804
+ callId: call.id,
805
+ text,
806
+ });
807
+ },
808
+ ...(opts.approvalHandler
809
+ ? {
810
+ request: (req) => opts.approvalHandler({
811
+ callId: call.id,
812
+ toolName: call.name,
813
+ input,
814
+ source: "tool",
815
+ ...(req.reason ? { reason: req.reason } : {}),
816
+ }),
817
+ }
818
+ : {}),
819
+ ...(opts.askHandler ? { ask: opts.askHandler } : {}),
820
+ });
821
+ if (result && typeof result === "object") {
822
+ if ("isError" in result) {
823
+ isError = Boolean(result.isError);
824
+ }
825
+ if ("terminate" in result) {
826
+ terminate = Boolean(result.terminate);
827
+ }
828
+ }
829
+ }
830
+ catch (err) {
831
+ result = { error: err instanceof Error ? err.message : String(err) };
832
+ isError = true;
833
+ }
834
+ }
835
+ }
836
+ else {
837
+ result = { error: `Unknown tool: ${call.name}` };
838
+ isError = true;
839
+ }
840
+ debugLog("oma", `tool_end runId=${runIdForDebug()} name=${call.name} callId=${call.id} error=${isError}`);
841
+ await emit({
842
+ type: "tool_execution_end",
843
+ toolName: call.name,
844
+ callId: call.id,
845
+ result: (result ?? {}),
846
+ });
847
+ // afterTool: observe (emit event) or patch (override result fields).
848
+ for (const p of opts.plugins) {
849
+ try {
850
+ const ret = p.hooks?.afterTool?.(call.name, result, rt);
851
+ if (ret) {
852
+ // OmaLoopEvent (has `type`) → emit; patch object →
853
+ // override result fields field-by-field.
854
+ if ("type" in ret) {
855
+ await emit(ret);
856
+ }
857
+ else {
858
+ if (ret.content !== undefined)
859
+ result = ret.content;
860
+ if (ret.isError !== undefined)
861
+ isError = ret.isError;
862
+ if (ret.terminate !== undefined)
863
+ terminate = ret.terminate;
864
+ }
865
+ }
866
+ }
867
+ catch {
868
+ /* plugin errors never affect the loop */
869
+ }
870
+ }
871
+ return { id: call.id, result, isError, terminate };
872
+ }
873
+ let i = 0;
874
+ while (i < calls.length) {
875
+ if (controller?.signal.aborted)
876
+ break;
877
+ const call = calls[i];
878
+ const isConcurrent = toolMap.get(call.name)?.executionMode === "concurrent";
879
+ if (!isConcurrent) {
880
+ // Serial tool: run alone (barrier before and after).
881
+ if (controller?.signal.aborted)
882
+ break;
883
+ const r = await runOne(call);
884
+ if (controller?.signal.aborted)
885
+ break;
886
+ results.push(r);
887
+ i++;
888
+ continue;
889
+ }
890
+ // Collect a maximal run of consecutive concurrent tools.
891
+ const batch = [call];
892
+ let j = i + 1;
893
+ while (j < calls.length) {
894
+ const next = calls[j];
895
+ if (toolMap.get(next.name)?.executionMode !== "concurrent")
896
+ break;
897
+ batch.push(next);
898
+ j++;
899
+ }
900
+ // Run the whole batch in parallel.
901
+ const batchResults = await Promise.all(batch.map((c) => runOne(c)));
902
+ if (controller?.signal.aborted)
903
+ break;
904
+ results.push(...batchResults);
905
+ i = j;
906
+ }
907
+ return results;
908
+ }
909
+ async function readBranchMessages() {
910
+ const entries = await opts.store.readBranch(opts.sessionId);
911
+ // Find latest CompactionEntry
912
+ let compactionSummary = null;
913
+ let coveredIds = null;
914
+ for (let i = entries.length - 1; i >= 0; i--) {
915
+ if (entries[i]?.type === "compaction") {
916
+ const comp = entries[i];
917
+ compactionSummary = comp.summary;
918
+ coveredIds = new Set(comp.coversEntryIds);
919
+ break;
920
+ }
921
+ }
922
+ return entries
923
+ .filter((e) => {
924
+ if (e.type !== "message")
925
+ return false;
926
+ // If compaction exists, skip covered entries
927
+ if (coveredIds?.has(e.entryId))
928
+ return false;
929
+ return true;
930
+ })
931
+ .map((e) => {
932
+ const msg = e.message;
933
+ // Prepend compaction summary as a system note if entries were compacted
934
+ return msg;
935
+ })
936
+ .flatMap((msg, _i, _arr) => {
937
+ // Insert summary as first user message if compaction applied
938
+ if (_i === 0 && compactionSummary && coveredIds && coveredIds.size > 0) {
939
+ return [
940
+ { role: "user", text: `[Context summary: ${compactionSummary}]` },
941
+ msg,
942
+ ];
943
+ }
944
+ return [msg];
945
+ });
946
+ }
947
+ return {
948
+ sessionId: opts.sessionId,
949
+ get status() {
950
+ return status;
951
+ },
952
+ async startLoop(deps) {
953
+ return runLoop(deps, "normal");
954
+ },
955
+ async startFollowUp(deps) {
956
+ return runLoop(deps, "follow_up");
957
+ },
958
+ steer(input) {
959
+ if (status !== "running" || !acceptingSteer) {
960
+ throw new Error("Steer is only accepted during a loop with remaining turn capacity");
961
+ }
962
+ steerQueue.push(input);
963
+ },
964
+ stop() {
965
+ controller?.abort();
966
+ },
967
+ async compact() {
968
+ await emit({ type: "compaction_start" });
969
+ await compactSession(opts.store, opts.sessionId, opts.summarize, controller?.signal, opts.contextBudget);
970
+ await emit({ type: "compaction_end" });
971
+ },
972
+ onEvent(listener) {
973
+ listeners.add(listener);
974
+ return () => {
975
+ listeners.delete(listener);
976
+ };
977
+ },
978
+ emit(event) {
979
+ void emit(event);
980
+ },
981
+ };
982
+ }
983
+ function safeParseJson(json) {
984
+ try {
985
+ return JSON.parse(json);
986
+ }
987
+ catch {
988
+ return {};
989
+ }
990
+ }
991
+ /** First stream rule matching `text` that still has injection budget.
992
+ * ponytail: full re-scan per text delta (O(deltas × rules × len)); anchor
993
+ * incremental matching if long generations measurably regress. */
994
+ function matchStreamRule(rules, text, injections) {
995
+ for (const rule of rules) {
996
+ if ((injections.get(rule.name) ?? 0) >= (rule.maxInjections ?? 1))
997
+ continue;
998
+ if (rule.pattern.test(text))
999
+ return rule;
1000
+ }
1001
+ return undefined;
1002
+ }
1003
+ const TOOL_FAILURE_REMINDER = [
1004
+ "<system-reminder>",
1005
+ "This tool call FAILED. Do not proceed as if it succeeded or claim it worked.",
1006
+ "Diagnose the error below; if the cause is fixable (wrong arguments, missing file, transient state), correct it and call the tool again. Only move on if the failure is genuinely permanent, and say so.",
1007
+ "</system-reminder>",
1008
+ ].join("\n");