@pat-lewczuk/cezar 0.1.4 → 0.1.5

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 (85) hide show
  1. package/README.md +124 -37
  2. package/dist/config.d.ts +46 -0
  3. package/dist/config.js +31 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/core/agent-runner.d.ts +16 -0
  6. package/dist/core/agent-runner.js +9 -0
  7. package/dist/core/agent-runner.js.map +1 -1
  8. package/dist/core/claude-cli-runner.d.ts +6 -0
  9. package/dist/core/claude-cli-runner.js +27 -43
  10. package/dist/core/claude-cli-runner.js.map +1 -1
  11. package/dist/core/codex-app-server-runner.js +42 -5
  12. package/dist/core/codex-app-server-runner.js.map +1 -1
  13. package/dist/core/opencode-server-runner.js +42 -5
  14. package/dist/core/opencode-server-runner.js.map +1 -1
  15. package/dist/git-worktree.d.ts +21 -0
  16. package/dist/git-worktree.js +48 -4
  17. package/dist/git-worktree.js.map +1 -1
  18. package/dist/handoff.d.ts +1 -1
  19. package/dist/handoff.js +2 -0
  20. package/dist/handoff.js.map +1 -1
  21. package/dist/index.js +34 -4
  22. package/dist/index.js.map +1 -1
  23. package/dist/runs/store.d.ts +82 -1
  24. package/dist/runs/store.js +57 -5
  25. package/dist/runs/store.js.map +1 -1
  26. package/dist/server/git.js +12 -1
  27. package/dist/server/git.js.map +1 -1
  28. package/dist/server/github.d.ts +8 -29
  29. package/dist/server/github.js +7 -139
  30. package/dist/server/github.js.map +1 -1
  31. package/dist/server/pr.d.ts +5 -20
  32. package/dist/server/pr.js +4 -95
  33. package/dist/server/pr.js.map +1 -1
  34. package/dist/server/server.d.ts +30 -1
  35. package/dist/server/server.js +448 -19
  36. package/dist/server/server.js.map +1 -1
  37. package/dist/workflows/run.d.ts +90 -0
  38. package/dist/workflows/run.js +412 -29
  39. package/dist/workflows/run.js.map +1 -1
  40. package/dist/workflows/types.d.ts +16 -16
  41. package/package.json +44 -6
  42. package/scripts/check-pack.mjs +46 -0
  43. package/scripts/dev.mjs +74 -0
  44. package/scripts/mock-claude.mjs +54 -2
  45. package/scripts/test-process-usage.mjs +47 -0
  46. package/web/dist/assets/{toaster-BS2E1bhz.js → centered-state-zgEVNWsj.js} +1 -1
  47. package/web/dist/assets/compare-variants-ChHCcSQX.js +1 -0
  48. package/web/dist/assets/dialog-DDRjj-uK.js +1 -0
  49. package/web/dist/assets/{diff-view-CctnJHWe.js → diff-view-CWRO0GMe.js} +1 -1
  50. package/web/dist/assets/dist-id2CeyV-.js +1 -0
  51. package/web/dist/assets/git-toolbar-C4jo602L.js +1 -0
  52. package/web/dist/assets/github-BOIzQFwE.js +1 -0
  53. package/web/dist/assets/index-Cv1pQs9U.js +11 -0
  54. package/web/dist/assets/repo-git-BuyJiqna.js +1 -0
  55. package/web/dist/assets/{run-diff-C7mfxj7f.js → run-diff-BocG6LVd.js} +1 -1
  56. package/web/dist/assets/run-header-CDKw22ek.js +1 -0
  57. package/web/dist/assets/skill-detail-CcFFKmGO.js +1 -0
  58. package/web/dist/assets/skills-Bc_4U3yq.js +1 -0
  59. package/web/dist/assets/tab-link-C77EP3i6.js +1 -0
  60. package/web/dist/assets/task-changes-DsDgN8-x.js +1 -0
  61. package/web/dist/assets/task-commits-Dve6Fozc.js +1 -0
  62. package/web/dist/assets/{task-files-FOAAz2FB.js → task-files-BDMc_z0B.js} +1 -1
  63. package/web/dist/assets/{task-thread-Bncxe8Os.js → task-thread-CmRuPj9H.js} +2 -2
  64. package/web/dist/assets/use-desktop-CyEWsxSU.js +3 -0
  65. package/web/dist/assets/{workflows-Dg2wQg6i.js → workflows-J3z3kYic.js} +1 -1
  66. package/web/dist/index.html +5 -5
  67. package/web/app/node_modules/.vite/vitest/ca84d1343b96baa8137c943ed1860e522cacb238/results.json +0 -1
  68. package/web/app/src/components/app-shell-container.tsx +0 -76
  69. package/web/app.js +0 -3428
  70. package/web/dist/assets/compare-variants-D6fPL9cS.js +0 -1
  71. package/web/dist/assets/dialog-BEgEWFDS.js +0 -1
  72. package/web/dist/assets/dist-CW7M_Jhs.js +0 -1
  73. package/web/dist/assets/git-toolbar-D8OPPqBP.js +0 -1
  74. package/web/dist/assets/github-BYIHwIJB.js +0 -1
  75. package/web/dist/assets/index-9qG98Orw.js +0 -11
  76. package/web/dist/assets/repo-git-XTqlVOl2.js +0 -1
  77. package/web/dist/assets/run-header-CfVeY3uM.js +0 -1
  78. package/web/dist/assets/skill-detail-C9cniyId.js +0 -1
  79. package/web/dist/assets/skills-DXgY1uXJ.js +0 -1
  80. package/web/dist/assets/tab-link-phWEqSBy.js +0 -1
  81. package/web/dist/assets/task-changes-DYwnPnTN.js +0 -1
  82. package/web/dist/assets/task-commits-DZzxEzbn.js +0 -1
  83. package/web/dist/assets/use-desktop-eHm2iTXX.js +0 -3
  84. package/web/index.html +0 -108
  85. package/web/style.css +0 -1767
@@ -2,20 +2,65 @@ import { randomUUID } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
+ import { onUsage, registerRunProcess, unregisterRunProcess } from '../core/process-usage.js';
5
6
  import { createRunner } from '../core/runner-factory.js';
6
7
  import { HANDOFF_INSTRUCTIONS, appendHandoffHeartbeat, handoffPath, seedHandoffFile, } from '../handoff.js';
7
8
  import { todosPath } from '../todos.js';
8
9
  import { discoverSkills } from '../skills.js';
9
10
  import { materializeSkillDir } from '../skills-remote.js';
10
11
  import { loadConfig } from '../config.js';
11
- import { autosaveCommit, createWorktree, resolveBaseRef, worktreeDiff } from '../git-worktree.js';
12
+ import { autosaveCommit, createWorktree, resolveBaseRef, worktreeDiff, worktreeShortstat } from '../git-worktree.js';
12
13
  import { getRepoInfo } from '../server/git.js';
14
+ import { loadWorkflows } from './load.js';
15
+ import { deriveTitleSummary } from '../runs/title-summary.js';
16
+ import { UiEventSink } from '../runs/ui-event-sink.js';
13
17
  import { DEFAULT_ALLOWED_TOOLS, stepKind } from './types.js';
14
18
  const CHECK_OUTPUT_CAP = 20_000;
15
19
  /** An interactive session that hears nothing from the user closes itself. */
16
20
  export const IDLE_TIMEOUT_MS = 15 * 60_000;
21
+ /**
22
+ * Task-completion marker from the agent contract (HANDOFF_INSTRUCTIONS): a
23
+ * turn whose text ends with `CEZ:DONE` means "goal achieved, nothing to ask" —
24
+ * the session is closed right away instead of parking at `waiting` (#347).
25
+ * Detection runs on the accumulated turn text so delta-streaming backends
26
+ * (codex, opencode) can't split the marker across text events.
27
+ */
28
+ const DONE_MARKER_RE = /CEZ:DONE\s*$/;
29
+ /** Strip a trailing marker from one text event so transcripts stay free of
30
+ * protocol noise. Delta backends may split the marker across events — then
31
+ * it stays visible; detection above is unaffected. */
32
+ function stripDoneMarker(text) {
33
+ return text.replace(/\s*CEZ:DONE\s*$/, '');
34
+ }
17
35
  /** Periodic "cezar autosave" commit in the task worktree (spec 006). */
18
36
  export const AUTOSAVE_INTERVAL_MS = 90_000;
37
+ /** Safety cap on autonomous auto-continues per run — stops a stuck agent from nudging forever. */
38
+ const MAX_AUTO_CONTINUES = 40;
39
+ 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.';
40
+ /**
41
+ * The effective "extra" system prompt for a run (spec §protocol v2, R2 2.3):
42
+ * the per-run override (`POST /api/runs` `systemPrompt`) REPLACES the
43
+ * `config.json` default — they are the same knob at two scopes, so the more
44
+ * specific one wins outright; they never concatenate. Whichever wins is
45
+ * ADDITIVE to the skill body and the handoff contract, which always ride
46
+ * along (see `composeSystemPrompt`). Blank strings count as unset.
47
+ */
48
+ export function resolveExtraSystemPrompt(override, configDefault) {
49
+ return override?.trim() || configDefault?.trim() || undefined;
50
+ }
51
+ /**
52
+ * Joins the parts of one agent step's system prompt in fixed order — skill
53
+ * body (most task-specific), then the run's extra prompt (user guidance, can
54
+ * amend the skill), then the handoff contract (always last, never optional in
55
+ * practice). Blank parts drop out; survivors join with the same `\n\n---\n\n`
56
+ * divider the skill+handoff composition has always used.
57
+ */
58
+ export function composeSystemPrompt(...parts) {
59
+ return parts
60
+ .map((p) => p?.trim())
61
+ .filter((p) => Boolean(p))
62
+ .join('\n\n---\n\n');
63
+ }
19
64
  /** Variant letters + the fixed diversification hints (spec 010). A runs the
20
65
  * task verbatim; B/C get one constant sentence each — zero configuration. */
21
66
  export const VARIANT_LETTERS = ['A', 'B', 'C'];
@@ -43,14 +88,66 @@ export class RunManager {
43
88
  // registering in `active`, so parallel-slot counting is never racy.
44
89
  queue = [];
45
90
  starting = new Set();
91
+ // Runs parked at `waiting` (open session, ball in the user's court). They
92
+ // don't consume a `maxParallel` slot (#347) — an idle claude process costs
93
+ // memory but no tokens, queued work progressing matters more, and the idle
94
+ // timeout already bounds how long a session can sit open. Invariant:
95
+ // `waiting ⊆ active` — always cleared together via dropActive().
96
+ waiting = new Set();
46
97
  pendingJobs = new Map();
47
98
  pumping = false;
48
99
  /** `.ai/cezar` — where the per-task handoff files and todos.json live. */
49
100
  dataDir;
101
+ /** Runs currently being paused by the memory guard — dedupes the ~2 s samples so one breach
102
+ * triggers one pause, not a burst. Cleared in dropActive when the run leaves the registry. */
103
+ memoryPausing = new Set();
50
104
  constructor(store, repoRoot) {
51
105
  this.store = store;
52
106
  this.repoRoot = repoRoot;
53
107
  this.dataDir = join(repoRoot, '.ai/cezar');
108
+ // Memory guard (#memory-guard): the shared process-tree sampler already ticks ~every 2 s for
109
+ // the runs table; piggyback on it to enforce the per-task memory ceiling.
110
+ onUsage((snapshot) => void this.enforceMemoryLimit(snapshot));
111
+ }
112
+ /**
113
+ * Pause any active run whose whole process tree exceeds `config.memoryLimitMb`, freeing its
114
+ * slot so the queue advances (#memory-guard). "Pause" closes the session — freeing the tree's
115
+ * memory — and leaves the run resumable via Continue; a loud warning explains why. No-op when
116
+ * no limit is set or the sampler has no data (e.g. `ps`/PowerShell unavailable).
117
+ */
118
+ async enforceMemoryLimit(snapshot) {
119
+ const runIds = Object.keys(snapshot);
120
+ if (runIds.length === 0)
121
+ return;
122
+ const limitMb = (await loadConfig(this.repoRoot)).memoryLimitMb;
123
+ if (!limitMb || limitMb <= 0)
124
+ return;
125
+ const limitBytes = limitMb * 1024 * 1024;
126
+ for (const runId of runIds) {
127
+ const usage = snapshot[runId];
128
+ if (!usage || usage.rssBytes <= limitBytes)
129
+ continue;
130
+ if (this.memoryPausing.has(runId))
131
+ continue;
132
+ const state = this.active.get(runId);
133
+ if (!state?.session?.open || state.cancelled)
134
+ continue;
135
+ this.memoryPausing.add(runId);
136
+ const usedMb = Math.round(usage.rssBytes / (1024 * 1024));
137
+ this.store.appendEvent(runId, {
138
+ type: 'note',
139
+ 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.`,
140
+ });
141
+ this.store.appendEvent(runId, {
142
+ type: 'lifecycle',
143
+ message: `paused — memory limit exceeded (${usedMb} MiB > ${limitMb} MiB)`,
144
+ });
145
+ // Closing the session frees the tree and lets the normal exit path settle the run and
146
+ // pump the queue. Suppress autonomous auto-continue so the pause actually holds.
147
+ state.autonomous = false;
148
+ this.clearIdleTimer(state);
149
+ state.session.end();
150
+ }
54
151
  }
55
152
  /** Env the spawned claude gets so the agent can find its handoff file and
56
153
  * the global inbox (spec 007). */
@@ -72,6 +169,9 @@ export class RunManager {
72
169
  variant: group?.variant,
73
170
  steps: workflow.steps.map((s) => ({ id: s.id, name: s.name ?? s.id, kind: stepKind(s) })),
74
171
  });
172
+ // Persist the full definition so a queued run survives a restart (#367) —
173
+ // ad-hoc "(planned)" chains exist nowhere else to re-resolve from.
174
+ this.store.updateRun(run.id, { workflowDef: workflow });
75
175
  this.pendingJobs.set(run.id, { workflow, input });
76
176
  this.queue.push(run.id);
77
177
  void this.pump();
@@ -104,7 +204,11 @@ export class RunManager {
104
204
  try {
105
205
  const repo = await getRepoInfo(this.repoRoot);
106
206
  const maxParallel = repo ? (await loadConfig(this.repoRoot)).maxParallel : 1;
107
- while (this.queue.length > 0 && this.active.size + this.starting.size < maxParallel) {
207
+ // `waiting` runs don't hold a slot (#347). A message into a waiting run
208
+ // resumes it even when that momentarily exceeds maxParallel — resumed
209
+ // conversations must never be blocked by the queue.
210
+ const busy = () => this.active.size + this.starting.size - this.waiting.size;
211
+ while (this.queue.length > 0 && busy() < maxParallel) {
108
212
  const runId = this.queue.shift();
109
213
  if (!runId)
110
214
  break;
@@ -126,7 +230,7 @@ export class RunManager {
126
230
  this.clearAutosaveTimer(state);
127
231
  }
128
232
  this.starting.delete(runId);
129
- this.active.delete(runId);
233
+ this.dropActive(runId);
130
234
  void this.pump();
131
235
  });
132
236
  }
@@ -135,6 +239,98 @@ export class RunManager {
135
239
  this.pumping = false;
136
240
  }
137
241
  }
242
+ /**
243
+ * Startup recovery (#367) — re-adopt runs that were live when the previous
244
+ * cezar process exited (requires the store opened with `keepLive`):
245
+ * - `queued` → back into the queue (FIFO by createdAt), from the persisted
246
+ * workflowDef (or the catalog by name for older records);
247
+ * - `waiting` → the turn was over and the ball was in the user's court —
248
+ * settle exactly like a closed session (review/done, Continue still works);
249
+ * - `running` → mark interrupted, then immediately resume the last agent
250
+ * session via the Continue path, pointing the agent at its handoff file.
251
+ * Call once, before the server starts taking requests.
252
+ */
253
+ async recover() {
254
+ const live = this.store
255
+ .listRuns()
256
+ .filter((r) => ['queued', 'waiting', 'running'].includes(r.status))
257
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
258
+ for (const run of live) {
259
+ if (run.status === 'queued') {
260
+ const workflow = await this.reviveWorkflow(run);
261
+ if (workflow) {
262
+ this.pendingJobs.set(run.id, {
263
+ workflow,
264
+ input: { task: run.task, model: run.model, runner: run.runner },
265
+ });
266
+ this.queue.push(run.id);
267
+ this.store.appendEvent(run.id, { type: 'lifecycle', message: 'cezar restarted — task re-queued' });
268
+ }
269
+ else {
270
+ this.store.updateRun(run.id, {
271
+ status: 'failed',
272
+ error: 'interrupted — workflow definition not recoverable after a restart',
273
+ finishedAt: new Date().toISOString(),
274
+ });
275
+ this.store.appendEvent(run.id, {
276
+ type: 'lifecycle',
277
+ message: 'cezar restarted — workflow definition not recoverable, task failed',
278
+ });
279
+ }
280
+ continue;
281
+ }
282
+ if (run.status === 'waiting') {
283
+ for (const step of run.steps) {
284
+ if (step.status === 'waiting' || step.status === 'running') {
285
+ this.store.updateStep(run.id, step.id, { status: 'done', finishedAt: new Date().toISOString() });
286
+ }
287
+ }
288
+ this.store.appendEvent(run.id, {
289
+ type: 'lifecycle',
290
+ message: 'cezar restarted — the open session was settled',
291
+ });
292
+ await this.settleSuccess(run.id);
293
+ continue;
294
+ }
295
+ // `running`: the process died mid-turn. Mark it interrupted (the state
296
+ // continueRun expects), then pick the work back up from the last session.
297
+ const finishedAt = new Date().toISOString();
298
+ for (const step of run.steps) {
299
+ if (step.status === 'running' || step.status === 'waiting') {
300
+ this.store.updateStep(run.id, step.id, { status: 'failed', finishedAt });
301
+ }
302
+ }
303
+ this.store.updateRun(run.id, {
304
+ status: 'failed',
305
+ error: 'interrupted — cezar process exited during the run',
306
+ finishedAt,
307
+ currentStepId: undefined,
308
+ });
309
+ 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.');
310
+ this.store.appendEvent(run.id, {
311
+ type: 'lifecycle',
312
+ message: resumed.ok
313
+ ? 'cezar restarted — resuming the interrupted task from its last session'
314
+ : `cezar restarted — could not resume the interrupted task (${resumed.error ?? 'unknown'})`,
315
+ });
316
+ }
317
+ void this.pump();
318
+ }
319
+ /** The persisted definition when it looks sane, else the catalog by name. */
320
+ async reviveWorkflow(run) {
321
+ const def = run.workflowDef;
322
+ if (def && Array.isArray(def.steps)) {
323
+ return def;
324
+ }
325
+ const { workflows } = await loadWorkflows(this.repoRoot);
326
+ return workflows.find((w) => w.name === run.workflow) ?? null;
327
+ }
328
+ /** Remove a run from the live registries — keeps `waiting ⊆ active`. */
329
+ dropActive(runId) {
330
+ this.waiting.delete(runId);
331
+ this.active.delete(runId);
332
+ this.memoryPausing.delete(runId);
333
+ }
138
334
  cancel(runId) {
139
335
  // Still waiting in the queue: just drop it there.
140
336
  const queuedAt = this.queue.indexOf(runId);
@@ -169,16 +365,24 @@ export class RunManager {
169
365
  .filter((b) => b.type === 'text')
170
366
  .map((b) => b.text)
171
367
  .join('\n');
172
- const imageCount = content.filter((b) => b.type === 'image').length;
368
+ // Persist the attached images so the thread can render them (not just count them) the same
369
+ // on-disk store + `/images/` route the agent's own screenshots use.
370
+ const images = content
371
+ .filter((b) => b.type === 'image')
372
+ .map((b) => this.persistImage(runId, state, b.source.media_type, b.source.data))
373
+ .filter((saved) => saved !== null)
374
+ .map((saved) => saved.url);
173
375
  this.store.appendEvent(runId, {
174
376
  type: 'user-message',
175
377
  stepId: state.currentStepId,
176
378
  text,
177
- imageCount,
379
+ imageCount: content.filter((b) => b.type === 'image').length,
380
+ images,
178
381
  });
179
382
  const delivered = state.session.sendMessage(content);
180
383
  if (delivered) {
181
384
  this.clearIdleTimer(state);
385
+ this.waiting.delete(runId); // resumed — the run counts against slots again
182
386
  this.store.updateRun(runId, { status: 'running' });
183
387
  if (state.currentStepId) {
184
388
  this.store.updateStep(runId, state.currentStepId, { status: 'running' });
@@ -236,7 +440,7 @@ export class RunManager {
236
440
  error: `continue crashed: ${message}`,
237
441
  finishedAt: new Date().toISOString(),
238
442
  });
239
- this.active.delete(runId);
443
+ this.dropActive(runId);
240
444
  void this.pump();
241
445
  });
242
446
  return { ok: true };
@@ -268,6 +472,8 @@ export class RunManager {
268
472
  this.store.appendEvent(runId, { type: 'step-start', stepId, name: 'Continue', kind: 'agent', iteration: 1 });
269
473
  this.store.appendEvent(runId, { type: 'user-message', stepId, text: prompt, imageCount: 0 });
270
474
  let stepCost = 0;
475
+ let turnText = '';
476
+ const sink = this.makeUiSink(runId, stepId);
271
477
  const onEvent = (event) => {
272
478
  if (event.type === 'image') {
273
479
  const saved = this.persistImage(runId, state, event.mediaType, event.data);
@@ -275,6 +481,13 @@ export class RunManager {
275
481
  this.store.appendEvent(runId, { type: 'image', stepId, ...saved });
276
482
  return;
277
483
  }
484
+ if (event.type === 'text') {
485
+ turnText += event.text;
486
+ const text = stripDoneMarker(event.text);
487
+ if (text)
488
+ this.store.appendEvent(runId, { type: 'text', text, stepId });
489
+ return;
490
+ }
278
491
  this.store.appendEvent(runId, { ...event, stepId });
279
492
  if (event.type === 'session') {
280
493
  this.store.updateStep(runId, stepId, { sessionId: event.sessionId });
@@ -287,18 +500,55 @@ export class RunManager {
287
500
  this.store.updateStep(runId, stepId, { costUsd: stepCost });
288
501
  }
289
502
  if (event.type === 'turn-end') {
290
- const waiting = !state.cancelled && state.session?.open;
291
- if (waiting) {
292
- this.store.updateRun(runId, { status: 'waiting' });
293
- this.store.updateStep(runId, stepId, { status: 'waiting' });
294
- this.armIdleTimer(runId, state);
503
+ // Belt-and-braces: v2 `turn.completed` already flushed the delta
504
+ // coalescers; the v1 turn boundary flushes again (idempotent) so no
505
+ // buffered delta can outlive its turn.
506
+ sink.flushAll();
507
+ void this.recordTurnEnd(runId, turnText); // titleSummary + diffStat (#389)
508
+ const sessionOpen = !state.cancelled && state.session?.open;
509
+ const done = sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
510
+ turnText = '';
511
+ if (done) {
512
+ // Goal achieved (agent contract, #347) — same as in runAgentStep.
513
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'goal achieved — session closed' });
514
+ appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — goal achieved, session closed');
515
+ state.session?.end();
516
+ return;
295
517
  }
296
- appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${waiting ? 'waiting' : 'running'}`);
518
+ if (sessionOpen) {
519
+ // Autonomous (#autonomous): never hand the ball back to the user. Nudge the agent to
520
+ // keep going (bounded by MAX_AUTO_CONTINUES) instead of parking at `waiting`.
521
+ const autoContinued = state.autonomous &&
522
+ (state.autoContinues ?? 0) < MAX_AUTO_CONTINUES &&
523
+ !state.cancelled &&
524
+ (() => {
525
+ const sent = state.session?.sendMessage([{ type: 'text', text: AUTONOMOUS_NUDGE }]);
526
+ if (!sent)
527
+ return false;
528
+ state.autoContinues = (state.autoContinues ?? 0) + 1;
529
+ this.store.appendEvent(runId, {
530
+ type: 'note',
531
+ message: `autonomous — continuing without pausing (${state.autoContinues}/${MAX_AUTO_CONTINUES})`,
532
+ });
533
+ return true;
534
+ })();
535
+ if (!autoContinued) {
536
+ this.store.updateRun(runId, { status: 'waiting' });
537
+ this.store.updateStep(runId, stepId, { status: 'waiting' });
538
+ this.waiting.add(runId);
539
+ this.armIdleTimer(runId, state);
540
+ void this.pump();
541
+ }
542
+ }
543
+ appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${sessionOpen ? 'waiting' : 'running'}`);
297
544
  }
298
545
  };
299
546
  const runner = createRunner(record?.runner ?? 'claude');
300
547
  const session = runner.startSession({
301
- systemPrompt: HANDOFF_INSTRUCTIONS,
548
+ // The Continue step is a fresh agent session on the same run — the
549
+ // run's extra system prompt (already resolved at execute time and
550
+ // echoed on the record) rides along with the handoff contract.
551
+ systemPrompt: composeSystemPrompt(record?.systemPrompt, HANDOFF_INSTRUCTIONS),
302
552
  userPrompt: prompt,
303
553
  cwd: state.cwd,
304
554
  allowedTools: DEFAULT_ALLOWED_TOOLS,
@@ -307,13 +557,16 @@ export class RunManager {
307
557
  sessionId,
308
558
  resume: true,
309
559
  timeoutMs: 0,
310
- }, onEvent);
560
+ }, onEvent, { onUiEvent: (event) => sink.handle(event) });
311
561
  state.session = session;
312
562
  state.currentStepId = stepId;
313
563
  state.interrupt = () => session.interrupt();
564
+ if (session.pid !== undefined)
565
+ registerRunProcess(runId, session.pid);
314
566
  const finishedAt = () => new Date().toISOString();
315
567
  try {
316
568
  await session.result;
569
+ sink.sessionEnded(state.cancelled ? 'cancelled' : 'end_turn');
317
570
  if (state.cancelled) {
318
571
  this.store.updateStep(runId, stepId, { status: 'cancelled', finishedAt: finishedAt() });
319
572
  this.store.updateRun(runId, { status: 'cancelled', finishedAt: finishedAt(), currentStepId: undefined });
@@ -329,6 +582,7 @@ export class RunManager {
329
582
  }
330
583
  catch (err) {
331
584
  const message = err instanceof Error ? err.message : String(err);
585
+ sink.sessionEnded('error', message);
332
586
  this.store.updateStep(runId, stepId, { status: 'failed', error: message, finishedAt: finishedAt() });
333
587
  appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=failed`);
334
588
  this.store.updateRun(runId, {
@@ -340,39 +594,56 @@ export class RunManager {
340
594
  this.store.appendEvent(runId, { type: 'lifecycle', message: `continue failed — ${message}` });
341
595
  }
342
596
  finally {
597
+ this.recordUsagePeaks(runId);
343
598
  this.clearIdleTimer(state);
344
599
  this.clearAutosaveTimer(state);
345
600
  if (state.cwd !== this.repoRoot)
346
601
  await autosaveCommit(state.cwd);
347
- this.active.delete(runId);
602
+ this.dropActive(runId);
348
603
  void this.pump();
349
604
  }
350
605
  }
351
606
  // ---- execution -----------------------------------------------------------
352
607
  async execute(runId, workflow, input) {
353
- const state = { cancelled: false, interrupt: () => undefined, cwd: this.repoRoot };
608
+ const state = {
609
+ cancelled: false,
610
+ interrupt: () => undefined,
611
+ cwd: this.repoRoot,
612
+ autonomous: input.autonomous === true,
613
+ autoContinues: 0,
614
+ };
354
615
  this.active.set(runId, state);
355
616
  this.starting.delete(runId);
356
617
  const emit = (event) => this.store.appendEvent(runId, event);
357
618
  // Resolve the agent backend for this run: the task choice (GUI) wins over
358
619
  // the config default. Per-step `runner` can still override it below.
359
- const taskBackend = input.runner ?? (await loadConfig(this.repoRoot)).defaultRunner;
620
+ const config = await loadConfig(this.repoRoot);
621
+ const taskBackend = input.runner ?? config.defaultRunner;
622
+ // Extra system prompt (R2 2.3): POST override > config default; echoed on
623
+ // the record so the UI/API can show what the run actually used.
624
+ const extraSystemPrompt = resolveExtraSystemPrompt(input.systemPrompt, config.systemPrompt);
360
625
  this.store.updateRun(runId, {
361
626
  status: 'running',
362
627
  startedAt: new Date().toISOString(),
363
628
  runner: taskBackend,
629
+ systemPrompt: extraSystemPrompt,
364
630
  });
365
631
  emit({ type: 'lifecycle', message: `run started — workflow "${workflow.name}" (runner: ${taskBackend})` });
366
632
  // Worktree per task (spec 006): the agent works on its own branch in
367
633
  // `.ai/cezar/worktrees/<id>`, never in the user's working tree. Not a git
368
634
  // repo, or worktree creation failed → degrade to running in place.
369
635
  const repo = await getRepoInfo(this.repoRoot);
370
- if (repo) {
636
+ if (repo && input.worktree === false) {
637
+ // Composer opt-out: run in the repo working tree, no branch/worktree
638
+ // (read-only skills — review, summarize — that never need isolation).
639
+ emit({ type: 'note', message: 'worktree off — running in the repo working tree' });
640
+ }
641
+ else if (repo) {
371
642
  // Fork from the configured base branch (config.json `baseBranch`, e.g.
372
643
  // `develop`) — also the target of the eventual draft PR. Unresolvable
373
644
  // (typo, not fetched) → note + the currently checked-out branch.
374
645
  let base = repo.branch;
375
- const configured = (await loadConfig(this.repoRoot)).baseBranch;
646
+ const configured = config.baseBranch;
376
647
  if (configured) {
377
648
  const resolved = await resolveBaseRef(this.repoRoot, configured);
378
649
  if (resolved) {
@@ -413,6 +684,17 @@ export class RunManager {
413
684
  const retriesUsed = new Map();
414
685
  let checkFailure = null;
415
686
  let runError = null;
687
+ // Persist the task's attached images so the thread's initial bubble can render them
688
+ // (#image-display); they still ride the first agent step's opening message below.
689
+ if (input.images?.length) {
690
+ const urls = input.images
691
+ .filter((b) => b.type === 'image')
692
+ .map((b) => this.persistImage(runId, state, b.source.media_type, b.source.data))
693
+ .filter((saved) => saved !== null)
694
+ .map((saved) => saved.url);
695
+ if (urls.length)
696
+ this.store.updateRun(runId, { taskImages: urls });
697
+ }
416
698
  // Task screenshots go with the FIRST agent step's opening message only —
417
699
  // later steps and retry loops run in fresh sessions without them.
418
700
  let startImages = input.images;
@@ -437,7 +719,7 @@ export class RunManager {
437
719
  // The last agent step of the workflow is interactive: after its turn
438
720
  // the session stays open for follow-ups until finish/idle/cancel.
439
721
  const interactive = i === lastAgentIdx && i === workflow.steps.length - 1;
440
- const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, startImages, taskBackend);
722
+ const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, startImages, taskBackend, extraSystemPrompt);
441
723
  startImages = undefined;
442
724
  checkFailure = null;
443
725
  if (state.cancelled)
@@ -505,11 +787,11 @@ export class RunManager {
505
787
  await this.settleSuccess(runId);
506
788
  }
507
789
  this.clearIdleTimer(state);
508
- this.active.delete(runId);
790
+ this.dropActive(runId);
509
791
  void this.pump();
510
792
  }
511
793
  /** Returns an error message, or null on success. */
512
- async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, images, taskBackend) {
794
+ async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, images, taskBackend, extraSystemPrompt) {
513
795
  let systemPrompt;
514
796
  if (step.skill) {
515
797
  const skill = skills.find((s) => s.name === step.skill);
@@ -554,6 +836,8 @@ export class RunManager {
554
836
  const stepRecord = this.store.getRun(runId)?.steps.find((s) => s.id === step.id);
555
837
  const startTokens = stepRecord?.tokensUsed ?? 0;
556
838
  let stepCost = stepRecord?.costUsd ?? 0;
839
+ let turnText = '';
840
+ const sink = this.makeUiSink(runId, step.id);
557
841
  const onEvent = (event) => {
558
842
  if (event.type === 'image') {
559
843
  const saved = this.persistImage(runId, state, event.mediaType, event.data);
@@ -561,6 +845,13 @@ export class RunManager {
561
845
  emit({ type: 'image', stepId: step.id, ...saved });
562
846
  return;
563
847
  }
848
+ if (event.type === 'text') {
849
+ turnText += event.text;
850
+ const text = stripDoneMarker(event.text);
851
+ if (text)
852
+ emit({ type: 'text', text, stepId: step.id });
853
+ return;
854
+ }
564
855
  emit({ ...event, stepId: step.id });
565
856
  if (event.type === 'session') {
566
857
  // Codex/OpenCode mint their own session id — persist it so resume works.
@@ -574,12 +865,29 @@ export class RunManager {
574
865
  this.store.updateStep(runId, step.id, { costUsd: stepCost });
575
866
  }
576
867
  if (event.type === 'turn-end') {
577
- const waiting = interactive && !state.cancelled && state.session?.open;
868
+ // v2 `turn.completed` already flushed the coalescers; the v1 turn
869
+ // boundary flushes again (idempotent) as a backstop.
870
+ sink.flushAll();
871
+ void this.recordTurnEnd(runId, turnText); // titleSummary + diffStat (#389)
872
+ const sessionOpen = !state.cancelled && state.session?.open;
873
+ const done = interactive && sessionOpen && DONE_MARKER_RE.test(turnText.trimEnd());
874
+ turnText = '';
875
+ if (done) {
876
+ // Goal achieved (agent contract, #347): close the session instead
877
+ // of parking at `waiting` — the run completes and frees its slot.
878
+ emit({ type: 'lifecycle', message: 'goal achieved — session closed' });
879
+ appendHandoffHeartbeat(this.dataDir, runId, 'turn complete — goal achieved, session closed');
880
+ state.session?.end();
881
+ return;
882
+ }
883
+ const waiting = interactive && sessionOpen;
578
884
  if (waiting) {
579
885
  // Turn over, session open: the ball is in the user's court.
580
886
  this.store.updateRun(runId, { status: 'waiting' });
581
887
  this.store.updateStep(runId, step.id, { status: 'waiting' });
888
+ this.waiting.add(runId);
582
889
  this.armIdleTimer(runId, state);
890
+ void this.pump(); // the freed slot can start a queued run right away
583
891
  }
584
892
  // Cez's own heartbeat — the handoff stays current even when the
585
893
  // agent forgets to write (spec 007).
@@ -590,10 +898,9 @@ export class RunManager {
590
898
  let session;
591
899
  try {
592
900
  session = runner.startSession({
593
- // The handoff/todos contract rides along on every agent step.
594
- systemPrompt: systemPrompt
595
- ? `${systemPrompt}\n\n---\n\n${HANDOFF_INSTRUCTIONS}`
596
- : HANDOFF_INSTRUCTIONS,
901
+ // Skill body, then the run's extra prompt (POST override or config
902
+ // default), then the handoff/todos contract — every agent step.
903
+ systemPrompt: composeSystemPrompt(systemPrompt, extraSystemPrompt, HANDOFF_INSTRUCTIONS),
597
904
  userPrompt,
598
905
  images,
599
906
  cwd: state.cwd,
@@ -606,7 +913,7 @@ export class RunManager {
606
913
  sessionId,
607
914
  // Interactive sessions have no wall clock — the idle timer rules.
608
915
  timeoutMs: interactive ? 0 : undefined,
609
- }, onEvent, { autoEndAfterFirstTurn: !interactive });
916
+ }, onEvent, { autoEndAfterFirstTurn: !interactive, onUiEvent: (event) => sink.handle(event) });
610
917
  }
611
918
  catch (err) {
612
919
  return err instanceof Error ? err.message : String(err);
@@ -614,21 +921,97 @@ export class RunManager {
614
921
  state.session = session;
615
922
  state.currentStepId = step.id;
616
923
  state.interrupt = () => session.interrupt();
924
+ if (session.pid !== undefined)
925
+ registerRunProcess(runId, session.pid);
617
926
  try {
618
927
  const result = await session.result;
928
+ // v2 counterpart of v1's `done` (spec: the mappers leave session-close
929
+ // events to the RunManager — only it knows how the session settled).
930
+ sink.sessionEnded(state.cancelled ? 'cancelled' : 'end_turn');
619
931
  this.store.updateStep(runId, step.id, { tokensUsed: startTokens + result.tokensUsed });
620
932
  return null;
621
933
  }
622
934
  catch (err) {
623
- return err instanceof Error ? err.message : String(err);
935
+ const message = err instanceof Error ? err.message : String(err);
936
+ sink.sessionEnded('error', message); // alongside v1's fatal `error`
937
+ return message;
624
938
  }
625
939
  finally {
940
+ this.recordUsagePeaks(runId);
626
941
  this.clearIdleTimer(state);
627
942
  state.session = undefined;
628
943
  state.currentStepId = undefined;
629
944
  state.interrupt = () => undefined;
630
945
  }
631
946
  }
947
+ /**
948
+ * Protocol-v2 sink for one agent session (R2 step 2.1): the runner's
949
+ * `onUiEvent` stream flows through here. Persisted snapshots ride the same
950
+ * NDJSON file as v1 (the store stamps `seq`/`ts`, `appendEvent` fans them
951
+ * out live too); coalesced `item.delta` flushes go out live-only via
952
+ * `emitEphemeral` — raw deltas never hit disk (spec §performance
953
+ * guardrails). One sink per session: cumulative usage dedup and the
954
+ * item-shape cache are session-scoped, like the mapper state feeding them.
955
+ */
956
+ makeUiSink(runId, stepId) {
957
+ return new UiEventSink({
958
+ persist: (event) => this.store.appendEvent(runId, { ...event, stepId }),
959
+ emitLive: (event) => this.store.emitEphemeral(runId, { ...event, stepId }),
960
+ });
961
+ }
962
+ /**
963
+ * Turn-end bookkeeping (#389), shared by `runAgentStep` and
964
+ * `runContinuation` — called (fire-and-forget) from every `turn-end` event:
965
+ *
966
+ * - `titleSummary`: derived from the turn's text, set ONCE — only while the
967
+ * record has none. A user's inline edit also lands in `titleSummary`
968
+ * (see `PATCH /api/runs/:id`), so an edit is never overwritten either.
969
+ * - `diffStat`: cheap `git diff --shortstat` vs the base, refreshed every
970
+ * turn. Async and best-effort — a git failure becomes at most a `note`
971
+ * event, NEVER a run failure. `updateRun` fans the record out over SSE,
972
+ * so the list views pick both up with no extra wiring.
973
+ *
974
+ * Not `private` so the integration tests can drive a turn-end directly —
975
+ * a real agent session is the only other way to reach this path.
976
+ */
977
+ async recordTurnEnd(runId, turnText) {
978
+ try {
979
+ const run = this.store.getRun(runId);
980
+ if (!run)
981
+ return;
982
+ if (run.titleSummary === undefined) {
983
+ const summary = deriveTitleSummary(turnText, run.task);
984
+ if (summary)
985
+ this.store.updateRun(runId, { titleSummary: summary });
986
+ }
987
+ if (!run.worktreePath || !existsSync(run.worktreePath))
988
+ return;
989
+ const stat = await worktreeShortstat(run.worktreePath, run.baseBranch ?? 'HEAD');
990
+ if (stat)
991
+ this.store.updateRun(runId, { diffStat: stat });
992
+ else
993
+ this.store.appendEvent(runId, { type: 'note', message: 'diff stat unavailable — git diff --shortstat failed in the worktree' });
994
+ }
995
+ catch {
996
+ // Bookkeeping only — nothing here may disturb the run.
997
+ }
998
+ }
999
+ /**
1000
+ * End-of-session telemetry (#348): stop sampling the run's process tree and
1001
+ * fold the session's peaks into the run record. `max` with existing values —
1002
+ * a run can hold several sessions (multiple agent steps, Continue) and the
1003
+ * record keeps the highest water mark across all of them.
1004
+ */
1005
+ recordUsagePeaks(runId) {
1006
+ const peaks = unregisterRunProcess(runId);
1007
+ if (!peaks)
1008
+ return;
1009
+ const run = this.store.getRun(runId);
1010
+ this.store.updateRun(runId, {
1011
+ peakRssBytes: Math.max(run?.peakRssBytes ?? 0, peaks.peakRssBytes),
1012
+ peakProcCount: Math.max(run?.peakProcCount ?? 0, peaks.peakProcCount),
1013
+ });
1014
+ }
632
1015
  /**
633
1016
  * Diff-first review gate (spec 009), shared by `execute` and
634
1017
  * `runContinuation`: a *successful* run whose worktree holds changes rests