@pat-lewczuk/cezar 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/config.d.ts +55 -0
  2. package/dist/config.js +44 -0
  3. package/dist/config.js.map +1 -0
  4. package/dist/core/agent-runner.d.ts +13 -0
  5. package/dist/core/claude-cli-runner.js +23 -2
  6. package/dist/core/claude-cli-runner.js.map +1 -1
  7. package/dist/git-worktree.d.ts +49 -0
  8. package/dist/git-worktree.js +129 -0
  9. package/dist/git-worktree.js.map +1 -0
  10. package/dist/handoff.d.ts +42 -0
  11. package/dist/handoff.js +120 -0
  12. package/dist/handoff.js.map +1 -0
  13. package/dist/index.js +27 -6
  14. package/dist/index.js.map +1 -1
  15. package/dist/planner.d.ts +17 -0
  16. package/dist/planner.js +244 -0
  17. package/dist/planner.js.map +1 -0
  18. package/dist/runs/store.d.ts +40 -13
  19. package/dist/runs/store.js +33 -3
  20. package/dist/runs/store.js.map +1 -1
  21. package/dist/server/github.d.ts +28 -0
  22. package/dist/server/github.js +133 -0
  23. package/dist/server/github.js.map +1 -0
  24. package/dist/server/launch-key.d.ts +7 -0
  25. package/dist/server/launch-key.js +33 -0
  26. package/dist/server/launch-key.js.map +1 -0
  27. package/dist/server/pr.d.ts +22 -0
  28. package/dist/server/pr.js +92 -0
  29. package/dist/server/pr.js.map +1 -0
  30. package/dist/server/server.js +370 -15
  31. package/dist/server/server.js.map +1 -1
  32. package/dist/skills-remote.d.ts +35 -0
  33. package/dist/skills-remote.js +266 -0
  34. package/dist/skills-remote.js.map +1 -0
  35. package/dist/skills.d.ts +18 -6
  36. package/dist/skills.js +8 -4
  37. package/dist/skills.js.map +1 -1
  38. package/dist/todos.d.ts +60 -0
  39. package/dist/todos.js +166 -0
  40. package/dist/todos.js.map +1 -0
  41. package/dist/workflows/load.js +5 -9
  42. package/dist/workflows/load.js.map +1 -1
  43. package/dist/workflows/run.d.ts +62 -3
  44. package/dist/workflows/run.js +332 -43
  45. package/dist/workflows/run.js.map +1 -1
  46. package/dist/workflows/types.d.ts +27 -20
  47. package/dist/workflows/types.js +21 -0
  48. package/dist/workflows/types.js.map +1 -1
  49. package/package.json +1 -1
  50. package/scripts/mock-claude.mjs +118 -0
  51. package/web/app.js +1851 -149
  52. package/web/index.html +73 -22
  53. package/web/style.css +1271 -223
@@ -1,47 +1,149 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
3
5
  import { ClaudeCliRunner } from '../core/claude-cli-runner.js';
6
+ import { HANDOFF_INSTRUCTIONS, appendHandoffHeartbeat, handoffPath, seedHandoffFile, } from '../handoff.js';
7
+ import { todosPath } from '../todos.js';
4
8
  import { discoverSkills } from '../skills.js';
9
+ import { materializeSkillDir } from '../skills-remote.js';
10
+ import { loadConfig } from '../config.js';
11
+ import { autosaveCommit, createWorktree, worktreeDiff } from '../git-worktree.js';
12
+ import { getRepoInfo } from '../server/git.js';
5
13
  import { DEFAULT_ALLOWED_TOOLS, stepKind } from './types.js';
6
14
  const CHECK_OUTPUT_CAP = 20_000;
7
15
  /** An interactive session that hears nothing from the user closes itself. */
8
16
  export const IDLE_TIMEOUT_MS = 15 * 60_000;
17
+ /** Periodic "cezar autosave" commit in the task worktree (spec 006). */
18
+ export const AUTOSAVE_INTERVAL_MS = 90_000;
19
+ /** Variant letters + the fixed diversification hints (spec 010). A runs the
20
+ * task verbatim; B/C get one constant sentence each — zero configuration. */
21
+ export const VARIANT_LETTERS = ['A', 'B', 'C'];
22
+ const VARIANT_HINTS = {
23
+ A: undefined,
24
+ B: 'Approach hint: prefer the minimal, surgical change.',
25
+ C: 'Approach hint: prefer a thorough, structural approach.',
26
+ };
9
27
  /**
10
28
  * The mini workflow engine: executes a `WorkflowDef` against a repo, one step
11
29
  * at a time, persisting every event to the RunStore (which the SSE endpoints
12
30
  * relay live to the GUI). No GitHub choreography — agent steps and shell
13
31
  * checks with bounded retry loops, plus live sessions: the last agent step
14
32
  * stays open for follow-ups (`waiting`) until "finish", idle timeout, or
15
- * cancel.
33
+ * cancel. Runs queue behind `maxParallel` slots and each run executes in its
34
+ * own git worktree on a `cez/<id8>` branch (spec 006), autosave-committed
35
+ * every 90 s — the user's working tree is never touched.
16
36
  */
17
37
  export class RunManager {
18
38
  store;
19
39
  repoRoot;
20
40
  active = new Map();
41
+ // Queue + `starting` set (spec 006, janitor's pump() pattern): `starting`
42
+ // covers the window between shifting a run off the queue and the run
43
+ // registering in `active`, so parallel-slot counting is never racy.
44
+ queue = [];
45
+ starting = new Set();
46
+ pendingJobs = new Map();
47
+ pumping = false;
48
+ /** `.ai/cezar` — where the per-task handoff files and todos.json live. */
49
+ dataDir;
21
50
  constructor(store, repoRoot) {
22
51
  this.store = store;
23
52
  this.repoRoot = repoRoot;
53
+ this.dataDir = join(repoRoot, '.ai/cezar');
24
54
  }
25
- startRun(workflow, input) {
55
+ /** Env the spawned claude gets so the agent can find its handoff file and
56
+ * the global inbox (spec 007). */
57
+ agentEnv(runId) {
58
+ return {
59
+ CEZ_HANDOFF_FILE: handoffPath(this.dataDir, runId),
60
+ CEZ_TODOS_FILE: todosPath(this.dataDir),
61
+ CEZ_TASK_ID: runId,
62
+ };
63
+ }
64
+ startRun(workflow, input, group) {
26
65
  const run = this.store.createRun({
27
- title: makeTitle(input.task),
66
+ title: makeTitle(input.task) + (group ? ` (${group.variant})` : ''),
28
67
  workflow: workflow.name,
29
68
  task: input.task,
30
69
  model: input.model,
70
+ groupId: group?.groupId,
71
+ variant: group?.variant,
31
72
  steps: workflow.steps.map((s) => ({ id: s.id, name: s.name ?? s.id, kind: stepKind(s) })),
32
73
  });
33
- void this.execute(run.id, workflow, input).catch((err) => {
34
- const message = err instanceof Error ? err.message : String(err);
35
- this.store.updateRun(run.id, {
36
- status: 'failed',
37
- error: `engine crashed: ${message}`,
38
- finishedAt: new Date().toISOString(),
39
- });
40
- this.active.delete(run.id);
41
- });
74
+ this.pendingJobs.set(run.id, { workflow, input });
75
+ this.queue.push(run.id);
76
+ void this.pump();
42
77
  return run;
43
78
  }
79
+ /**
80
+ * Parallel variants (spec 010): N runs of the same workflow on the same
81
+ * task, sharing a groupId. Variant A gets the task verbatim; B and C get a
82
+ * fixed one-line approach hint appended to the *task input* (not the step
83
+ * template), so diversification works with any workflow. The normal queue
84
+ * applies — with maxParallel=2 a third variant simply waits.
85
+ */
86
+ startVariants(workflow, input, count) {
87
+ const groupId = randomUUID();
88
+ return VARIANT_LETTERS.slice(0, Math.min(Math.max(count, 1), VARIANT_LETTERS.length)).map((variant) => {
89
+ const hint = VARIANT_HINTS[variant];
90
+ const task = hint ? `${input.task}\n\n${hint}` : input.task;
91
+ return this.startRun(workflow, { ...input, task }, { groupId, variant });
92
+ });
93
+ }
94
+ /**
95
+ * Start queued runs while parallel slots are free. `maxParallel` comes from
96
+ * `.ai/cezar/config.json` (default 2); a non-git directory degrades to 1
97
+ * sequential run in the repo root (spec 006 degradation rule).
98
+ */
99
+ async pump() {
100
+ if (this.pumping)
101
+ return;
102
+ this.pumping = true;
103
+ try {
104
+ const repo = await getRepoInfo(this.repoRoot);
105
+ const maxParallel = repo ? (await loadConfig(this.repoRoot)).maxParallel : 1;
106
+ while (this.queue.length > 0 && this.active.size + this.starting.size < maxParallel) {
107
+ const runId = this.queue.shift();
108
+ if (!runId)
109
+ break;
110
+ const job = this.pendingJobs.get(runId);
111
+ this.pendingJobs.delete(runId);
112
+ if (!job)
113
+ continue;
114
+ this.starting.add(runId);
115
+ void this.execute(runId, job.workflow, job.input).catch((err) => {
116
+ const message = err instanceof Error ? err.message : String(err);
117
+ this.store.updateRun(runId, {
118
+ status: 'failed',
119
+ error: `engine crashed: ${message}`,
120
+ finishedAt: new Date().toISOString(),
121
+ });
122
+ const state = this.active.get(runId);
123
+ if (state) {
124
+ this.clearIdleTimer(state);
125
+ this.clearAutosaveTimer(state);
126
+ }
127
+ this.starting.delete(runId);
128
+ this.active.delete(runId);
129
+ void this.pump();
130
+ });
131
+ }
132
+ }
133
+ finally {
134
+ this.pumping = false;
135
+ }
136
+ }
44
137
  cancel(runId) {
138
+ // Still waiting in the queue: just drop it there.
139
+ const queuedAt = this.queue.indexOf(runId);
140
+ if (queuedAt >= 0) {
141
+ this.queue.splice(queuedAt, 1);
142
+ this.pendingJobs.delete(runId);
143
+ this.store.updateRun(runId, { status: 'cancelled', finishedAt: new Date().toISOString() });
144
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'cancelled while queued' });
145
+ return true;
146
+ }
45
147
  const state = this.active.get(runId);
46
148
  if (!state)
47
149
  return false;
@@ -51,7 +153,7 @@ export class RunManager {
51
153
  return true;
52
154
  }
53
155
  isActive(runId) {
54
- return this.active.has(runId);
156
+ return this.active.has(runId) || this.starting.has(runId) || this.queue.includes(runId);
55
157
  }
56
158
  /**
57
159
  * Deliver a user message into the run's live claude session (mid-turn or
@@ -83,15 +185,26 @@ export class RunManager {
83
185
  }
84
186
  return delivered;
85
187
  }
86
- /** Close the open session gracefully — the run then completes as `done`. */
188
+ /** Close the open session gracefully — the run then completes as `done`
189
+ * (or rests at `review` when the worktree holds changes, spec 009).
190
+ * On a run already resting at `review` (no session — the engine loop is
191
+ * over), "Finish" is the third review exit: accept the changes without a
192
+ * PR and flip straight to `done`. */
87
193
  finish(runId) {
88
194
  const state = this.active.get(runId);
89
- if (!state?.session?.open)
90
- return false;
91
- this.clearIdleTimer(state);
92
- this.store.appendEvent(runId, { type: 'lifecycle', message: 'session closed by user' });
93
- state.session.end();
94
- return true;
195
+ if (state?.session?.open) {
196
+ this.clearIdleTimer(state);
197
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'session closed by user' });
198
+ state.session.end();
199
+ return true;
200
+ }
201
+ const run = this.store.getRun(runId);
202
+ if (run?.status === 'review' && !this.isActive(runId)) {
203
+ this.store.updateRun(runId, { status: 'done' });
204
+ this.store.appendEvent(runId, { type: 'lifecycle', message: 'review accepted — finished without a PR' });
205
+ return true;
206
+ }
207
+ return false;
95
208
  }
96
209
  /**
97
210
  * "Continue" (spec 003): reopen a finished run's claude session in-process
@@ -105,7 +218,8 @@ export class RunManager {
105
218
  const run = this.store.getRun(runId);
106
219
  if (!run)
107
220
  return { ok: false, error: 'not found' };
108
- if (!['done', 'failed', 'cancelled'].includes(run.status)) {
221
+ // `review` is continuable too — that's the "Send back" path (spec 009).
222
+ if (!['done', 'failed', 'cancelled', 'review'].includes(run.status)) {
109
223
  return { ok: false, error: `cannot continue a ${run.status} run` };
110
224
  }
111
225
  const sessionId = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
@@ -122,12 +236,22 @@ export class RunManager {
122
236
  finishedAt: new Date().toISOString(),
123
237
  });
124
238
  this.active.delete(runId);
239
+ void this.pump();
125
240
  });
126
241
  return { ok: true };
127
242
  }
128
243
  async runContinuation(runId, stepId, sessionId, prompt) {
129
- const state = { cancelled: false, interrupt: () => undefined };
244
+ // Continuation runs in the task's worktree when it still exists (spec
245
+ // 006) — the resumed session sees exactly what the original run left.
246
+ const record = this.store.getRun(runId);
247
+ const cwd = record?.worktreePath && existsSync(record.worktreePath)
248
+ ? record.worktreePath
249
+ : this.repoRoot;
250
+ const state = { cancelled: false, interrupt: () => undefined, cwd };
130
251
  this.active.set(runId, state);
252
+ this.armAutosave(state);
253
+ if (record)
254
+ seedHandoffFile(this.dataDir, record); // idempotent — normally already there
131
255
  this.store.updateRun(runId, {
132
256
  status: 'running',
133
257
  error: undefined,
@@ -144,6 +268,12 @@ export class RunManager {
144
268
  this.store.appendEvent(runId, { type: 'user-message', stepId, text: prompt, imageCount: 0 });
145
269
  let stepCost = 0;
146
270
  const onEvent = (event) => {
271
+ if (event.type === 'image') {
272
+ const saved = this.persistImage(runId, state, event.mediaType, event.data);
273
+ if (saved)
274
+ this.store.appendEvent(runId, { type: 'image', stepId, ...saved });
275
+ return;
276
+ }
147
277
  this.store.appendEvent(runId, { ...event, stepId });
148
278
  if (event.type === 'token-usage') {
149
279
  this.store.updateStep(runId, stepId, { tokensUsed: event.tokensUsed });
@@ -152,17 +282,24 @@ export class RunManager {
152
282
  stepCost += event.usd;
153
283
  this.store.updateStep(runId, stepId, { costUsd: stepCost });
154
284
  }
155
- if (event.type === 'turn-end' && !state.cancelled && state.session?.open) {
156
- this.store.updateRun(runId, { status: 'waiting' });
157
- this.store.updateStep(runId, stepId, { status: 'waiting' });
158
- this.armIdleTimer(runId, state);
285
+ if (event.type === 'turn-end') {
286
+ const waiting = !state.cancelled && state.session?.open;
287
+ if (waiting) {
288
+ this.store.updateRun(runId, { status: 'waiting' });
289
+ this.store.updateStep(runId, stepId, { status: 'waiting' });
290
+ this.armIdleTimer(runId, state);
291
+ }
292
+ appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${waiting ? 'waiting' : 'running'}`);
159
293
  }
160
294
  };
161
295
  const runner = new ClaudeCliRunner();
162
296
  const session = runner.startSession({
297
+ systemPrompt: HANDOFF_INSTRUCTIONS,
163
298
  userPrompt: prompt,
164
- cwd: this.repoRoot,
299
+ cwd: state.cwd,
165
300
  allowedTools: DEFAULT_ALLOWED_TOOLS,
301
+ additionalDirectories: [join(this.dataDir, 'runs')],
302
+ env: this.agentEnv(runId),
166
303
  sessionId,
167
304
  resume: true,
168
305
  timeoutMs: 0,
@@ -177,17 +314,19 @@ export class RunManager {
177
314
  this.store.updateStep(runId, stepId, { status: 'cancelled', finishedAt: finishedAt() });
178
315
  this.store.updateRun(runId, { status: 'cancelled', finishedAt: finishedAt(), currentStepId: undefined });
179
316
  this.store.appendEvent(runId, { type: 'lifecycle', message: 'run cancelled' });
317
+ appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=cancelled`);
180
318
  }
181
319
  else {
182
320
  this.store.updateStep(runId, stepId, { status: 'done', finishedAt: finishedAt() });
183
- this.store.updateRun(runId, { status: 'done', finishedAt: finishedAt(), currentStepId: undefined });
184
321
  this.store.appendEvent(runId, { type: 'step-end', stepId, status: 'done' });
185
- this.store.appendEvent(runId, { type: 'lifecycle', message: 'run finished' });
322
+ await this.settleSuccess(runId);
323
+ appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=done`);
186
324
  }
187
325
  }
188
326
  catch (err) {
189
327
  const message = err instanceof Error ? err.message : String(err);
190
328
  this.store.updateStep(runId, stepId, { status: 'failed', error: message, finishedAt: finishedAt() });
329
+ appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=failed`);
191
330
  this.store.updateRun(runId, {
192
331
  status: 'failed',
193
332
  error: `continue failed: ${message}`,
@@ -198,20 +337,57 @@ export class RunManager {
198
337
  }
199
338
  finally {
200
339
  this.clearIdleTimer(state);
340
+ this.clearAutosaveTimer(state);
341
+ if (state.cwd !== this.repoRoot)
342
+ await autosaveCommit(state.cwd);
201
343
  this.active.delete(runId);
344
+ void this.pump();
202
345
  }
203
346
  }
204
347
  // ---- execution -----------------------------------------------------------
205
348
  async execute(runId, workflow, input) {
206
- const state = { cancelled: false, interrupt: () => undefined };
349
+ const state = { cancelled: false, interrupt: () => undefined, cwd: this.repoRoot };
207
350
  this.active.set(runId, state);
351
+ this.starting.delete(runId);
208
352
  const emit = (event) => this.store.appendEvent(runId, event);
209
353
  this.store.updateRun(runId, { status: 'running', startedAt: new Date().toISOString() });
210
354
  emit({ type: 'lifecycle', message: `run started — workflow "${workflow.name}"` });
355
+ // Worktree per task (spec 006): the agent works on its own branch in
356
+ // `.ai/cezar/worktrees/<id>`, never in the user's working tree. Not a git
357
+ // repo, or worktree creation failed → degrade to running in place.
358
+ const repo = await getRepoInfo(this.repoRoot);
359
+ if (repo) {
360
+ try {
361
+ const wt = await createWorktree(this.repoRoot, runId, repo.branch);
362
+ state.cwd = wt.path;
363
+ this.store.updateRun(runId, {
364
+ worktreePath: wt.path,
365
+ branch: wt.branch,
366
+ baseBranch: wt.baseBranch,
367
+ });
368
+ emit({ type: 'note', message: `worktree ready — branch ${wt.branch} (base ${wt.baseBranch})` });
369
+ this.armAutosave(state);
370
+ }
371
+ catch (err) {
372
+ const message = err instanceof Error ? err.message : String(err);
373
+ emit({ type: 'note', message: `worktree creation failed (${message}) — running in the repo working tree` });
374
+ }
375
+ }
376
+ else {
377
+ emit({ type: 'note', message: 'not a git repository — running in place, one task at a time' });
378
+ }
379
+ // Handoff journal (spec 007) — seeded after the worktree exists so the
380
+ // header can name the branch. Idempotent: an existing file stays as-is.
381
+ const seeded = this.store.getRun(runId);
382
+ if (seeded)
383
+ seedHandoffFile(this.dataDir, seeded);
211
384
  const skills = await discoverSkills(this.repoRoot);
212
385
  const retriesUsed = new Map();
213
386
  let checkFailure = null;
214
387
  let runError = null;
388
+ // Task screenshots go with the FIRST agent step's opening message only —
389
+ // later steps and retry loops run in fresh sessions without them.
390
+ let startImages = input.images;
215
391
  const lastAgentIdx = findLastAgentStepIndex(workflow);
216
392
  let i = 0;
217
393
  while (i < workflow.steps.length) {
@@ -233,7 +409,8 @@ export class RunManager {
233
409
  // The last agent step of the workflow is interactive: after its turn
234
410
  // the session stays open for follow-ups until finish/idle/cancel.
235
411
  const interactive = i === lastAgentIdx && i === workflow.steps.length - 1;
236
- const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit);
412
+ const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, startImages);
413
+ startImages = undefined;
237
414
  checkFailure = null;
238
415
  if (state.cancelled)
239
416
  break;
@@ -277,6 +454,10 @@ export class RunManager {
277
454
  runError = `check "${step.id}" failed${step.onFail ? ` after ${used + 1} attempts` : ''}`;
278
455
  break;
279
456
  }
457
+ // Final autosave: the branch always ends holding the finished state.
458
+ this.clearAutosaveTimer(state);
459
+ if (state.cwd !== this.repoRoot)
460
+ await autosaveCommit(state.cwd);
280
461
  const finishedAt = new Date().toISOString();
281
462
  if (state.cancelled) {
282
463
  const run = this.store.getRun(runId);
@@ -293,25 +474,39 @@ export class RunManager {
293
474
  emit({ type: 'lifecycle', message: `run failed — ${runError}` });
294
475
  }
295
476
  else {
296
- this.store.updateRun(runId, { status: 'done', finishedAt, currentStepId: undefined });
297
- emit({ type: 'lifecycle', message: 'run finished' });
477
+ await this.settleSuccess(runId);
298
478
  }
299
479
  this.clearIdleTimer(state);
300
480
  this.active.delete(runId);
481
+ void this.pump();
301
482
  }
302
483
  /** Returns an error message, or null on success. */
303
- async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit) {
484
+ async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, images) {
304
485
  let systemPrompt;
305
486
  if (step.skill) {
306
487
  const skill = skills.find((s) => s.name === step.skill);
307
488
  if (skill) {
308
489
  systemPrompt = skill.body.trim();
490
+ // Directory team skills (SKILL.md + references/) get materialized
491
+ // into <cwd>/.claude/skills/<name>/ — the run's worktree when there
492
+ // is one — so claude sees the companion files on disk; the shared
493
+ // info/exclude keeps them out of git (and out of autosave commits).
494
+ if (skill.source === 'team' && skill.team?.dir) {
495
+ const seeded = await materializeSkillDir(state.cwd, skill).catch(() => false);
496
+ if (seeded) {
497
+ emit({
498
+ type: 'note',
499
+ stepId: step.id,
500
+ message: `team skill "${skill.name}" materialized to .claude/skills/${skill.name}/`,
501
+ });
502
+ }
503
+ }
309
504
  }
310
505
  else {
311
506
  emit({
312
507
  type: 'note',
313
508
  stepId: step.id,
314
- message: `skill "${step.skill}" not found in .ai/cezar/skills or .ai/skills — running with the plain prompt`,
509
+ message: `skill "${step.skill}" not found in .ai/cezar/skills, .ai/skills or the team skills repo — running with the plain prompt`,
315
510
  });
316
511
  }
317
512
  }
@@ -319,12 +514,25 @@ export class RunManager {
319
514
  if (checkFailure) {
320
515
  userPrompt += `\n\nA verification command failed after the previous attempt. Fix the cause. Failing output:\n\n${checkFailure}`;
321
516
  }
517
+ if (images?.length) {
518
+ emit({
519
+ type: 'note',
520
+ stepId: step.id,
521
+ message: `${images.length} screenshot${images.length > 1 ? 's' : ''} attached to the task`,
522
+ });
523
+ }
322
524
  const sessionId = randomUUID();
323
525
  this.store.updateStep(runId, step.id, { sessionId });
324
526
  const stepRecord = this.store.getRun(runId)?.steps.find((s) => s.id === step.id);
325
527
  const startTokens = stepRecord?.tokensUsed ?? 0;
326
528
  let stepCost = stepRecord?.costUsd ?? 0;
327
529
  const onEvent = (event) => {
530
+ if (event.type === 'image') {
531
+ const saved = this.persistImage(runId, state, event.mediaType, event.data);
532
+ if (saved)
533
+ emit({ type: 'image', stepId: step.id, ...saved });
534
+ return;
535
+ }
328
536
  emit({ ...event, stepId: step.id });
329
537
  if (event.type === 'token-usage') {
330
538
  this.store.updateStep(runId, step.id, { tokensUsed: startTokens + event.tokensUsed });
@@ -333,22 +541,35 @@ export class RunManager {
333
541
  stepCost += event.usd;
334
542
  this.store.updateStep(runId, step.id, { costUsd: stepCost });
335
543
  }
336
- if (event.type === 'turn-end' && interactive && !state.cancelled && state.session?.open) {
337
- // Turn over, session open: the ball is in the user's court.
338
- this.store.updateRun(runId, { status: 'waiting' });
339
- this.store.updateStep(runId, step.id, { status: 'waiting' });
340
- this.armIdleTimer(runId, state);
544
+ if (event.type === 'turn-end') {
545
+ const waiting = interactive && !state.cancelled && state.session?.open;
546
+ if (waiting) {
547
+ // Turn over, session open: the ball is in the user's court.
548
+ this.store.updateRun(runId, { status: 'waiting' });
549
+ this.store.updateStep(runId, step.id, { status: 'waiting' });
550
+ this.armIdleTimer(runId, state);
551
+ }
552
+ // Cez's own heartbeat — the handoff stays current even when the
553
+ // agent forgets to write (spec 007).
554
+ appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${waiting ? 'waiting' : 'running'}`);
341
555
  }
342
556
  };
343
557
  const runner = new ClaudeCliRunner();
344
558
  let session;
345
559
  try {
346
560
  session = runner.startSession({
347
- systemPrompt,
561
+ // The handoff/todos contract rides along on every agent step.
562
+ systemPrompt: systemPrompt
563
+ ? `${systemPrompt}\n\n---\n\n${HANDOFF_INSTRUCTIONS}`
564
+ : HANDOFF_INSTRUCTIONS,
348
565
  userPrompt,
349
- cwd: this.repoRoot,
566
+ images,
567
+ cwd: state.cwd,
350
568
  allowedTools: step.allowedTools ?? DEFAULT_ALLOWED_TOOLS,
351
569
  bashAllowlist: step.bashAllowlist,
570
+ // The handoff file lives outside the worktree — grant access.
571
+ additionalDirectories: [join(this.dataDir, 'runs')],
572
+ env: this.agentEnv(runId),
352
573
  model: step.model ?? input.model,
353
574
  sessionId,
354
575
  // Interactive sessions have no wall clock — the idle timer rules.
@@ -376,6 +597,57 @@ export class RunManager {
376
597
  state.interrupt = () => undefined;
377
598
  }
378
599
  }
600
+ /**
601
+ * Diff-first review gate (spec 009), shared by `execute` and
602
+ * `runContinuation`: a *successful* run whose worktree holds changes rests
603
+ * at `review` instead of `done` — the user inspects the diff first, then
604
+ * sends feedback back, opens a draft PR, or just finishes. Failed/cancelled
605
+ * runs never enter review; no worktree or an empty diff means plain `done`.
606
+ */
607
+ async settleSuccess(runId) {
608
+ const run = this.store.getRun(runId);
609
+ let review = false;
610
+ if (run?.worktreePath && existsSync(run.worktreePath)) {
611
+ const diff = await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD');
612
+ review = diff.trim().length > 0 && !diff.startsWith('(diff failed');
613
+ }
614
+ this.store.updateRun(runId, {
615
+ status: review ? 'review' : 'done',
616
+ finishedAt: new Date().toISOString(),
617
+ currentStepId: undefined,
618
+ });
619
+ this.store.appendEvent(runId, {
620
+ type: 'lifecycle',
621
+ message: review
622
+ ? 'changes ready for review — send feedback, open a draft PR, or finish'
623
+ : 'run finished',
624
+ });
625
+ }
626
+ /**
627
+ * Agent screenshot (an image block inside a tool result): the base64 data
628
+ * never enters the NDJSON event log — it lands as a file under
629
+ * `.ai/cezar/runs/<id>-images/` and the transcript event carries only the
630
+ * name + serving URL. Best effort: on failure the screenshot is dropped,
631
+ * the transcript still shows the tool result's `[screenshot]` placeholder.
632
+ */
633
+ persistImage(runId, state, mediaType, data) {
634
+ try {
635
+ const ext = /png/.test(mediaType) ? 'png'
636
+ : /jpe?g/.test(mediaType) ? 'jpg'
637
+ : /webp/.test(mediaType) ? 'webp'
638
+ : /gif/.test(mediaType) ? 'gif'
639
+ : 'img';
640
+ state.imageSeq = (state.imageSeq ?? 0) + 1;
641
+ const name = `screenshot-${state.imageSeq}.${ext}`;
642
+ const dir = join(this.dataDir, 'runs', `${runId}-images`);
643
+ mkdirSync(dir, { recursive: true });
644
+ writeFileSync(join(dir, name), Buffer.from(data, 'base64'));
645
+ return { name, url: `/api/runs/${runId}/images/${name}` };
646
+ }
647
+ catch {
648
+ return null;
649
+ }
650
+ }
379
651
  armIdleTimer(runId, state) {
380
652
  this.clearIdleTimer(state);
381
653
  state.idleTimer = setTimeout(() => {
@@ -395,11 +667,27 @@ export class RunManager {
395
667
  state.idleTimer = undefined;
396
668
  }
397
669
  }
670
+ /** Autosave-commit the worktree every 90 s while the run lives (spec 006). */
671
+ armAutosave(state) {
672
+ if (state.cwd === this.repoRoot || state.autosaveTimer)
673
+ return;
674
+ state.autosaveTimer = setInterval(() => {
675
+ void autosaveCommit(state.cwd);
676
+ }, AUTOSAVE_INTERVAL_MS);
677
+ state.autosaveTimer.unref?.();
678
+ }
679
+ clearAutosaveTimer(state) {
680
+ if (state.autosaveTimer) {
681
+ clearInterval(state.autosaveTimer);
682
+ state.autosaveTimer = undefined;
683
+ }
684
+ }
398
685
  runCheckStep(state, step, emit) {
399
686
  const command = step.command;
400
687
  emit({ type: 'note', stepId: step.id, message: `$ ${command}` });
401
688
  return new Promise((resolve) => {
402
- const child = spawn('bash', ['-lc', command], { cwd: this.repoRoot, env: process.env });
689
+ // Check steps run in the same cwd as the agent steps — the worktree.
690
+ const child = spawn('bash', ['-lc', command], { cwd: state.cwd, env: process.env });
403
691
  state.interrupt = () => child.kill('SIGTERM');
404
692
  let output = '';
405
693
  const collect = (chunk) => {
@@ -432,6 +720,7 @@ export class RunManager {
432
720
  finishedAt: new Date().toISOString(),
433
721
  });
434
722
  emit({ type: 'step-end', stepId, status, ...(error ? { error } : {}) });
723
+ appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=${status}`);
435
724
  }
436
725
  }
437
726
  function findLastAgentStepIndex(workflow) {