@open-mercato/cezar 0.1.5-pr506.357

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 (264) hide show
  1. package/README.md +573 -0
  2. package/dist/config.d.ts +144 -0
  3. package/dist/config.js +106 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/core/agent-runner.d.ts +169 -0
  6. package/dist/core/agent-runner.js +23 -0
  7. package/dist/core/agent-runner.js.map +1 -0
  8. package/dist/core/backend-detect.d.ts +16 -0
  9. package/dist/core/backend-detect.js +118 -0
  10. package/dist/core/backend-detect.js.map +1 -0
  11. package/dist/core/claude-cli-runner.d.ts +56 -0
  12. package/dist/core/claude-cli-runner.js +416 -0
  13. package/dist/core/claude-cli-runner.js.map +1 -0
  14. package/dist/core/claude-ui-mapper.d.ts +71 -0
  15. package/dist/core/claude-ui-mapper.js +567 -0
  16. package/dist/core/claude-ui-mapper.js.map +1 -0
  17. package/dist/core/codex-app-server-runner.d.ts +29 -0
  18. package/dist/core/codex-app-server-runner.js +489 -0
  19. package/dist/core/codex-app-server-runner.js.map +1 -0
  20. package/dist/core/codex-ui-mapper.d.ts +63 -0
  21. package/dist/core/codex-ui-mapper.js +491 -0
  22. package/dist/core/codex-ui-mapper.js.map +1 -0
  23. package/dist/core/ndjson.d.ts +6 -0
  24. package/dist/core/ndjson.js +23 -0
  25. package/dist/core/ndjson.js.map +1 -0
  26. package/dist/core/opencode-server-runner.d.ts +30 -0
  27. package/dist/core/opencode-server-runner.js +488 -0
  28. package/dist/core/opencode-server-runner.js.map +1 -0
  29. package/dist/core/opencode-ui-mapper.d.ts +105 -0
  30. package/dist/core/opencode-ui-mapper.js +588 -0
  31. package/dist/core/opencode-ui-mapper.js.map +1 -0
  32. package/dist/core/process-usage.d.ts +63 -0
  33. package/dist/core/process-usage.js +181 -0
  34. package/dist/core/process-usage.js.map +1 -0
  35. package/dist/core/runner-factory.d.ts +8 -0
  36. package/dist/core/runner-factory.js +22 -0
  37. package/dist/core/runner-factory.js.map +1 -0
  38. package/dist/core/tool-display.d.ts +30 -0
  39. package/dist/core/tool-display.js +156 -0
  40. package/dist/core/tool-display.js.map +1 -0
  41. package/dist/core/ui-events.d.ts +308 -0
  42. package/dist/core/ui-events.js +25 -0
  43. package/dist/core/ui-events.js.map +1 -0
  44. package/dist/core/usage.d.ts +11 -0
  45. package/dist/core/usage.js +15 -0
  46. package/dist/core/usage.js.map +1 -0
  47. package/dist/git-worktree.d.ts +88 -0
  48. package/dist/git-worktree.js +297 -0
  49. package/dist/git-worktree.js.map +1 -0
  50. package/dist/handoff.d.ts +62 -0
  51. package/dist/handoff.js +147 -0
  52. package/dist/handoff.js.map +1 -0
  53. package/dist/index.d.ts +2 -0
  54. package/dist/index.js +466 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/install-as-command.d.ts +39 -0
  57. package/dist/install-as-command.js +51 -0
  58. package/dist/install-as-command.js.map +1 -0
  59. package/dist/pack-check.d.ts +17 -0
  60. package/dist/pack-check.js +27 -0
  61. package/dist/pack-check.js.map +1 -0
  62. package/dist/paths.d.ts +23 -0
  63. package/dist/paths.js +34 -0
  64. package/dist/paths.js.map +1 -0
  65. package/dist/planner.d.ts +17 -0
  66. package/dist/planner.js +248 -0
  67. package/dist/planner.js.map +1 -0
  68. package/dist/release/snapshot.d.ts +64 -0
  69. package/dist/release/snapshot.js +75 -0
  70. package/dist/release/snapshot.js.map +1 -0
  71. package/dist/release/stable.d.ts +46 -0
  72. package/dist/release/stable.js +57 -0
  73. package/dist/release/stable.js.map +1 -0
  74. package/dist/runs/auto-name.d.ts +75 -0
  75. package/dist/runs/auto-name.js +168 -0
  76. package/dist/runs/auto-name.js.map +1 -0
  77. package/dist/runs/retention.d.ts +60 -0
  78. package/dist/runs/retention.js +86 -0
  79. package/dist/runs/retention.js.map +1 -0
  80. package/dist/runs/review-gate.d.ts +18 -0
  81. package/dist/runs/review-gate.js +21 -0
  82. package/dist/runs/review-gate.js.map +1 -0
  83. package/dist/runs/store.d.ts +408 -0
  84. package/dist/runs/store.js +526 -0
  85. package/dist/runs/store.js.map +1 -0
  86. package/dist/runs/task-refs.d.ts +24 -0
  87. package/dist/runs/task-refs.js +74 -0
  88. package/dist/runs/task-refs.js.map +1 -0
  89. package/dist/runs/ui-event-sink.d.ts +81 -0
  90. package/dist/runs/ui-event-sink.js +210 -0
  91. package/dist/runs/ui-event-sink.js.map +1 -0
  92. package/dist/server/capabilities.d.ts +33 -0
  93. package/dist/server/capabilities.js +43 -0
  94. package/dist/server/capabilities.js.map +1 -0
  95. package/dist/server/forge/github.d.ts +70 -0
  96. package/dist/server/forge/github.js +375 -0
  97. package/dist/server/forge/github.js.map +1 -0
  98. package/dist/server/forge/index.d.ts +24 -0
  99. package/dist/server/forge/index.js +45 -0
  100. package/dist/server/forge/index.js.map +1 -0
  101. package/dist/server/forge/types.d.ts +80 -0
  102. package/dist/server/forge/types.js +2 -0
  103. package/dist/server/forge/types.js.map +1 -0
  104. package/dist/server/git-changes.d.ts +195 -0
  105. package/dist/server/git-changes.js +497 -0
  106. package/dist/server/git-changes.js.map +1 -0
  107. package/dist/server/git.d.ts +26 -0
  108. package/dist/server/git.js +103 -0
  109. package/dist/server/git.js.map +1 -0
  110. package/dist/server/github.d.ts +8 -0
  111. package/dist/server/github.js +8 -0
  112. package/dist/server/github.js.map +1 -0
  113. package/dist/server/launch-key.d.ts +7 -0
  114. package/dist/server/launch-key.js +33 -0
  115. package/dist/server/launch-key.js.map +1 -0
  116. package/dist/server/open-in-app.d.ts +51 -0
  117. package/dist/server/open-in-app.js +189 -0
  118. package/dist/server/open-in-app.js.map +1 -0
  119. package/dist/server/open-in-terminal.d.ts +25 -0
  120. package/dist/server/open-in-terminal.js +120 -0
  121. package/dist/server/open-in-terminal.js.map +1 -0
  122. package/dist/server/pr.d.ts +7 -0
  123. package/dist/server/pr.js +7 -0
  124. package/dist/server/pr.js.map +1 -0
  125. package/dist/server/server.d.ts +41 -0
  126. package/dist/server/server.js +1390 -0
  127. package/dist/server/server.js.map +1 -0
  128. package/dist/server/static-ui.d.ts +49 -0
  129. package/dist/server/static-ui.js +92 -0
  130. package/dist/server/static-ui.js.map +1 -0
  131. package/dist/server/wsl.d.ts +25 -0
  132. package/dist/server/wsl.js +92 -0
  133. package/dist/server/wsl.js.map +1 -0
  134. package/dist/server-install/engine.d.ts +33 -0
  135. package/dist/server-install/engine.js +314 -0
  136. package/dist/server-install/engine.js.map +1 -0
  137. package/dist/server-install/platforms/macosx-ngrok.d.ts +6 -0
  138. package/dist/server-install/platforms/macosx-ngrok.js +345 -0
  139. package/dist/server-install/platforms/macosx-ngrok.js.map +1 -0
  140. package/dist/server-install/platforms/ubuntu-vps.d.ts +39 -0
  141. package/dist/server-install/platforms/ubuntu-vps.js +746 -0
  142. package/dist/server-install/platforms/ubuntu-vps.js.map +1 -0
  143. package/dist/server-install/state.d.ts +26 -0
  144. package/dist/server-install/state.js +125 -0
  145. package/dist/server-install/state.js.map +1 -0
  146. package/dist/server-install/steps.d.ts +109 -0
  147. package/dist/server-install/steps.js +330 -0
  148. package/dist/server-install/steps.js.map +1 -0
  149. package/dist/server-install/strategies.d.ts +4 -0
  150. package/dist/server-install/strategies.js +20 -0
  151. package/dist/server-install/strategies.js.map +1 -0
  152. package/dist/server-install/types.d.ts +597 -0
  153. package/dist/server-install/types.js +98 -0
  154. package/dist/server-install/types.js.map +1 -0
  155. package/dist/server-install/ui.d.ts +42 -0
  156. package/dist/server-install/ui.js +130 -0
  157. package/dist/server-install/ui.js.map +1 -0
  158. package/dist/skills-banner.d.ts +21 -0
  159. package/dist/skills-banner.js +40 -0
  160. package/dist/skills-banner.js.map +1 -0
  161. package/dist/skills-remote.d.ts +35 -0
  162. package/dist/skills-remote.js +266 -0
  163. package/dist/skills-remote.js.map +1 -0
  164. package/dist/skills.d.ts +45 -0
  165. package/dist/skills.js +182 -0
  166. package/dist/skills.js.map +1 -0
  167. package/dist/todos.d.ts +63 -0
  168. package/dist/todos.js +168 -0
  169. package/dist/todos.js.map +1 -0
  170. package/dist/ui-state.d.ts +12 -0
  171. package/dist/ui-state.js +25 -0
  172. package/dist/ui-state.js.map +1 -0
  173. package/dist/update-check.d.ts +12 -0
  174. package/dist/update-check.js +41 -0
  175. package/dist/update-check.js.map +1 -0
  176. package/dist/workflows/load.d.ts +15 -0
  177. package/dist/workflows/load.js +56 -0
  178. package/dist/workflows/load.js.map +1 -0
  179. package/dist/workflows/run.d.ts +281 -0
  180. package/dist/workflows/run.js +1511 -0
  181. package/dist/workflows/run.js.map +1 -0
  182. package/dist/workflows/types.d.ts +307 -0
  183. package/dist/workflows/types.js +171 -0
  184. package/dist/workflows/types.js.map +1 -0
  185. package/package.json +90 -0
  186. package/scripts/check-pack.mjs +46 -0
  187. package/scripts/dev.mjs +74 -0
  188. package/scripts/install-as-command.mjs +120 -0
  189. package/scripts/mock-claude.mjs +310 -0
  190. package/scripts/release-snapshot.mjs +130 -0
  191. package/scripts/release.mjs +119 -0
  192. package/scripts/test-process-usage.mjs +47 -0
  193. package/web/dist/assets/arrow-left-BE957rJs.js +1 -0
  194. package/web/dist/assets/bundle-mjs-DLPpRFyl.js +1 -0
  195. package/web/dist/assets/centered-state-BddhEEtO.js +43 -0
  196. package/web/dist/assets/chunk-BO2N2NFS-f5igTtpB.js +131 -0
  197. package/web/dist/assets/command-CjhFbABo.js +1 -0
  198. package/web/dist/assets/compare-variants-Cs52IAEG.js +1 -0
  199. package/web/dist/assets/core-BuMqAF_1.js +12 -0
  200. package/web/dist/assets/css-BsVw1vtW.js +1 -0
  201. package/web/dist/assets/dialog-DFvXTkFv.js +1 -0
  202. package/web/dist/assets/diff-view-C9b8AITq.js +4 -0
  203. package/web/dist/assets/diff-woXpYk--.js +1 -0
  204. package/web/dist/assets/dist-FBs4wmIY.js +1 -0
  205. package/web/dist/assets/dist-o_kVMXjg.js +1 -0
  206. package/web/dist/assets/engine-javascript-FQvzRocB.js +141 -0
  207. package/web/dist/assets/file-BVuztAOd.js +1 -0
  208. package/web/dist/assets/folder-0pJ1NZ_S.js +1 -0
  209. package/web/dist/assets/git-pull-request-DJkAMT0f.js +1 -0
  210. package/web/dist/assets/git-toolbar-CwhQdx3J.js +1 -0
  211. package/web/dist/assets/github-D0rAX4-r.js +1 -0
  212. package/web/dist/assets/go-rLFTqkRN.js +1 -0
  213. package/web/dist/assets/highlighted-body-OFNGDK62-DGHHie_q.js +1 -0
  214. package/web/dist/assets/highlighter-BKVEsACy.js +3 -0
  215. package/web/dist/assets/html-BY76lXLH.js +1 -0
  216. package/web/dist/assets/index-CEkI7QxN.css +2 -0
  217. package/web/dist/assets/index-CO2lH2et.js +11 -0
  218. package/web/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  219. package/web/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  220. package/web/dist/assets/javascript-BgS3c2Ky.js +1 -0
  221. package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  222. package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  223. package/web/dist/assets/json-qhed-kSA.js +1 -0
  224. package/web/dist/assets/jsonc-CYpm1nAK.js +1 -0
  225. package/web/dist/assets/jsx-Bz0zcwM4.js +1 -0
  226. package/web/dist/assets/lib-CNGa3Dig.js +1 -0
  227. package/web/dist/assets/loader-circle-BV3DO9mE.js +1 -0
  228. package/web/dist/assets/markdown-BYOwaDjH.js +1 -0
  229. package/web/dist/assets/markdown-D4-ADgH9.js +1 -0
  230. package/web/dist/assets/mermaid-GHXKKRXX-DM2VsGBG.js +1 -0
  231. package/web/dist/assets/open-mercato-toBr6SOa.svg +11 -0
  232. package/web/dist/assets/prompt-templates-C7ptKvDd.js +15 -0
  233. package/web/dist/assets/python-gzcpVVnB.js +1 -0
  234. package/web/dist/assets/react-dom-Ddtik4qM.js +1 -0
  235. package/web/dist/assets/refresh-cw-DCa-Rlwz.js +1 -0
  236. package/web/dist/assets/repo-git-DIhryGDx.js +1 -0
  237. package/web/dist/assets/run-diff-Dyow8B1q.js +3 -0
  238. package/web/dist/assets/run-header-jx9-18b8.js +1 -0
  239. package/web/dist/assets/rust-Cfkwpbl8.js +1 -0
  240. package/web/dist/assets/search-x-CoCyl7zT.js +1 -0
  241. package/web/dist/assets/shellscript-CLZ0U2zV.js +1 -0
  242. package/web/dist/assets/skill-detail-DB7x_FQp.js +1 -0
  243. package/web/dist/assets/skills-98vCroU8.js +1 -0
  244. package/web/dist/assets/skills-BKIoqD6K.js +1 -0
  245. package/web/dist/assets/sql-BsFa4tDR.js +1 -0
  246. package/web/dist/assets/square-terminal-B6PZX4Qp.js +1 -0
  247. package/web/dist/assets/tab-link-C5--YMQd.js +1 -0
  248. package/web/dist/assets/task-changes-ejxPGVJw.js +1 -0
  249. package/web/dist/assets/task-commits-C6GO5qT-.js +1 -0
  250. package/web/dist/assets/task-files-CatpCBXa.js +2 -0
  251. package/web/dist/assets/task-thread-BMbXDM3d.js +5 -0
  252. package/web/dist/assets/tasks-table-dgezlCcg.js +1 -0
  253. package/web/dist/assets/trash-2-CDtxmmc8.js +1 -0
  254. package/web/dist/assets/triangle-alert-6su4Js5O.js +1 -0
  255. package/web/dist/assets/tsx-udAQXfEw.js +1 -0
  256. package/web/dist/assets/typescript-bsJCZSQ-.js +1 -0
  257. package/web/dist/assets/upload-Bp7hQxll.js +1 -0
  258. package/web/dist/assets/use-desktop-CvTwojOJ.js +3 -0
  259. package/web/dist/assets/utils-rQGbOrwc.js +1 -0
  260. package/web/dist/assets/workflows-Dy2c6790.js +11 -0
  261. package/web/dist/assets/x-Br_jIDBw.js +1 -0
  262. package/web/dist/assets/yaml-rwi0_p6S.js +1 -0
  263. package/web/dist/index.html +70 -0
  264. package/web/open-mercato.svg +11 -0
@@ -0,0 +1,1511 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { onUsage, registerRunProcess, unregisterRunProcess } from '../core/process-usage.js';
6
+ import { createRunner } from '../core/runner-factory.js';
7
+ import { HANDOFF_ONLY_INSTRUCTIONS, HANDOFF_INSTRUCTIONS, appendHandoffHeartbeat, followupsEnabled, handoffPath, seedHandoffFile, } from '../handoff.js';
8
+ import { todosPath } from '../todos.js';
9
+ import { discoverSkills } from '../skills.js';
10
+ import { materializeSkillDir } from '../skills-remote.js';
11
+ import { loadConfig } from '../config.js';
12
+ import { autosaveCommit, createWorktree, resolveBaseRef, worktreeDiff, worktreeShortstat } from '../git-worktree.js';
13
+ import { getRepoInfo } from '../server/git.js';
14
+ import { loadWorkflows } from './load.js';
15
+ import { reclaimWorktrees, rematerializeReclaimedWorktree } from '../runs/retention.js';
16
+ import { extractTaskRefs, refineTaskRefs, titleRefNumber } from '../runs/task-refs.js';
17
+ import { autoNamingActive, generateRunName, liveTitleUpdatesEnabled } from '../runs/auto-name.js';
18
+ import { reviewGateEnabled } from '../runs/review-gate.js';
19
+ import { UiEventSink } from '../runs/ui-event-sink.js';
20
+ import { chainStepNote, DEFAULT_ALLOWED_TOOLS, stepKind } from './types.js';
21
+ const CHECK_OUTPUT_CAP = 20_000;
22
+ /** An interactive session that hears nothing from the user closes itself. */
23
+ export const IDLE_TIMEOUT_MS = 15 * 60_000;
24
+ /**
25
+ * Task-completion marker from the agent contract (HANDOFF_INSTRUCTIONS): a
26
+ * turn whose text ends with `CEZ:DONE` means "goal achieved, nothing to ask" —
27
+ * the session is closed right away instead of parking at `waiting` (#347).
28
+ * Detection runs on the accumulated turn text so delta-streaming backends
29
+ * (codex, opencode) can't split the marker across text events.
30
+ */
31
+ const DONE_MARKER_RE = /CEZ:DONE\s*$/;
32
+ /**
33
+ * Still-working marker from the agent contract (spec
34
+ * 2026-07-18-subagent-monitoring-status, #490): a turn whose text ends with
35
+ * `CEZ:MONITORING` means "I ended this turn but I'm still working on my own
36
+ * downstream work (a sub-agent / a command I'm monitoring), not waiting on the
37
+ * user" — cezar parks it as `running`/`activity:'monitoring'` instead of
38
+ * `waiting`, so the cockpit shows a non-attention state. `CEZ:DONE` wins if both
39
+ * appear. Detected on accumulated turn text (like `CEZ:DONE`) so delta-streaming
40
+ * backends can't split the marker across text events.
41
+ */
42
+ const MONITORING_MARKER_RE = /CEZ:MONITORING\s*$/;
43
+ /** Strip a trailing marker from one text event so transcripts stay free of
44
+ * protocol noise. Delta backends may split the marker across events — then
45
+ * it stays visible; detection above is unaffected. */
46
+ function stripDoneMarker(text) {
47
+ return text.replace(/\s*CEZ:DONE\s*$/, '');
48
+ }
49
+ /** Strip a trailing `CEZ:MONITORING` marker from one text event (see
50
+ * `stripDoneMarker`; same delta-backend caveat). */
51
+ function stripMonitoringMarker(text) {
52
+ return text.replace(/\s*CEZ:MONITORING\s*$/, '');
53
+ }
54
+ /** Periodic "cezar autosave" commit in the task worktree (spec 006). */
55
+ export const AUTOSAVE_INTERVAL_MS = 90_000;
56
+ /** The periodic autosave timer is opt-in (#471): off, a task branch carries only the
57
+ * agent's own commits plus the turn-end/pre-PR flushes — no mid-run "cezar autosave"
58
+ * noise interleaving PR history. The flushes (`autosaveCommit` at turn end and before
59
+ * a draft PR) are NOT gated: the branch must still end holding the finished state. */
60
+ export function periodicAutosaveEnabled(env = process.env) {
61
+ return env.CEZ_AUTOSAVE === '1';
62
+ }
63
+ /** Safety cap on autonomous auto-continues per run — stops a stuck agent from nudging forever. */
64
+ const MAX_AUTO_CONTINUES = 40;
65
+ const AUTONOMOUS_NUDGE = 'Continue working autonomously until the task is fully complete. Do not ask me for confirmation or clarification — make reasonable assumptions and proceed. When everything is done, end the session with your done signal.';
66
+ /**
67
+ * The effective "extra" system prompt for a run (spec §protocol v2, R2 2.3):
68
+ * the per-run override (`POST /api/runs` `systemPrompt`) REPLACES the
69
+ * `config.json` default — they are the same knob at two scopes, so the more
70
+ * specific one wins outright; they never concatenate. Whichever wins is
71
+ * ADDITIVE to the skill body and the handoff contract, which always ride
72
+ * along (see `composeSystemPrompt`). Blank strings count as unset.
73
+ */
74
+ export function resolveExtraSystemPrompt(override, configDefault) {
75
+ return override?.trim() || configDefault?.trim() || undefined;
76
+ }
77
+ /**
78
+ * Joins the parts of one agent step's system prompt in fixed order — skill
79
+ * body (most task-specific), then the run's extra prompt (user guidance, can
80
+ * amend the skill), then the handoff contract (always last, never optional in
81
+ * practice). Blank parts drop out; survivors join with the same `\n\n---\n\n`
82
+ * divider the skill+handoff composition has always used.
83
+ */
84
+ export function composeSystemPrompt(...parts) {
85
+ return parts
86
+ .map((p) => p?.trim())
87
+ .filter((p) => Boolean(p))
88
+ .join('\n\n---\n\n');
89
+ }
90
+ /** Variant letters + the fixed diversification hints (spec 010). A runs the
91
+ * task verbatim; B/C get one constant sentence each — zero configuration. */
92
+ export const VARIANT_LETTERS = ['A', 'B', 'C'];
93
+ const VARIANT_HINTS = {
94
+ A: undefined,
95
+ B: 'Approach hint: prefer the minimal, surgical change.',
96
+ C: 'Approach hint: prefer a thorough, structural approach.',
97
+ };
98
+ /**
99
+ * The mini workflow engine: executes a `WorkflowDef` against a repo, one step
100
+ * at a time, persisting every event to the RunStore (which the SSE endpoints
101
+ * relay live to the GUI). No GitHub choreography — agent steps and shell
102
+ * checks with bounded retry loops, plus live sessions: the last agent step
103
+ * stays open for follow-ups (`waiting`) until "finish", idle timeout, or
104
+ * cancel. Runs queue behind `maxParallel` slots and each run executes in its
105
+ * own git worktree on a `cez/<id8>` branch (spec 006), autosave-committed at
106
+ * turn end and before a draft PR — plus every 90 s when opted in via
107
+ * CEZ_AUTOSAVE=1 (#471). The user's working tree is never touched.
108
+ */
109
+ export class RunManager {
110
+ store;
111
+ repoRoot;
112
+ active = new Map();
113
+ // Queue + `starting` set (spec 006, janitor's pump() pattern): `starting`
114
+ // covers the window between shifting a run off the queue and the run
115
+ // registering in `active`, so parallel-slot counting is never racy.
116
+ queue = [];
117
+ starting = new Set();
118
+ // Runs parked at `waiting` (open session, ball in the user's court). They
119
+ // don't consume a `maxParallel` slot (#347) — an idle claude process costs
120
+ // memory but no tokens, queued work progressing matters more, and the idle
121
+ // timeout already bounds how long a session can sit open. Invariant:
122
+ // `waiting ⊆ active` — always cleared together via dropActive().
123
+ waiting = new Set();
124
+ pendingJobs = new Map();
125
+ pumping = false;
126
+ /**
127
+ * Runs normally isolate in worktrees and may execute in parallel. When that
128
+ * isolation is unavailable (or explicitly disabled), serialize access to
129
+ * `repoRoot` so two agents can never edit/revert the same files (#438).
130
+ */
131
+ repoRootTail = Promise.resolve();
132
+ /** `.ai/cezar` — where the per-task handoff files and todos.json live. */
133
+ dataDir;
134
+ /** Runs currently being paused by the memory guard — dedupes the ~2 s samples so one breach
135
+ * triggers one pause, not a burst. Cleared in dropActive when the run leaves the registry. */
136
+ memoryPausing = new Set();
137
+ constructor(store, repoRoot) {
138
+ this.store = store;
139
+ this.repoRoot = repoRoot;
140
+ this.dataDir = join(repoRoot, '.ai/cezar');
141
+ // Memory guard (#memory-guard): the shared process-tree sampler already ticks ~every 2 s for
142
+ // the runs table; piggyback on it to enforce the per-task memory ceiling.
143
+ onUsage((snapshot) => void this.enforceMemoryLimit(snapshot));
144
+ }
145
+ /**
146
+ * Pause any active run whose whole process tree exceeds `config.memoryLimitMb`, freeing its
147
+ * slot so the queue advances (#memory-guard). "Pause" closes the session — freeing the tree's
148
+ * memory — and leaves the run resumable via Continue; a loud warning explains why. No-op when
149
+ * no limit is set or the sampler has no data (e.g. `ps`/PowerShell unavailable).
150
+ */
151
+ async enforceMemoryLimit(snapshot) {
152
+ const runIds = Object.keys(snapshot);
153
+ if (runIds.length === 0)
154
+ return;
155
+ const limitMb = (await loadConfig(this.repoRoot)).memoryLimitMb;
156
+ if (!limitMb || limitMb <= 0)
157
+ return;
158
+ const limitBytes = limitMb * 1024 * 1024;
159
+ for (const runId of runIds) {
160
+ const usage = snapshot[runId];
161
+ if (!usage || usage.rssBytes <= limitBytes)
162
+ continue;
163
+ if (this.memoryPausing.has(runId))
164
+ continue;
165
+ const state = this.active.get(runId);
166
+ if (!state?.session?.open || state.cancelled)
167
+ continue;
168
+ this.memoryPausing.add(runId);
169
+ const usedMb = Math.round(usage.rssBytes / (1024 * 1024));
170
+ this.store.appendEvent(runId, {
171
+ type: 'note',
172
+ message: `⚠ memory limit exceeded — this task's process tree is using ${usedMb} MiB (limit ${limitMb} MiB). Pausing it and letting the next queued task run; resume it with Continue.`,
173
+ });
174
+ this.store.appendEvent(runId, {
175
+ type: 'lifecycle',
176
+ message: `paused — memory limit exceeded (${usedMb} MiB > ${limitMb} MiB)`,
177
+ });
178
+ // Closing the session frees the tree and lets the normal exit path settle the run and
179
+ // pump the queue. Suppress autonomous auto-continue so the pause actually holds.
180
+ state.autonomous = false;
181
+ this.clearIdleTimer(state);
182
+ state.session.end();
183
+ }
184
+ }
185
+ /** Env the spawned claude gets so the agent can find its handoff file and
186
+ * the global inbox (spec 007; the inbox only when the run opted in).
187
+ *
188
+ * `CEZ_TODOS_FILE` is set to `''` rather than omitted when follow-ups are
189
+ * off: runners spawn with `{ ...process.env, ...spec.env }`, so omitting the
190
+ * key would let a value inherited from *this* process through — a nested
191
+ * cezar (an agent running `cez serve`/`cez run`/the test suite) would then
192
+ * write follow-ups into the parent's inbox despite the opt-out. Empty is the
193
+ * established "absent" spelling — consumers guard with `if (todosFile)`. */
194
+ agentEnv(runId, generateFollowups = true) {
195
+ return {
196
+ CEZ_HANDOFF_FILE: handoffPath(this.dataDir, runId),
197
+ CEZ_TASK_ID: runId,
198
+ CEZ_TODOS_FILE: generateFollowups ? todosPath(this.dataDir) : '',
199
+ };
200
+ }
201
+ startRun(workflow, input, group) {
202
+ const run = this.store.createRun({
203
+ title: makeRunTitle(input.task, workflow) + (group ? ` (${group.variant})` : ''),
204
+ workflow: workflow.name,
205
+ task: input.task,
206
+ model: input.model,
207
+ runner: input.runner,
208
+ // The global inbox is the ceiling on the per-run flag (#471). Enforced here rather than
209
+ // at the HTTP route because `cezar run`, the inbox's own "▶ Run" and variants all reach
210
+ // startRun directly — a route-level gate would leave those writing todos.json.
211
+ generateFollowups: followupsEnabled() ? input.generateFollowups : false,
212
+ // Persist autonomy on the record (#489) so the terminal review gate
213
+ // (`settleSuccess`) and the group-pick winner-park can honor it — mid-run
214
+ // auto-nudge reads `input.autonomous` (`execute`), but the record is the
215
+ // only source those after-the-fact consumers have.
216
+ autonomous: input.autonomous === true,
217
+ groupId: group?.groupId,
218
+ variant: group?.variant,
219
+ steps: workflow.steps.map((s) => ({ id: s.id, name: s.name ?? s.id, kind: stepKind(s) })),
220
+ });
221
+ // Persist the full definition so a queued run survives a restart (#367) —
222
+ // ad-hoc "(planned)" chains exist nowhere else to re-resolve from.
223
+ this.store.updateRun(run.id, { workflowDef: workflow });
224
+ // Step-0 reference extraction (task auto-naming spec): the regex layer's
225
+ // numbers persist immediately; the namer may add the kind it verified later.
226
+ const skillHint = workflow.steps.find((s) => stepKind(s) === 'agent' && s.skill)?.skill?.trim();
227
+ const refs = refineTaskRefs(extractTaskRefs(input.task), skillHint);
228
+ if (refs.prNumber !== undefined || refs.issueNumber !== undefined) {
229
+ this.store.updateRun(run.id, {
230
+ ...(refs.prNumber !== undefined ? { prNumber: refs.prNumber } : {}),
231
+ ...(refs.issueNumber !== undefined ? { issueNumber: refs.issueNumber } : {}),
232
+ });
233
+ }
234
+ // Fire-and-forget LLM naming (task auto-naming spec): the heuristic title
235
+ // above shows instantly; the namer's short title replaces it when (and if)
236
+ // the model answers. Never awaited, never fails the run.
237
+ void this.autoNameRun(run.id, skillHint, input.task);
238
+ this.pendingJobs.set(run.id, { workflow, input });
239
+ this.queue.push(run.id);
240
+ void this.pump();
241
+ return run;
242
+ }
243
+ /**
244
+ * Parallel variants (spec 010): N runs of the same workflow on the same
245
+ * task, sharing a groupId. Variant A gets the task verbatim; B and C get a
246
+ * fixed one-line approach hint appended to the *task input* (not the step
247
+ * template), so diversification works with any workflow. The normal queue
248
+ * applies — with maxParallel=2 a third variant simply waits.
249
+ */
250
+ startVariants(workflow, input, count) {
251
+ const groupId = randomUUID();
252
+ return VARIANT_LETTERS.slice(0, Math.min(Math.max(count, 1), VARIANT_LETTERS.length)).map((variant) => {
253
+ const hint = VARIANT_HINTS[variant];
254
+ const task = hint ? `${input.task}\n\n${hint}` : input.task;
255
+ return this.startRun(workflow, { ...input, task }, { groupId, variant });
256
+ });
257
+ }
258
+ /**
259
+ * Start queued runs while parallel slots are free. `maxParallel` comes from
260
+ * `.ai/cezar/config.json` (default 2); a non-git directory degrades to 1
261
+ * sequential run in the repo root (spec 006 degradation rule).
262
+ */
263
+ async pump() {
264
+ if (this.pumping)
265
+ return;
266
+ this.pumping = true;
267
+ try {
268
+ const repo = await getRepoInfo(this.repoRoot);
269
+ const maxParallel = repo ? (await loadConfig(this.repoRoot)).maxParallel : 1;
270
+ // `waiting` runs don't hold a slot (#347). A message into a waiting run
271
+ // resumes it even when that momentarily exceeds maxParallel — resumed
272
+ // conversations must never be blocked by the queue.
273
+ const busy = () => this.active.size + this.starting.size - this.waiting.size;
274
+ while (this.queue.length > 0 && busy() < maxParallel) {
275
+ const runId = this.queue.shift();
276
+ if (!runId)
277
+ break;
278
+ const job = this.pendingJobs.get(runId);
279
+ this.pendingJobs.delete(runId);
280
+ if (!job)
281
+ continue;
282
+ this.starting.add(runId);
283
+ void this.execute(runId, job.workflow, job.input).catch((err) => {
284
+ const message = err instanceof Error ? err.message : String(err);
285
+ this.store.updateRun(runId, {
286
+ status: 'failed',
287
+ error: `engine crashed: ${message}`,
288
+ finishedAt: new Date().toISOString(),
289
+ });
290
+ const state = this.active.get(runId);
291
+ if (state) {
292
+ this.clearIdleTimer(state);
293
+ this.clearAutosaveTimer(state);
294
+ }
295
+ this.starting.delete(runId);
296
+ this.dropActive(runId);
297
+ void this.pump();
298
+ });
299
+ }
300
+ }
301
+ finally {
302
+ this.pumping = false;
303
+ }
304
+ }
305
+ /**
306
+ * Startup recovery (#367) — re-adopt runs that were live when the previous
307
+ * cezar process exited (requires the store opened with `keepLive`):
308
+ * - `queued` → back into the queue (FIFO by createdAt), from the persisted
309
+ * workflowDef (or the catalog by name for older records);
310
+ * - `waiting` → the turn was over and the ball was in the user's court —
311
+ * settle exactly like a closed session (review/done, Continue still works);
312
+ * - `running` → mark interrupted, then immediately resume the last agent
313
+ * session via the Continue path, pointing the agent at its handoff file.
314
+ * Call once, before the server starts taking requests.
315
+ */
316
+ async recover() {
317
+ const live = this.store
318
+ .listRuns()
319
+ .filter((r) => ['queued', 'waiting', 'running'].includes(r.status))
320
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
321
+ for (const run of live) {
322
+ if (run.status === 'queued') {
323
+ const workflow = await this.reviveWorkflow(run);
324
+ if (workflow) {
325
+ // Re-apply the inbox ceiling (#471). `execute()` gates again at spawn time, so the
326
+ // agent is safe either way — but a run queued while the inbox was on and recovered
327
+ // after it was switched off would otherwise keep echoing `generateFollowups: true`
328
+ // on a run that demonstrably produced none. Normalize the record, the way startRun
329
+ // does, so the stored answer matches what actually happens.
330
+ const generateFollowups = followupsEnabled() ? run.generateFollowups : false;
331
+ if (generateFollowups !== run.generateFollowups) {
332
+ this.store.updateRun(run.id, { generateFollowups });
333
+ }
334
+ this.pendingJobs.set(run.id, {
335
+ workflow,
336
+ input: {
337
+ task: run.task,
338
+ model: run.model,
339
+ runner: run.runner,
340
+ generateFollowups,
341
+ // Re-thread autonomy (#489): the rebuilt input feeds `execute`,
342
+ // whose mid-run auto-nudge reads `input.autonomous`. Without this a
343
+ // recovered queued autonomous run would run non-autonomously (no
344
+ // auto-nudge) and later wrongly park at `review`.
345
+ autonomous: run.autonomous,
346
+ },
347
+ });
348
+ this.queue.push(run.id);
349
+ this.store.appendEvent(run.id, { type: 'lifecycle', message: 'cezar restarted — task re-queued' });
350
+ }
351
+ else {
352
+ this.store.updateRun(run.id, {
353
+ status: 'failed',
354
+ error: 'interrupted — workflow definition not recoverable after a restart',
355
+ finishedAt: new Date().toISOString(),
356
+ });
357
+ this.store.appendEvent(run.id, {
358
+ type: 'lifecycle',
359
+ message: 'cezar restarted — workflow definition not recoverable, task failed',
360
+ });
361
+ }
362
+ continue;
363
+ }
364
+ if (run.status === 'waiting') {
365
+ for (const step of run.steps) {
366
+ if (step.status === 'waiting' || step.status === 'running') {
367
+ this.store.updateStep(run.id, step.id, { status: 'done', finishedAt: new Date().toISOString() });
368
+ }
369
+ }
370
+ this.store.appendEvent(run.id, {
371
+ type: 'lifecycle',
372
+ message: 'cezar restarted — the open session was settled',
373
+ });
374
+ await this.settleSuccess(run.id);
375
+ continue;
376
+ }
377
+ // `running`: the process died mid-turn. Mark it interrupted (the state
378
+ // continueRun expects), then pick the work back up from the last session.
379
+ const finishedAt = new Date().toISOString();
380
+ for (const step of run.steps) {
381
+ if (step.status === 'running' || step.status === 'waiting') {
382
+ this.store.updateStep(run.id, step.id, { status: 'failed', finishedAt });
383
+ }
384
+ }
385
+ this.store.updateRun(run.id, {
386
+ status: 'failed',
387
+ error: 'interrupted — cezar process exited during the run',
388
+ finishedAt,
389
+ currentStepId: undefined,
390
+ });
391
+ const resumed = this.continueRun(run.id, 'The cezar process restarted while you were working on this task. Read the handoff file (CEZ_HANDOFF_FILE) to recover context, then continue the task from where you left off.');
392
+ this.store.appendEvent(run.id, {
393
+ type: 'lifecycle',
394
+ message: resumed.ok
395
+ ? 'cezar restarted — resuming the interrupted task from its last session'
396
+ : `cezar restarted — could not resume the interrupted task (${resumed.error ?? 'unknown'})`,
397
+ });
398
+ }
399
+ void this.pump();
400
+ }
401
+ /** The persisted definition when it looks sane, else the catalog by name. */
402
+ async reviveWorkflow(run) {
403
+ const def = run.workflowDef;
404
+ if (def && Array.isArray(def.steps)) {
405
+ return def;
406
+ }
407
+ const { workflows } = await loadWorkflows(this.repoRoot);
408
+ return workflows.find((w) => w.name === run.workflow) ?? null;
409
+ }
410
+ /** Remove a run from the live registries — keeps `waiting ⊆ active`. */
411
+ dropActive(runId) {
412
+ const state = this.active.get(runId);
413
+ state?.releaseRepoRoot?.();
414
+ if (state)
415
+ state.releaseRepoRoot = undefined;
416
+ this.waiting.delete(runId);
417
+ this.active.delete(runId);
418
+ this.memoryPausing.delete(runId);
419
+ this.lastNamerKey.delete(runId);
420
+ // A run leaving the active registry is a terminal transition (done/review/
421
+ // failed/cancelled) — the one moment the finished-worktree count can grow.
422
+ // Enforce count-based retention (#483) here so a single hook covers every
423
+ // terminal path. Fire-and-forget: retention must never delay or throw into
424
+ // the lifecycle.
425
+ void this.enforceRetention();
426
+ }
427
+ /** Reclaim finished worktrees beyond the keep-limit (#483) — directory only,
428
+ * `cez/<id8>` branch kept. Best-effort; a failure never affects run
429
+ * lifecycle. `review`/live runs are excluded by the selector. */
430
+ async enforceRetention() {
431
+ try {
432
+ const keep = (await loadConfig(this.repoRoot)).worktreeRetention;
433
+ await reclaimWorktrees(this.repoRoot, this.store, keep);
434
+ }
435
+ catch {
436
+ // retention is best-effort; swallow so terminal transitions never break.
437
+ }
438
+ }
439
+ /** Last live-refresh namer inputs per run — unchanged inputs skip the call. */
440
+ lastNamerKey = new Map();
441
+ /**
442
+ * Acquire the one-at-a-time lease for runs executing in `repoRoot`.
443
+ *
444
+ * A lease waiter is idle, so it parks in `waiting` and gives its
445
+ * `maxParallel` slot back (the #347 rule): isolated worktrees keep using
446
+ * every configured slot while root runs line up. The store status stays
447
+ * `running` — only the queue's busy count changes, so the GUI never shows a
448
+ * lease-blocked run as awaiting user input.
449
+ *
450
+ * The lease is held for the run's whole lifetime, including the idle
451
+ * `waiting` parks between agent turns. A parked session is still live and
452
+ * writes to the working tree the moment it resumes, so handing the tree to
453
+ * another run there would reintroduce the concurrent-edit bug (#438) this
454
+ * lease exists to prevent.
455
+ *
456
+ * Returns false when the run was cancelled while waiting: the lease was
457
+ * never granted and the caller must not touch the working tree.
458
+ */
459
+ async acquireRepoRoot(runId, state) {
460
+ // `cancel()` can land between the run going `running` and reaching here,
461
+ // while `interrupt` is still the default no-op — never enter the chain.
462
+ if (state.cancelled)
463
+ return false;
464
+ const previous = this.repoRootTail;
465
+ let release = () => undefined;
466
+ this.repoRootTail = new Promise((resolve) => {
467
+ release = resolve;
468
+ });
469
+ // Until `previous` resolves this run does not own the tree yet, so a drop
470
+ // during the wait must not hand the tree to the next waiter — chain our
471
+ // release behind `previous` instead of resolving the tail early.
472
+ state.releaseRepoRoot = () => {
473
+ void previous.then(release);
474
+ };
475
+ let abort = () => undefined;
476
+ const cancelled = new Promise((resolve) => {
477
+ abort = resolve;
478
+ });
479
+ const parked = state.interrupt;
480
+ state.interrupt = () => {
481
+ parked();
482
+ abort();
483
+ };
484
+ this.waiting.add(runId);
485
+ void this.pump();
486
+ try {
487
+ await Promise.race([previous, cancelled]);
488
+ }
489
+ finally {
490
+ state.interrupt = parked;
491
+ this.waiting.delete(runId);
492
+ }
493
+ if (state.cancelled)
494
+ return false;
495
+ state.releaseRepoRoot = release;
496
+ return true;
497
+ }
498
+ cancel(runId) {
499
+ // Still waiting in the queue: just drop it there.
500
+ const queuedAt = this.queue.indexOf(runId);
501
+ if (queuedAt >= 0) {
502
+ this.queue.splice(queuedAt, 1);
503
+ this.pendingJobs.delete(runId);
504
+ this.store.updateRun(runId, { status: 'cancelled', finishedAt: new Date().toISOString() });
505
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'cancelled while queued' });
506
+ return true;
507
+ }
508
+ const state = this.active.get(runId);
509
+ if (!state)
510
+ return false;
511
+ state.cancelled = true;
512
+ this.clearIdleTimer(state);
513
+ state.interrupt();
514
+ return true;
515
+ }
516
+ isActive(runId) {
517
+ return this.active.has(runId) || this.starting.has(runId) || this.queue.includes(runId);
518
+ }
519
+ /**
520
+ * Deliver a user message into the run's live claude session (mid-turn or
521
+ * while `waiting`). Returns false when there is no open session — the GUI
522
+ * then offers "Continue" instead.
523
+ */
524
+ sendMessage(runId, content) {
525
+ const state = this.active.get(runId);
526
+ if (!state?.session?.open || state.cancelled)
527
+ return false;
528
+ const text = content
529
+ .filter((b) => b.type === 'text')
530
+ .map((b) => b.text)
531
+ .join('\n');
532
+ // Persist the attached images so the thread can render them (not just count them) — the same
533
+ // on-disk store + `/images/` route the agent's own screenshots use.
534
+ const images = content
535
+ .filter((b) => b.type === 'image')
536
+ .map((b) => this.persistImage(runId, state, b.source.media_type, b.source.data))
537
+ .filter((saved) => saved !== null)
538
+ .map((saved) => saved.url);
539
+ this.store.appendEvent(runId, {
540
+ type: 'user-message',
541
+ stepId: state.currentStepId,
542
+ text,
543
+ imageCount: content.filter((b) => b.type === 'image').length,
544
+ images,
545
+ });
546
+ const delivered = state.session.sendMessage(content);
547
+ if (delivered) {
548
+ this.clearIdleTimer(state);
549
+ this.waiting.delete(runId); // resumed — the run counts against slots again
550
+ // Clear any `monitoring` activity — the agent is actively working again
551
+ // (spec 2026-07-18-subagent-monitoring-status, #490).
552
+ this.store.updateRun(runId, { status: 'running', activity: undefined });
553
+ if (state.currentStepId) {
554
+ this.store.updateStep(runId, state.currentStepId, { status: 'running' });
555
+ }
556
+ }
557
+ return delivered;
558
+ }
559
+ /** Close the open session gracefully — the run then completes as `done`
560
+ * (or rests at `review` when the worktree holds changes, spec 009).
561
+ * On a run already resting at `review` (no session — the engine loop is
562
+ * over), "Finish" is the third review exit: accept the changes without a
563
+ * PR and flip straight to `done`. */
564
+ finish(runId) {
565
+ const state = this.active.get(runId);
566
+ if (state?.session?.open) {
567
+ this.clearIdleTimer(state);
568
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'session closed by user' });
569
+ state.session.end();
570
+ return true;
571
+ }
572
+ const run = this.store.getRun(runId);
573
+ if (run?.status === 'review' && !this.isActive(runId)) {
574
+ this.store.updateRun(runId, { status: 'done' });
575
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'review accepted — finished without a PR' });
576
+ return true;
577
+ }
578
+ return false;
579
+ }
580
+ /**
581
+ * "Continue" (spec 003): reopen a finished run's claude session in-process
582
+ * (`claude --resume <sessionId>`) as a new synthetic step. The session then
583
+ * behaves exactly like an interactive step: `waiting` after each turn,
584
+ * messages via sendMessage, closed by finish/idle/cancel.
585
+ */
586
+ continueRun(runId, text) {
587
+ if (this.active.has(runId))
588
+ return { ok: false, error: 'run is still active' };
589
+ const run = this.store.getRun(runId);
590
+ if (!run)
591
+ return { ok: false, error: 'not found' };
592
+ // `review` is continuable too — that's the "Send back" path (spec 009).
593
+ if (!['done', 'failed', 'cancelled', 'review'].includes(run.status)) {
594
+ return { ok: false, error: `cannot continue a ${run.status} run` };
595
+ }
596
+ const sessionId = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
597
+ if (!sessionId)
598
+ return { ok: false, error: 'no agent session to resume' };
599
+ const continuations = run.steps.filter((s) => s.id.startsWith('continue-')).length;
600
+ const stepId = `continue-${continuations + 1}`;
601
+ this.store.addStep(runId, { id: stepId, name: 'Continue', kind: 'agent' });
602
+ void this.runContinuation(runId, stepId, sessionId, text?.trim() || 'Continue.').catch((err) => {
603
+ const message = err instanceof Error ? err.message : String(err);
604
+ this.store.updateRun(runId, {
605
+ status: 'failed',
606
+ error: `continue crashed: ${message}`,
607
+ finishedAt: new Date().toISOString(),
608
+ });
609
+ this.dropActive(runId);
610
+ void this.pump();
611
+ });
612
+ return { ok: true };
613
+ }
614
+ async runContinuation(runId, stepId, sessionId, prompt) {
615
+ // Continuation runs in the task's worktree when it still exists (spec
616
+ // 006) — the resumed session sees exactly what the original run left.
617
+ // Retention (#483) may have reclaimed this run's worktree directory while
618
+ // keeping its branch and worktreePath. Re-materialize it on resume and clear
619
+ // the stamp so the session regains its isolated tree and the run is eligible
620
+ // for retention again — otherwise it keeps a dir on disk while staying
621
+ // invisible to the enforcer forever. Best-effort; falls back to repoRoot.
622
+ await rematerializeReclaimedWorktree(this.repoRoot, this.store, runId);
623
+ const record = this.store.getRun(runId);
624
+ // The env is a live ceiling: a run created while the inbox was on must not keep writing
625
+ // follow-ups after it is switched off.
626
+ const generateFollowups = followupsEnabled() && record?.generateFollowups !== false;
627
+ const cwd = record?.worktreePath && existsSync(record.worktreePath)
628
+ ? record.worktreePath
629
+ : this.repoRoot;
630
+ const state = { cancelled: false, interrupt: () => undefined, cwd };
631
+ this.active.set(runId, state);
632
+ if (state.cwd === this.repoRoot) {
633
+ this.store.appendEvent(runId, {
634
+ type: 'note',
635
+ message: 'waiting for exclusive access to the repository working tree',
636
+ });
637
+ if (!(await this.acquireRepoRoot(runId, state))) {
638
+ this.store.updateRun(runId, {
639
+ status: 'cancelled',
640
+ finishedAt: new Date().toISOString(),
641
+ currentStepId: undefined,
642
+ });
643
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'run cancelled' });
644
+ this.dropActive(runId);
645
+ void this.pump();
646
+ return;
647
+ }
648
+ }
649
+ this.armAutosave(state);
650
+ if (record)
651
+ seedHandoffFile(this.dataDir, record); // idempotent — normally already there
652
+ this.store.updateRun(runId, {
653
+ status: 'running',
654
+ error: undefined,
655
+ finishedAt: undefined,
656
+ currentStepId: stepId,
657
+ activity: undefined, // resuming a monitoring run — it's actively working again (#490)
658
+ });
659
+ this.store.updateStep(runId, stepId, {
660
+ status: 'running',
661
+ iterations: 1,
662
+ startedAt: new Date().toISOString(),
663
+ sessionId,
664
+ });
665
+ this.store.appendEvent(runId, { type: 'step-start', stepId, name: 'Continue', kind: 'agent', iteration: 1 });
666
+ this.store.appendEvent(runId, { type: 'user-message', stepId, text: prompt, imageCount: 0 });
667
+ let stepCost = 0;
668
+ let turnText = '';
669
+ const sink = this.makeUiSink(runId, stepId);
670
+ const onEvent = (event) => {
671
+ if (event.type === 'image') {
672
+ const saved = this.persistImage(runId, state, event.mediaType, event.data);
673
+ if (saved)
674
+ this.store.appendEvent(runId, { type: 'image', stepId, ...saved });
675
+ return;
676
+ }
677
+ if (event.type === 'text') {
678
+ turnText += event.text;
679
+ const text = stripMonitoringMarker(stripDoneMarker(event.text));
680
+ if (text)
681
+ this.store.appendEvent(runId, { type: 'text', text, stepId });
682
+ return;
683
+ }
684
+ this.store.appendEvent(runId, { ...event, stepId });
685
+ if (event.type === 'session') {
686
+ this.store.updateStep(runId, stepId, { sessionId: event.sessionId });
687
+ }
688
+ if (event.type === 'token-usage') {
689
+ this.store.updateStep(runId, stepId, { tokensUsed: event.tokensUsed });
690
+ }
691
+ if (event.type === 'cost') {
692
+ stepCost += event.usd;
693
+ this.store.updateStep(runId, stepId, { costUsd: stepCost });
694
+ }
695
+ if (event.type === 'turn-end') {
696
+ // Belt-and-braces: v2 `turn.completed` already flushed the delta
697
+ // coalescers; the v1 turn boundary flushes again (idempotent) so no
698
+ // buffered delta can outlive its turn.
699
+ sink.flushAll();
700
+ void this.recordTurnEnd(runId, turnText); // titleSummary + diffStat (#389)
701
+ const sessionOpen = !state.cancelled && state.session?.open;
702
+ const done = sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
703
+ const monitoring = sessionOpen && !done && MONITORING_MARKER_RE.test(turnText.trimEnd());
704
+ turnText = '';
705
+ if (done) {
706
+ // Goal achieved (agent contract, #347) — same as in runAgentStep.
707
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'goal achieved — session closed' });
708
+ appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — goal achieved, session closed');
709
+ state.session?.end();
710
+ return;
711
+ }
712
+ if (sessionOpen) {
713
+ // Autonomous (#autonomous): never hand the ball back to the user. Nudge the agent to
714
+ // keep going (bounded by MAX_AUTO_CONTINUES) instead of parking at `waiting`.
715
+ const autoContinued = state.autonomous &&
716
+ (state.autoContinues ?? 0) < MAX_AUTO_CONTINUES &&
717
+ !state.cancelled &&
718
+ (() => {
719
+ const sent = state.session?.sendMessage([{ type: 'text', text: AUTONOMOUS_NUDGE }]);
720
+ if (!sent)
721
+ return false;
722
+ state.autoContinues = (state.autoContinues ?? 0) + 1;
723
+ this.store.appendEvent(runId, {
724
+ type: 'note',
725
+ message: `autonomous — continuing without pausing (${state.autoContinues}/${MAX_AUTO_CONTINUES})`,
726
+ });
727
+ return true;
728
+ })();
729
+ if (!autoContinued) {
730
+ // `CEZ:MONITORING` → non-attention `running`/`activity:'monitoring'`
731
+ // instead of `waiting` (spec 2026-07-18-subagent-monitoring-status,
732
+ // #490). Same lifecycle as waiting (frees the slot, keeps the idle
733
+ // timer). The autonomous nudge above still wins over monitoring.
734
+ if (monitoring) {
735
+ this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
736
+ this.store.updateStep(runId, stepId, { status: 'running' });
737
+ }
738
+ else {
739
+ this.store.updateRun(runId, { status: 'waiting', activity: undefined });
740
+ this.store.updateStep(runId, stepId, { status: 'waiting' });
741
+ }
742
+ this.waiting.add(runId);
743
+ this.armIdleTimer(runId, state);
744
+ void this.pump();
745
+ }
746
+ }
747
+ appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${monitoring ? 'monitoring' : sessionOpen ? 'waiting' : 'running'}`);
748
+ }
749
+ };
750
+ const runner = createRunner(record?.runner ?? 'claude');
751
+ const session = runner.startSession({
752
+ // The Continue step is a fresh agent session on the same run — the
753
+ // run's extra system prompt (already resolved at execute time and
754
+ // echoed on the record) rides along with the handoff contract.
755
+ systemPrompt: composeSystemPrompt(record?.systemPrompt, generateFollowups ? HANDOFF_INSTRUCTIONS : HANDOFF_ONLY_INSTRUCTIONS),
756
+ userPrompt: prompt,
757
+ cwd: state.cwd,
758
+ allowedTools: DEFAULT_ALLOWED_TOOLS,
759
+ additionalDirectories: [join(this.dataDir, 'runs')],
760
+ env: this.agentEnv(runId, generateFollowups),
761
+ sessionId,
762
+ resume: true,
763
+ timeoutMs: 0,
764
+ }, onEvent, { onUiEvent: (event) => sink.handle(event) });
765
+ state.session = session;
766
+ state.currentStepId = stepId;
767
+ state.interrupt = () => session.interrupt();
768
+ if (session.pid !== undefined)
769
+ registerRunProcess(runId, session.pid);
770
+ const finishedAt = () => new Date().toISOString();
771
+ try {
772
+ await session.result;
773
+ sink.sessionEnded(state.cancelled ? 'cancelled' : 'end_turn');
774
+ if (state.cancelled) {
775
+ this.store.updateStep(runId, stepId, { status: 'cancelled', finishedAt: finishedAt() });
776
+ this.store.updateRun(runId, { status: 'cancelled', finishedAt: finishedAt(), currentStepId: undefined });
777
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'run cancelled' });
778
+ appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=cancelled`);
779
+ }
780
+ else {
781
+ this.store.updateStep(runId, stepId, { status: 'done', finishedAt: finishedAt() });
782
+ this.store.appendEvent(runId, { type: 'step-end', stepId, status: 'done' });
783
+ await this.settleSuccess(runId);
784
+ appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=done`);
785
+ }
786
+ }
787
+ catch (err) {
788
+ const message = err instanceof Error ? err.message : String(err);
789
+ sink.sessionEnded('error', message);
790
+ this.store.updateStep(runId, stepId, { status: 'failed', error: message, finishedAt: finishedAt() });
791
+ appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=failed`);
792
+ this.store.updateRun(runId, {
793
+ status: 'failed',
794
+ error: `continue failed: ${message}`,
795
+ finishedAt: finishedAt(),
796
+ currentStepId: undefined,
797
+ });
798
+ this.store.appendEvent(runId, { type: 'lifecycle', message: `continue failed — ${message}` });
799
+ }
800
+ finally {
801
+ this.recordUsagePeaks(runId);
802
+ this.clearIdleTimer(state);
803
+ this.clearAutosaveTimer(state);
804
+ if (state.cwd !== this.repoRoot)
805
+ await autosaveCommit(state.cwd);
806
+ this.dropActive(runId);
807
+ void this.pump();
808
+ }
809
+ }
810
+ // ---- execution -----------------------------------------------------------
811
+ async execute(runId, workflow, input) {
812
+ const state = {
813
+ cancelled: false,
814
+ interrupt: () => undefined,
815
+ cwd: this.repoRoot,
816
+ autonomous: input.autonomous === true,
817
+ autoContinues: 0,
818
+ };
819
+ this.active.set(runId, state);
820
+ this.starting.delete(runId);
821
+ const emit = (event) => this.store.appendEvent(runId, event);
822
+ // Resolve the agent backend for this run: the task choice (GUI) wins over
823
+ // the config default. Per-step `runner` can still override it below.
824
+ const config = await loadConfig(this.repoRoot);
825
+ const taskBackend = input.runner ?? config.defaultRunner;
826
+ // Extra system prompt (R2 2.3): POST override > config default; echoed on
827
+ // the record so the UI/API can show what the run actually used.
828
+ const extraSystemPrompt = resolveExtraSystemPrompt(input.systemPrompt, config.systemPrompt);
829
+ this.store.updateRun(runId, {
830
+ status: 'running',
831
+ startedAt: new Date().toISOString(),
832
+ runner: taskBackend,
833
+ systemPrompt: extraSystemPrompt,
834
+ });
835
+ emit({ type: 'lifecycle', message: `run started — workflow "${workflow.name}" (runner: ${taskBackend})` });
836
+ // Worktree per task (spec 006): the agent works on its own branch in
837
+ // `.ai/cezar/worktrees/<id>`, never in the user's working tree. A Git task
838
+ // that requests isolation fails closed if the worktree cannot be
839
+ // established; only explicit opt-out and non-Git modes run in place.
840
+ const repo = await getRepoInfo(this.repoRoot);
841
+ if (repo && input.worktree === false) {
842
+ // Composer opt-out: run in the repo working tree, no branch/worktree
843
+ // (read-only skills — review, summarize — that never need isolation).
844
+ emit({ type: 'note', message: 'worktree off — running in the repo working tree' });
845
+ }
846
+ else if (repo) {
847
+ // Fork from the configured base branch (config.json `baseBranch`, e.g.
848
+ // `develop`) — also the target of the eventual draft PR. Unresolvable
849
+ // (typo, not fetched) → note + the currently checked-out branch.
850
+ //
851
+ // A task that already recorded a fork point keeps it: its worktree is
852
+ // reused as-is, and re-resolving against a since-changed config would
853
+ // silently re-anchor the `merge-base` every diff/shortstat is measured
854
+ // from, shifting "what did this task change" under an existing task.
855
+ const recorded = this.store.getRun(runId)?.baseBranch;
856
+ let base = recorded ?? repo.branch;
857
+ const configured = recorded ? undefined : config.baseBranch;
858
+ if (configured) {
859
+ const resolved = await resolveBaseRef(this.repoRoot, configured);
860
+ if (resolved) {
861
+ base = resolved;
862
+ }
863
+ else {
864
+ emit({
865
+ type: 'note',
866
+ message: `configured base branch "${configured}" not found (locally or on origin) — using "${repo.branch}"`,
867
+ });
868
+ }
869
+ }
870
+ try {
871
+ const wt = await createWorktree(this.repoRoot, runId, base);
872
+ state.cwd = wt.path;
873
+ this.store.updateRun(runId, {
874
+ worktreePath: wt.path,
875
+ branch: wt.branch,
876
+ baseBranch: wt.baseBranch,
877
+ });
878
+ emit({ type: 'note', message: `worktree ready — branch ${wt.branch} (base ${wt.baseBranch})` });
879
+ this.armAutosave(state);
880
+ }
881
+ catch (err) {
882
+ const message = err instanceof Error ? err.message : String(err);
883
+ const error = `worktree creation failed: ${message}`;
884
+ emit({ type: 'note', message: `${error} — task stopped before workflow execution` });
885
+ this.store.updateRun(runId, {
886
+ status: 'failed',
887
+ error,
888
+ finishedAt: new Date().toISOString(),
889
+ currentStepId: undefined,
890
+ });
891
+ emit({ type: 'lifecycle', message: `run failed — ${error}` });
892
+ this.dropActive(runId);
893
+ void this.pump();
894
+ return;
895
+ }
896
+ }
897
+ else {
898
+ emit({ type: 'note', message: 'not a git repository — running in place, one task at a time' });
899
+ }
900
+ if (state.cwd === this.repoRoot) {
901
+ emit({
902
+ type: 'note',
903
+ message: 'waiting for exclusive access to the repository working tree',
904
+ });
905
+ // A cancel during the wait leaves the lease ungranted; the step loop
906
+ // below breaks on `cancelled` before touching the tree and settles the
907
+ // run through the usual path.
908
+ await this.acquireRepoRoot(runId, state);
909
+ }
910
+ // Handoff journal (spec 007) — seeded after the worktree exists so the
911
+ // header can name the branch. Idempotent: an existing file stays as-is.
912
+ const seeded = this.store.getRun(runId);
913
+ if (seeded)
914
+ seedHandoffFile(this.dataDir, seeded);
915
+ const skills = await discoverSkills(this.repoRoot);
916
+ const retriesUsed = new Map();
917
+ let checkFailure = null;
918
+ let runError = null;
919
+ // Persist the task's attached images so the thread's initial bubble can render them
920
+ // (#image-display); they still ride the first agent step's opening message below.
921
+ if (input.images?.length) {
922
+ const urls = input.images
923
+ .filter((b) => b.type === 'image')
924
+ .map((b) => this.persistImage(runId, state, b.source.media_type, b.source.data))
925
+ .filter((saved) => saved !== null)
926
+ .map((saved) => saved.url);
927
+ if (urls.length)
928
+ this.store.updateRun(runId, { taskImages: urls });
929
+ }
930
+ // Task screenshots go with the FIRST agent step's opening message only —
931
+ // later steps and retry loops run in fresh sessions without them.
932
+ let startImages = input.images;
933
+ const lastAgentIdx = findLastAgentStepIndex(workflow);
934
+ let i = 0;
935
+ while (i < workflow.steps.length) {
936
+ if (state.cancelled)
937
+ break;
938
+ const step = workflow.steps[i];
939
+ const kind = stepKind(step);
940
+ const record = this.store.getRun(runId)?.steps.find((s) => s.id === step.id);
941
+ const iteration = (record?.iterations ?? 0) + 1;
942
+ this.store.updateRun(runId, { currentStepId: step.id });
943
+ this.store.updateStep(runId, step.id, {
944
+ status: 'running',
945
+ iterations: iteration,
946
+ startedAt: new Date().toISOString(),
947
+ error: undefined,
948
+ });
949
+ emit({ type: 'step-start', stepId: step.id, name: step.name ?? step.id, kind, iteration });
950
+ if (kind === 'agent') {
951
+ // The last agent step of the workflow is interactive: after its turn
952
+ // the session stays open for follow-ups until finish/idle/cancel.
953
+ const interactive = i === lastAgentIdx && i === workflow.steps.length - 1;
954
+ const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, startImages, taskBackend, extraSystemPrompt, chainStepNote(workflow.steps, i));
955
+ startImages = undefined;
956
+ checkFailure = null;
957
+ if (state.cancelled)
958
+ break;
959
+ if (failure) {
960
+ this.finishStep(runId, step.id, 'failed', failure, emit);
961
+ runError = `step "${step.id}" failed: ${failure}`;
962
+ break;
963
+ }
964
+ this.finishStep(runId, step.id, 'done', undefined, emit);
965
+ i++;
966
+ continue;
967
+ }
968
+ const { ok, output } = await this.runCheckStep(state, step, emit);
969
+ if (state.cancelled)
970
+ break;
971
+ if (ok) {
972
+ this.finishStep(runId, step.id, 'done', undefined, emit);
973
+ i++;
974
+ continue;
975
+ }
976
+ const used = retriesUsed.get(step.id) ?? 0;
977
+ if (step.onFail && used < step.onFail.max) {
978
+ retriesUsed.set(step.id, used + 1);
979
+ checkFailure = output;
980
+ this.finishStep(runId, step.id, 'failed', 'check failed — looping back', emit);
981
+ const retryIdx = workflow.steps.findIndex((s) => s.id === step.onFail?.retry);
982
+ emit({
983
+ type: 'note',
984
+ stepId: step.id,
985
+ message: `check failed — retrying from "${step.onFail.retry}" (attempt ${used + 1}/${step.onFail.max})`,
986
+ });
987
+ // Steps we're about to re-run go back to pending so the GUI rail
988
+ // reads top-to-bottom truthfully.
989
+ for (const s of workflow.steps.slice(retryIdx, i + 1)) {
990
+ this.store.updateStep(runId, s.id, { status: 'pending' });
991
+ }
992
+ i = retryIdx;
993
+ continue;
994
+ }
995
+ this.finishStep(runId, step.id, 'failed', `\`${step.command}\` exited non-zero`, emit);
996
+ runError = `check "${step.id}" failed${step.onFail ? ` after ${used + 1} attempts` : ''}`;
997
+ break;
998
+ }
999
+ // Final autosave: the branch always ends holding the finished state.
1000
+ this.clearAutosaveTimer(state);
1001
+ if (state.cwd !== this.repoRoot)
1002
+ await autosaveCommit(state.cwd);
1003
+ const finishedAt = new Date().toISOString();
1004
+ if (state.cancelled) {
1005
+ const run = this.store.getRun(runId);
1006
+ for (const s of run?.steps ?? []) {
1007
+ if (s.status === 'running' || s.status === 'waiting') {
1008
+ this.store.updateStep(runId, s.id, { status: 'cancelled' });
1009
+ }
1010
+ }
1011
+ this.store.updateRun(runId, { status: 'cancelled', finishedAt, currentStepId: undefined });
1012
+ emit({ type: 'lifecycle', message: 'run cancelled' });
1013
+ }
1014
+ else if (runError) {
1015
+ this.store.updateRun(runId, { status: 'failed', error: runError, finishedAt, currentStepId: undefined });
1016
+ emit({ type: 'lifecycle', message: `run failed — ${runError}` });
1017
+ }
1018
+ else {
1019
+ await this.settleSuccess(runId);
1020
+ }
1021
+ this.clearIdleTimer(state);
1022
+ this.dropActive(runId);
1023
+ void this.pump();
1024
+ }
1025
+ /** Returns an error message, or null on success. */
1026
+ async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, images, taskBackend, extraSystemPrompt,
1027
+ /** The chain-boundary note for this step (#410), or undefined when the
1028
+ * workflow has a single agent step and there is no boundary to explain. */
1029
+ chainNote) {
1030
+ let systemPrompt;
1031
+ if (step.skill) {
1032
+ const skill = skills.find((s) => s.name === step.skill);
1033
+ if (skill) {
1034
+ // The body alone often does not identify the selected skill. Keep its
1035
+ // name and catalog description in the normalized runner payload so a
1036
+ // numeric task such as "432" still gives the model enough context to
1037
+ // describe the work — and therefore derive a useful title (#432).
1038
+ systemPrompt = skillSystemPrompt(skill);
1039
+ // Directory team skills (SKILL.md + references/) get materialized
1040
+ // into <cwd>/.claude/skills/<name>/ — the run's worktree when there
1041
+ // is one — so claude sees the companion files on disk; the shared
1042
+ // info/exclude keeps them out of git (and out of autosave commits).
1043
+ if (skill.source === 'team' && skill.team?.dir) {
1044
+ const seeded = await materializeSkillDir(state.cwd, skill).catch(() => false);
1045
+ if (seeded) {
1046
+ emit({
1047
+ type: 'note',
1048
+ stepId: step.id,
1049
+ message: `team skill "${skill.name}" materialized to .claude/skills/${skill.name}/`,
1050
+ });
1051
+ }
1052
+ }
1053
+ }
1054
+ else {
1055
+ emit({
1056
+ type: 'note',
1057
+ stepId: step.id,
1058
+ message: `skill "${step.skill}" not found in .ai/cezar/skills, .ai/skills or the team skills repo — running with the plain prompt`,
1059
+ });
1060
+ }
1061
+ }
1062
+ let userPrompt = applyTemplate(step.prompt ?? '{{task}}', input.task);
1063
+ if (chainNote)
1064
+ userPrompt = `${chainNote}\n\n---\n\n${userPrompt}`;
1065
+ if (checkFailure) {
1066
+ userPrompt += `\n\nA verification command failed after the previous attempt. Fix the cause. Failing output:\n\n${checkFailure}`;
1067
+ }
1068
+ if (images?.length) {
1069
+ emit({
1070
+ type: 'note',
1071
+ stepId: step.id,
1072
+ message: `${images.length} screenshot${images.length > 1 ? 's' : ''} attached to the task`,
1073
+ });
1074
+ }
1075
+ const sessionId = randomUUID();
1076
+ this.store.updateStep(runId, step.id, { sessionId });
1077
+ const stepRecord = this.store.getRun(runId)?.steps.find((s) => s.id === step.id);
1078
+ const startTokens = stepRecord?.tokensUsed ?? 0;
1079
+ let stepCost = stepRecord?.costUsd ?? 0;
1080
+ let turnText = '';
1081
+ const sink = this.makeUiSink(runId, step.id);
1082
+ const onEvent = (event) => {
1083
+ if (event.type === 'image') {
1084
+ const saved = this.persistImage(runId, state, event.mediaType, event.data);
1085
+ if (saved)
1086
+ emit({ type: 'image', stepId: step.id, ...saved });
1087
+ return;
1088
+ }
1089
+ if (event.type === 'text') {
1090
+ turnText += event.text;
1091
+ const text = stripMonitoringMarker(stripDoneMarker(event.text));
1092
+ if (text)
1093
+ emit({ type: 'text', text, stepId: step.id });
1094
+ return;
1095
+ }
1096
+ emit({ ...event, stepId: step.id });
1097
+ if (event.type === 'session') {
1098
+ // Codex/OpenCode mint their own session id — persist it so resume works.
1099
+ this.store.updateStep(runId, step.id, { sessionId: event.sessionId });
1100
+ }
1101
+ if (event.type === 'token-usage') {
1102
+ this.store.updateStep(runId, step.id, { tokensUsed: startTokens + event.tokensUsed });
1103
+ }
1104
+ if (event.type === 'cost') {
1105
+ stepCost += event.usd;
1106
+ this.store.updateStep(runId, step.id, { costUsd: stepCost });
1107
+ }
1108
+ if (event.type === 'turn-end') {
1109
+ // v2 `turn.completed` already flushed the coalescers; the v1 turn
1110
+ // boundary flushes again (idempotent) as a backstop.
1111
+ sink.flushAll();
1112
+ void this.recordTurnEnd(runId, turnText); // titleSummary + diffStat (#389)
1113
+ const sessionOpen = !state.cancelled && state.session?.open;
1114
+ const done = interactive && sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
1115
+ const monitoring = interactive && sessionOpen && !done && MONITORING_MARKER_RE.test(turnText.trimEnd());
1116
+ turnText = '';
1117
+ if (done) {
1118
+ // Goal achieved (agent contract, #347): close the session instead
1119
+ // of parking at `waiting` — the run completes and frees its slot.
1120
+ emit({ type: 'lifecycle', message: 'goal achieved — session closed' });
1121
+ appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — goal achieved, session closed');
1122
+ state.session?.end();
1123
+ return;
1124
+ }
1125
+ const waiting = interactive && sessionOpen;
1126
+ if (waiting) {
1127
+ // Turn over, session open. Either the ball is in the user's court
1128
+ // (`waiting`), or the agent declared it is still working on its own
1129
+ // downstream work with `CEZ:MONITORING` — then park as
1130
+ // `running`/`activity:'monitoring'`, a non-attention state, instead of
1131
+ // raising "needs you" (spec 2026-07-18-subagent-monitoring-status, #490).
1132
+ // Lifecycle is identical either way: the run frees its slot and keeps
1133
+ // the idle timer, so even a stalled monitoring run is reclaimed.
1134
+ if (monitoring) {
1135
+ this.store.updateRun(runId, { status: 'running', activity: 'monitoring' });
1136
+ this.store.updateStep(runId, step.id, { status: 'running' });
1137
+ }
1138
+ else {
1139
+ this.store.updateRun(runId, { status: 'waiting', activity: undefined });
1140
+ this.store.updateStep(runId, step.id, { status: 'waiting' });
1141
+ }
1142
+ this.waiting.add(runId);
1143
+ this.armIdleTimer(runId, state);
1144
+ void this.pump(); // the freed slot can start a queued run right away
1145
+ }
1146
+ // Cez's own heartbeat — the handoff stays current even when the
1147
+ // agent forgets to write (spec 007).
1148
+ appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${monitoring ? 'monitoring' : waiting ? 'waiting' : 'running'}`);
1149
+ }
1150
+ };
1151
+ const runner = createRunner(step.runner ?? taskBackend);
1152
+ let session;
1153
+ try {
1154
+ session = runner.startSession({
1155
+ // Skill body, then the run's extra prompt (POST override or config
1156
+ // default), then the handoff/todos contract — every agent step.
1157
+ systemPrompt: composeSystemPrompt(systemPrompt, extraSystemPrompt, followupsEnabled() && input.generateFollowups !== false
1158
+ ? HANDOFF_INSTRUCTIONS
1159
+ : HANDOFF_ONLY_INSTRUCTIONS),
1160
+ userPrompt,
1161
+ images,
1162
+ cwd: state.cwd,
1163
+ allowedTools: step.allowedTools ?? DEFAULT_ALLOWED_TOOLS,
1164
+ bashAllowlist: step.bashAllowlist,
1165
+ // The handoff file lives outside the worktree — grant access.
1166
+ additionalDirectories: [join(this.dataDir, 'runs')],
1167
+ env: this.agentEnv(runId, followupsEnabled() && input.generateFollowups !== false),
1168
+ model: step.model ?? input.model,
1169
+ sessionId,
1170
+ // Interactive sessions have no wall clock — the idle timer rules.
1171
+ timeoutMs: interactive ? 0 : undefined,
1172
+ }, onEvent, { autoEndAfterFirstTurn: !interactive, onUiEvent: (event) => sink.handle(event) });
1173
+ }
1174
+ catch (err) {
1175
+ return err instanceof Error ? err.message : String(err);
1176
+ }
1177
+ state.session = session;
1178
+ state.currentStepId = step.id;
1179
+ state.interrupt = () => session.interrupt();
1180
+ if (session.pid !== undefined)
1181
+ registerRunProcess(runId, session.pid);
1182
+ try {
1183
+ const result = await session.result;
1184
+ // v2 counterpart of v1's `done` (spec: the mappers leave session-close
1185
+ // events to the RunManager — only it knows how the session settled).
1186
+ sink.sessionEnded(state.cancelled ? 'cancelled' : 'end_turn');
1187
+ this.store.updateStep(runId, step.id, { tokensUsed: startTokens + result.tokensUsed });
1188
+ return null;
1189
+ }
1190
+ catch (err) {
1191
+ const message = err instanceof Error ? err.message : String(err);
1192
+ sink.sessionEnded('error', message); // alongside v1's fatal `error`
1193
+ return message;
1194
+ }
1195
+ finally {
1196
+ this.recordUsagePeaks(runId);
1197
+ this.clearIdleTimer(state);
1198
+ state.session = undefined;
1199
+ state.currentStepId = undefined;
1200
+ state.interrupt = () => undefined;
1201
+ }
1202
+ }
1203
+ /**
1204
+ * Protocol-v2 sink for one agent session (R2 step 2.1): the runner's
1205
+ * `onUiEvent` stream flows through here. Persisted snapshots ride the same
1206
+ * NDJSON file as v1 (the store stamps `seq`/`ts`, `appendEvent` fans them
1207
+ * out live too); coalesced `item.delta` flushes go out live-only via
1208
+ * `emitEphemeral` — raw deltas never hit disk (spec §performance
1209
+ * guardrails). One sink per session: cumulative usage dedup and the
1210
+ * item-shape cache are session-scoped, like the mapper state feeding them.
1211
+ */
1212
+ makeUiSink(runId, stepId) {
1213
+ return new UiEventSink({
1214
+ persist: (event) => this.store.appendEvent(runId, { ...event, stepId }),
1215
+ emitLive: (event) => this.store.emitEphemeral(runId, { ...event, stepId }),
1216
+ });
1217
+ }
1218
+ /**
1219
+ * Turn-end bookkeeping (#389), shared by `runAgentStep` and
1220
+ * `runContinuation` — called (fire-and-forget) from every `turn-end` event:
1221
+ *
1222
+ * - `titleSummary`: derived from the turn's text, set ONCE — only while the
1223
+ * record has none. A user's inline edit also lands in `titleSummary`
1224
+ * (see `PATCH /api/runs/:id`), so an edit is never overwritten either.
1225
+ * - `diffStat`: cheap `git diff --shortstat` vs the base, refreshed every
1226
+ * turn. Async and best-effort — a git failure becomes at most a `note`
1227
+ * event, NEVER a run failure. `updateRun` fans the record out over SSE,
1228
+ * so the list views pick both up with no extra wiring.
1229
+ *
1230
+ * Not `private` so the integration tests can drive a turn-end directly —
1231
+ * a real agent session is the only other way to reach this path.
1232
+ */
1233
+ /**
1234
+ * The namer's apply path (task auto-naming spec). Fire-and-forget: called
1235
+ * without await from `startRun` (creation) and `recordTurnEnd` (live
1236
+ * refresh). A user-owned title (`titleOrigin: 'user'`) is never overwritten;
1237
+ * namer-owned titles may be replaced by fresher namer results.
1238
+ */
1239
+ async autoNameRun(runId, skillName, task, live) {
1240
+ // CEZ_AUTONAME=0 kills all LLM naming; dry-run skips it too unless
1241
+ // CEZ_AUTONAME=1 forces the mock path — see autoNamingActive.
1242
+ if (!autoNamingActive())
1243
+ return;
1244
+ try {
1245
+ let skillDescription;
1246
+ if (skillName) {
1247
+ const skills = await discoverSkills(this.repoRoot).catch(() => []);
1248
+ skillDescription = skills.find((s) => s.name === skillName)?.description;
1249
+ }
1250
+ const result = await generateRunName(this.repoRoot, { task, skillName, skillDescription, ...live });
1251
+ if (!result)
1252
+ return;
1253
+ const run = this.store.getRun(runId);
1254
+ if (!run || run.titleOrigin === 'user')
1255
+ return;
1256
+ this.store.updateRun(runId, {
1257
+ titleSummary: result.titleSummary,
1258
+ titleOrigin: 'auto',
1259
+ ...(result.prNumber !== undefined ? { prNumber: result.prNumber } : {}),
1260
+ ...(result.issueNumber !== undefined ? { issueNumber: result.issueNumber } : {}),
1261
+ });
1262
+ }
1263
+ catch {
1264
+ // Naming is best-effort — nothing here may disturb the run.
1265
+ }
1266
+ }
1267
+ async recordTurnEnd(runId, turnText) {
1268
+ try {
1269
+ const run = this.store.getRun(runId);
1270
+ if (!run)
1271
+ return;
1272
+ // Titles are the namer's job (task auto-naming spec) — turn text is
1273
+ // deliberately NEVER a title source; see maybeRefreshTitle below.
1274
+ if (run.worktreePath && existsSync(run.worktreePath)) {
1275
+ const stat = await worktreeShortstat(run.worktreePath, run.baseBranch ?? 'HEAD');
1276
+ if (stat)
1277
+ this.store.updateRun(runId, { diffStat: stat });
1278
+ else
1279
+ this.store.appendEvent(runId, { type: 'note', message: 'diff stat unavailable — git diff --shortstat failed in the worktree' });
1280
+ }
1281
+ await this.maybeRefreshTitle(runId, turnText);
1282
+ }
1283
+ catch {
1284
+ // Bookkeeping only — nothing here may disturb the run.
1285
+ }
1286
+ }
1287
+ /**
1288
+ * Live title refresh (task auto-naming spec, step 3): re-run the namer with
1289
+ * the turn's context. Skips: toggle off (`liveTitleUpdates` config over
1290
+ * `CEZ_TITLE_UPDATES` env, default ON), user-owned title, dry-run mocks
1291
+ * (canned answers add nothing), empty turn text, unchanged namer inputs.
1292
+ */
1293
+ async maybeRefreshTitle(runId, turnText) {
1294
+ if (!autoNamingActive())
1295
+ return;
1296
+ if (!turnText.trim())
1297
+ return;
1298
+ const config = await loadConfig(this.repoRoot);
1299
+ if (!liveTitleUpdatesEnabled(config))
1300
+ return;
1301
+ const run = this.store.getRun(runId);
1302
+ if (!run || run.titleOrigin === 'user')
1303
+ return;
1304
+ const statText = run.diffStat ? `${run.diffStat.files} files, +${run.diffStat.adds} -${run.diffStat.dels}` : undefined;
1305
+ const key = `${turnText.slice(0, 200)}|${statText ?? ''}`;
1306
+ if (this.lastNamerKey.get(runId) === key)
1307
+ return;
1308
+ this.lastNamerKey.set(runId, key);
1309
+ const workflow = await this.reviveWorkflow(run);
1310
+ const skillName = workflow?.steps.find((s) => stepKind(s) === 'agent' && s.skill)?.skill?.trim();
1311
+ void this.autoNameRun(runId, skillName, run.task, { turnText, diffStat: statText });
1312
+ }
1313
+ /**
1314
+ * End-of-session telemetry (#348): stop sampling the run's process tree and
1315
+ * fold the session's peaks into the run record. `max` with existing values —
1316
+ * a run can hold several sessions (multiple agent steps, Continue) and the
1317
+ * record keeps the highest water mark across all of them.
1318
+ */
1319
+ recordUsagePeaks(runId) {
1320
+ const peaks = unregisterRunProcess(runId);
1321
+ if (!peaks)
1322
+ return;
1323
+ const run = this.store.getRun(runId);
1324
+ this.store.updateRun(runId, {
1325
+ peakRssBytes: Math.max(run?.peakRssBytes ?? 0, peaks.peakRssBytes),
1326
+ peakProcCount: Math.max(run?.peakProcCount ?? 0, peaks.peakProcCount),
1327
+ });
1328
+ }
1329
+ /**
1330
+ * Diff-first review gate (spec 009), shared by `execute` and
1331
+ * `runContinuation`: a *successful* run whose worktree holds changes rests
1332
+ * at `review` instead of `done` — the user inspects the diff first, then
1333
+ * sends feedback back, opens a draft PR, or just finishes. Failed/cancelled
1334
+ * runs never enter review; no worktree or an empty diff means plain `done`.
1335
+ *
1336
+ * The gate is opt-in (#489): the review park happens only when it is enabled
1337
+ * (`reviewGateEnabled` — config toggle over the `CEZ_REVIEW_GATE` env, default
1338
+ * OFF) AND the run is not autonomous. Autonomous runs — and runs with the gate
1339
+ * off — settle straight to `done`, leaving the diff in the worktree untouched.
1340
+ */
1341
+ async settleSuccess(runId) {
1342
+ const run = this.store.getRun(runId);
1343
+ let review = false;
1344
+ if (run?.worktreePath && existsSync(run.worktreePath)) {
1345
+ const diff = await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD');
1346
+ const hasDiff = diff.trim().length > 0 && !diff.startsWith('(diff failed');
1347
+ const config = await loadConfig(this.repoRoot);
1348
+ review = hasDiff && reviewGateEnabled(config) && run.autonomous !== true;
1349
+ }
1350
+ this.store.updateRun(runId, {
1351
+ status: review ? 'review' : 'done',
1352
+ finishedAt: new Date().toISOString(),
1353
+ currentStepId: undefined,
1354
+ });
1355
+ this.store.appendEvent(runId, {
1356
+ type: 'lifecycle',
1357
+ message: review
1358
+ ? 'changes ready for review — send feedback, open a draft PR, or finish'
1359
+ : 'run finished',
1360
+ });
1361
+ }
1362
+ /**
1363
+ * Agent screenshot (an image block inside a tool result): the base64 data
1364
+ * never enters the NDJSON event log — it lands as a file under
1365
+ * `.ai/cezar/runs/<id>-images/` and the transcript event carries only the
1366
+ * name + serving URL. Best effort: on failure the screenshot is dropped,
1367
+ * the transcript still shows the tool result's `[screenshot]` placeholder.
1368
+ */
1369
+ persistImage(runId, state, mediaType, data) {
1370
+ try {
1371
+ const ext = /png/.test(mediaType) ? 'png'
1372
+ : /jpe?g/.test(mediaType) ? 'jpg'
1373
+ : /webp/.test(mediaType) ? 'webp'
1374
+ : /gif/.test(mediaType) ? 'gif'
1375
+ : 'img';
1376
+ state.imageSeq = (state.imageSeq ?? 0) + 1;
1377
+ const name = `screenshot-${state.imageSeq}.${ext}`;
1378
+ const dir = join(this.dataDir, 'runs', `${runId}-images`);
1379
+ mkdirSync(dir, { recursive: true });
1380
+ writeFileSync(join(dir, name), Buffer.from(data, 'base64'));
1381
+ return { name, url: `/api/runs/${runId}/images/${name}` };
1382
+ }
1383
+ catch {
1384
+ return null;
1385
+ }
1386
+ }
1387
+ armIdleTimer(runId, state) {
1388
+ this.clearIdleTimer(state);
1389
+ state.idleTimer = setTimeout(() => {
1390
+ if (state.session?.open && !state.cancelled) {
1391
+ this.store.appendEvent(runId, {
1392
+ type: 'lifecycle',
1393
+ message: `session closed after ${Math.round(IDLE_TIMEOUT_MS / 60_000)}m of inactivity`,
1394
+ });
1395
+ state.session.end();
1396
+ }
1397
+ }, IDLE_TIMEOUT_MS);
1398
+ state.idleTimer.unref?.();
1399
+ }
1400
+ clearIdleTimer(state) {
1401
+ if (state.idleTimer) {
1402
+ clearTimeout(state.idleTimer);
1403
+ state.idleTimer = undefined;
1404
+ }
1405
+ }
1406
+ /** Autosave-commit the worktree every 90 s while the run lives (spec 006).
1407
+ * Opt-in via CEZ_AUTOSAVE=1 (#471) — see periodicAutosaveEnabled. */
1408
+ armAutosave(state) {
1409
+ if (!periodicAutosaveEnabled())
1410
+ return;
1411
+ if (state.cwd === this.repoRoot || state.autosaveTimer)
1412
+ return;
1413
+ state.autosaveTimer = setInterval(() => {
1414
+ void autosaveCommit(state.cwd);
1415
+ }, AUTOSAVE_INTERVAL_MS);
1416
+ state.autosaveTimer.unref?.();
1417
+ }
1418
+ clearAutosaveTimer(state) {
1419
+ if (state.autosaveTimer) {
1420
+ clearInterval(state.autosaveTimer);
1421
+ state.autosaveTimer = undefined;
1422
+ }
1423
+ }
1424
+ runCheckStep(state, step, emit) {
1425
+ const command = step.command;
1426
+ emit({ type: 'note', stepId: step.id, message: `$ ${command}` });
1427
+ return new Promise((resolve) => {
1428
+ // Check steps run in the same cwd as the agent steps — the worktree.
1429
+ const child = spawn('bash', ['-lc', command], { cwd: state.cwd, env: process.env });
1430
+ state.interrupt = () => child.kill('SIGTERM');
1431
+ let output = '';
1432
+ const collect = (chunk) => {
1433
+ if (output.length < CHECK_OUTPUT_CAP) {
1434
+ output += chunk.toString('utf8');
1435
+ if (output.length >= CHECK_OUTPUT_CAP)
1436
+ output += '\n… (output truncated)';
1437
+ }
1438
+ };
1439
+ child.stdout.on('data', collect);
1440
+ child.stderr.on('data', collect);
1441
+ child.on('error', (err) => {
1442
+ state.interrupt = () => undefined;
1443
+ const message = `failed to spawn: ${err.message}`;
1444
+ emit({ type: 'check-output', stepId: step.id, command, text: message, exitCode: -1 });
1445
+ resolve({ ok: false, output: message });
1446
+ });
1447
+ child.on('close', (code) => {
1448
+ state.interrupt = () => undefined;
1449
+ const trimmed = output.trim() || '(no output)';
1450
+ emit({ type: 'check-output', stepId: step.id, command, text: trimmed, exitCode: code ?? -1 });
1451
+ resolve({ ok: code === 0, output: trimmed });
1452
+ });
1453
+ });
1454
+ }
1455
+ finishStep(runId, stepId, status, error, emit) {
1456
+ this.store.updateStep(runId, stepId, {
1457
+ status,
1458
+ error,
1459
+ finishedAt: new Date().toISOString(),
1460
+ });
1461
+ emit({ type: 'step-end', stepId, status, ...(error ? { error } : {}) });
1462
+ appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=${status}`);
1463
+ }
1464
+ }
1465
+ function findLastAgentStepIndex(workflow) {
1466
+ for (let i = workflow.steps.length - 1; i >= 0; i--) {
1467
+ const step = workflow.steps[i];
1468
+ if (step && stepKind(step) === 'agent')
1469
+ return i;
1470
+ }
1471
+ return -1;
1472
+ }
1473
+ function applyTemplate(template, task) {
1474
+ return template.replaceAll('{{task}}', task);
1475
+ }
1476
+ /**
1477
+ * Immediate title shown while a run is queued. The namer's `titleSummary`
1478
+ * replaces it once the model answers; this is the honest, permanent fallback
1479
+ * when no model is available (#432, spec 2026-07-17-task-auto-naming). When
1480
+ * the task references a PR/issue, the number leads: `469: /om-auto-review-pr`.
1481
+ */
1482
+ export function makeRunTitle(task, workflow) {
1483
+ const firstLine = task.trim().split('\n')[0] ?? '';
1484
+ const skill = workflow.steps.find((step) => stepKind(step) === 'agent' && step.skill)?.skill?.trim();
1485
+ const contextual = skill && !firstLine.startsWith(`/${skill}`)
1486
+ ? `/${skill}${firstLine ? ` ${firstLine}` : ''}`
1487
+ : firstLine;
1488
+ const refNumber = titleRefNumber(refineTaskRefs(extractTaskRefs(task), skill));
1489
+ // `469` or `/om-auto-review-pr 469` reads as `469: /om-auto-review-pr` — the
1490
+ // number leads so it survives the tasks table's narrow truncation.
1491
+ const skillArg = skill && contextual.startsWith(`/${skill}`) ? contextual.slice(skill.length + 1).trim() : null;
1492
+ const body = refNumber !== undefined && skill && (skillArg === '' || /^#?\d+$/.test(skillArg ?? ''))
1493
+ ? `/${skill}`
1494
+ : contextual;
1495
+ const prefixed = refNumber !== undefined && !body.trimStart().replace(/^#/, '').startsWith(String(refNumber))
1496
+ ? `${refNumber}: ${body}`
1497
+ : body;
1498
+ const chars = [...(prefixed || '(untitled task)')];
1499
+ return chars.length > 80 ? `${chars.slice(0, 79).join('').trimEnd()}…` : chars.join('');
1500
+ }
1501
+ /** Skill identity is context, while the Markdown body remains instructions. */
1502
+ export function skillSystemPrompt(skill) {
1503
+ return [
1504
+ `Selected skill: /${skill.name}`,
1505
+ ...(skill.description ? [`Description: ${skill.description}`] : []),
1506
+ '',
1507
+ 'Skill instructions:',
1508
+ skill.body.trim(),
1509
+ ].join('\n');
1510
+ }
1511
+ //# sourceMappingURL=run.js.map