@pat-lewczuk/cezar 0.1.0 → 0.1.2
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.
- package/dist/config.d.ts +62 -0
- package/dist/config.js +49 -0
- package/dist/config.js.map +1 -0
- package/dist/core/agent-runner.d.ts +13 -0
- package/dist/core/claude-cli-runner.js +23 -2
- package/dist/core/claude-cli-runner.js.map +1 -1
- package/dist/git-worktree.d.ts +56 -0
- package/dist/git-worktree.js +143 -0
- package/dist/git-worktree.js.map +1 -0
- package/dist/handoff.d.ts +42 -0
- package/dist/handoff.js +120 -0
- package/dist/handoff.js.map +1 -0
- package/dist/index.js +27 -6
- package/dist/index.js.map +1 -1
- package/dist/planner.d.ts +17 -0
- package/dist/planner.js +244 -0
- package/dist/planner.js.map +1 -0
- package/dist/runs/store.d.ts +40 -13
- package/dist/runs/store.js +33 -3
- package/dist/runs/store.js.map +1 -1
- package/dist/server/git.d.ts +5 -0
- package/dist/server/git.js +38 -0
- package/dist/server/git.js.map +1 -1
- package/dist/server/github.d.ts +29 -0
- package/dist/server/github.js +140 -0
- package/dist/server/github.js.map +1 -0
- package/dist/server/launch-key.d.ts +7 -0
- package/dist/server/launch-key.js +33 -0
- package/dist/server/launch-key.js.map +1 -0
- package/dist/server/pr.d.ts +22 -0
- package/dist/server/pr.js +98 -0
- package/dist/server/pr.js.map +1 -0
- package/dist/server/server.js +537 -19
- package/dist/server/server.js.map +1 -1
- package/dist/skills-remote.d.ts +35 -0
- package/dist/skills-remote.js +266 -0
- package/dist/skills-remote.js.map +1 -0
- package/dist/skills.d.ts +20 -6
- package/dist/skills.js +77 -12
- package/dist/skills.js.map +1 -1
- package/dist/todos.d.ts +60 -0
- package/dist/todos.js +166 -0
- package/dist/todos.js.map +1 -0
- package/dist/workflows/load.js +8 -10
- package/dist/workflows/load.js.map +1 -1
- package/dist/workflows/run.d.ts +62 -3
- package/dist/workflows/run.js +351 -45
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +96 -26
- package/dist/workflows/types.js +73 -2
- package/dist/workflows/types.js.map +1 -1
- package/package.json +1 -1
- package/scripts/mock-claude.mjs +118 -0
- package/web/app.js +2821 -154
- package/web/index.html +82 -23
- package/web/open-mercato.svg +11 -0
- package/web/style.css +1652 -222
package/dist/workflows/run.js
CHANGED
|
@@ -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, resolveBaseRef, 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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 (
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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:
|
|
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.
|
|
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,74 @@ 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
|
+
// Fork from the configured base branch (config.json `baseBranch`, e.g.
|
|
361
|
+
// `develop`) — also the target of the eventual draft PR. Unresolvable
|
|
362
|
+
// (typo, not fetched) → note + the currently checked-out branch.
|
|
363
|
+
let base = repo.branch;
|
|
364
|
+
const configured = (await loadConfig(this.repoRoot)).baseBranch;
|
|
365
|
+
if (configured) {
|
|
366
|
+
const resolved = await resolveBaseRef(this.repoRoot, configured);
|
|
367
|
+
if (resolved) {
|
|
368
|
+
base = resolved;
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
emit({
|
|
372
|
+
type: 'note',
|
|
373
|
+
message: `configured base branch "${configured}" not found (locally or on origin) — using "${repo.branch}"`,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
try {
|
|
378
|
+
const wt = await createWorktree(this.repoRoot, runId, base);
|
|
379
|
+
state.cwd = wt.path;
|
|
380
|
+
this.store.updateRun(runId, {
|
|
381
|
+
worktreePath: wt.path,
|
|
382
|
+
branch: wt.branch,
|
|
383
|
+
baseBranch: wt.baseBranch,
|
|
384
|
+
});
|
|
385
|
+
emit({ type: 'note', message: `worktree ready — branch ${wt.branch} (base ${wt.baseBranch})` });
|
|
386
|
+
this.armAutosave(state);
|
|
387
|
+
}
|
|
388
|
+
catch (err) {
|
|
389
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
390
|
+
emit({ type: 'note', message: `worktree creation failed (${message}) — running in the repo working tree` });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
emit({ type: 'note', message: 'not a git repository — running in place, one task at a time' });
|
|
395
|
+
}
|
|
396
|
+
// Handoff journal (spec 007) — seeded after the worktree exists so the
|
|
397
|
+
// header can name the branch. Idempotent: an existing file stays as-is.
|
|
398
|
+
const seeded = this.store.getRun(runId);
|
|
399
|
+
if (seeded)
|
|
400
|
+
seedHandoffFile(this.dataDir, seeded);
|
|
211
401
|
const skills = await discoverSkills(this.repoRoot);
|
|
212
402
|
const retriesUsed = new Map();
|
|
213
403
|
let checkFailure = null;
|
|
214
404
|
let runError = null;
|
|
405
|
+
// Task screenshots go with the FIRST agent step's opening message only —
|
|
406
|
+
// later steps and retry loops run in fresh sessions without them.
|
|
407
|
+
let startImages = input.images;
|
|
215
408
|
const lastAgentIdx = findLastAgentStepIndex(workflow);
|
|
216
409
|
let i = 0;
|
|
217
410
|
while (i < workflow.steps.length) {
|
|
@@ -233,7 +426,8 @@ export class RunManager {
|
|
|
233
426
|
// The last agent step of the workflow is interactive: after its turn
|
|
234
427
|
// the session stays open for follow-ups until finish/idle/cancel.
|
|
235
428
|
const interactive = i === lastAgentIdx && i === workflow.steps.length - 1;
|
|
236
|
-
const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit);
|
|
429
|
+
const failure = await this.runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, startImages);
|
|
430
|
+
startImages = undefined;
|
|
237
431
|
checkFailure = null;
|
|
238
432
|
if (state.cancelled)
|
|
239
433
|
break;
|
|
@@ -277,6 +471,10 @@ export class RunManager {
|
|
|
277
471
|
runError = `check "${step.id}" failed${step.onFail ? ` after ${used + 1} attempts` : ''}`;
|
|
278
472
|
break;
|
|
279
473
|
}
|
|
474
|
+
// Final autosave: the branch always ends holding the finished state.
|
|
475
|
+
this.clearAutosaveTimer(state);
|
|
476
|
+
if (state.cwd !== this.repoRoot)
|
|
477
|
+
await autosaveCommit(state.cwd);
|
|
280
478
|
const finishedAt = new Date().toISOString();
|
|
281
479
|
if (state.cancelled) {
|
|
282
480
|
const run = this.store.getRun(runId);
|
|
@@ -293,25 +491,39 @@ export class RunManager {
|
|
|
293
491
|
emit({ type: 'lifecycle', message: `run failed — ${runError}` });
|
|
294
492
|
}
|
|
295
493
|
else {
|
|
296
|
-
this.
|
|
297
|
-
emit({ type: 'lifecycle', message: 'run finished' });
|
|
494
|
+
await this.settleSuccess(runId);
|
|
298
495
|
}
|
|
299
496
|
this.clearIdleTimer(state);
|
|
300
497
|
this.active.delete(runId);
|
|
498
|
+
void this.pump();
|
|
301
499
|
}
|
|
302
500
|
/** Returns an error message, or null on success. */
|
|
303
|
-
async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit) {
|
|
501
|
+
async runAgentStep(runId, state, step, input, skills, checkFailure, interactive, emit, images) {
|
|
304
502
|
let systemPrompt;
|
|
305
503
|
if (step.skill) {
|
|
306
504
|
const skill = skills.find((s) => s.name === step.skill);
|
|
307
505
|
if (skill) {
|
|
308
506
|
systemPrompt = skill.body.trim();
|
|
507
|
+
// Directory team skills (SKILL.md + references/) get materialized
|
|
508
|
+
// into <cwd>/.claude/skills/<name>/ — the run's worktree when there
|
|
509
|
+
// is one — so claude sees the companion files on disk; the shared
|
|
510
|
+
// info/exclude keeps them out of git (and out of autosave commits).
|
|
511
|
+
if (skill.source === 'team' && skill.team?.dir) {
|
|
512
|
+
const seeded = await materializeSkillDir(state.cwd, skill).catch(() => false);
|
|
513
|
+
if (seeded) {
|
|
514
|
+
emit({
|
|
515
|
+
type: 'note',
|
|
516
|
+
stepId: step.id,
|
|
517
|
+
message: `team skill "${skill.name}" materialized to .claude/skills/${skill.name}/`,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
309
521
|
}
|
|
310
522
|
else {
|
|
311
523
|
emit({
|
|
312
524
|
type: 'note',
|
|
313
525
|
stepId: step.id,
|
|
314
|
-
message: `skill "${step.skill}" not found in .ai/cezar/skills
|
|
526
|
+
message: `skill "${step.skill}" not found in .ai/cezar/skills, .ai/skills or the team skills repo — running with the plain prompt`,
|
|
315
527
|
});
|
|
316
528
|
}
|
|
317
529
|
}
|
|
@@ -319,12 +531,25 @@ export class RunManager {
|
|
|
319
531
|
if (checkFailure) {
|
|
320
532
|
userPrompt += `\n\nA verification command failed after the previous attempt. Fix the cause. Failing output:\n\n${checkFailure}`;
|
|
321
533
|
}
|
|
534
|
+
if (images?.length) {
|
|
535
|
+
emit({
|
|
536
|
+
type: 'note',
|
|
537
|
+
stepId: step.id,
|
|
538
|
+
message: `${images.length} screenshot${images.length > 1 ? 's' : ''} attached to the task`,
|
|
539
|
+
});
|
|
540
|
+
}
|
|
322
541
|
const sessionId = randomUUID();
|
|
323
542
|
this.store.updateStep(runId, step.id, { sessionId });
|
|
324
543
|
const stepRecord = this.store.getRun(runId)?.steps.find((s) => s.id === step.id);
|
|
325
544
|
const startTokens = stepRecord?.tokensUsed ?? 0;
|
|
326
545
|
let stepCost = stepRecord?.costUsd ?? 0;
|
|
327
546
|
const onEvent = (event) => {
|
|
547
|
+
if (event.type === 'image') {
|
|
548
|
+
const saved = this.persistImage(runId, state, event.mediaType, event.data);
|
|
549
|
+
if (saved)
|
|
550
|
+
emit({ type: 'image', stepId: step.id, ...saved });
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
328
553
|
emit({ ...event, stepId: step.id });
|
|
329
554
|
if (event.type === 'token-usage') {
|
|
330
555
|
this.store.updateStep(runId, step.id, { tokensUsed: startTokens + event.tokensUsed });
|
|
@@ -333,22 +558,35 @@ export class RunManager {
|
|
|
333
558
|
stepCost += event.usd;
|
|
334
559
|
this.store.updateStep(runId, step.id, { costUsd: stepCost });
|
|
335
560
|
}
|
|
336
|
-
if (event.type === 'turn-end'
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
561
|
+
if (event.type === 'turn-end') {
|
|
562
|
+
const waiting = interactive && !state.cancelled && state.session?.open;
|
|
563
|
+
if (waiting) {
|
|
564
|
+
// Turn over, session open: the ball is in the user's court.
|
|
565
|
+
this.store.updateRun(runId, { status: 'waiting' });
|
|
566
|
+
this.store.updateStep(runId, step.id, { status: 'waiting' });
|
|
567
|
+
this.armIdleTimer(runId, state);
|
|
568
|
+
}
|
|
569
|
+
// Cez's own heartbeat — the handoff stays current even when the
|
|
570
|
+
// agent forgets to write (spec 007).
|
|
571
|
+
appendHandoffHeartbeat(this.dataDir, runId, `turn complete — status=${waiting ? 'waiting' : 'running'}`);
|
|
341
572
|
}
|
|
342
573
|
};
|
|
343
574
|
const runner = new ClaudeCliRunner();
|
|
344
575
|
let session;
|
|
345
576
|
try {
|
|
346
577
|
session = runner.startSession({
|
|
347
|
-
|
|
578
|
+
// The handoff/todos contract rides along on every agent step.
|
|
579
|
+
systemPrompt: systemPrompt
|
|
580
|
+
? `${systemPrompt}\n\n---\n\n${HANDOFF_INSTRUCTIONS}`
|
|
581
|
+
: HANDOFF_INSTRUCTIONS,
|
|
348
582
|
userPrompt,
|
|
349
|
-
|
|
583
|
+
images,
|
|
584
|
+
cwd: state.cwd,
|
|
350
585
|
allowedTools: step.allowedTools ?? DEFAULT_ALLOWED_TOOLS,
|
|
351
586
|
bashAllowlist: step.bashAllowlist,
|
|
587
|
+
// The handoff file lives outside the worktree — grant access.
|
|
588
|
+
additionalDirectories: [join(this.dataDir, 'runs')],
|
|
589
|
+
env: this.agentEnv(runId),
|
|
352
590
|
model: step.model ?? input.model,
|
|
353
591
|
sessionId,
|
|
354
592
|
// Interactive sessions have no wall clock — the idle timer rules.
|
|
@@ -376,6 +614,57 @@ export class RunManager {
|
|
|
376
614
|
state.interrupt = () => undefined;
|
|
377
615
|
}
|
|
378
616
|
}
|
|
617
|
+
/**
|
|
618
|
+
* Diff-first review gate (spec 009), shared by `execute` and
|
|
619
|
+
* `runContinuation`: a *successful* run whose worktree holds changes rests
|
|
620
|
+
* at `review` instead of `done` — the user inspects the diff first, then
|
|
621
|
+
* sends feedback back, opens a draft PR, or just finishes. Failed/cancelled
|
|
622
|
+
* runs never enter review; no worktree or an empty diff means plain `done`.
|
|
623
|
+
*/
|
|
624
|
+
async settleSuccess(runId) {
|
|
625
|
+
const run = this.store.getRun(runId);
|
|
626
|
+
let review = false;
|
|
627
|
+
if (run?.worktreePath && existsSync(run.worktreePath)) {
|
|
628
|
+
const diff = await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD');
|
|
629
|
+
review = diff.trim().length > 0 && !diff.startsWith('(diff failed');
|
|
630
|
+
}
|
|
631
|
+
this.store.updateRun(runId, {
|
|
632
|
+
status: review ? 'review' : 'done',
|
|
633
|
+
finishedAt: new Date().toISOString(),
|
|
634
|
+
currentStepId: undefined,
|
|
635
|
+
});
|
|
636
|
+
this.store.appendEvent(runId, {
|
|
637
|
+
type: 'lifecycle',
|
|
638
|
+
message: review
|
|
639
|
+
? 'changes ready for review — send feedback, open a draft PR, or finish'
|
|
640
|
+
: 'run finished',
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Agent screenshot (an image block inside a tool result): the base64 data
|
|
645
|
+
* never enters the NDJSON event log — it lands as a file under
|
|
646
|
+
* `.ai/cezar/runs/<id>-images/` and the transcript event carries only the
|
|
647
|
+
* name + serving URL. Best effort: on failure the screenshot is dropped,
|
|
648
|
+
* the transcript still shows the tool result's `[screenshot]` placeholder.
|
|
649
|
+
*/
|
|
650
|
+
persistImage(runId, state, mediaType, data) {
|
|
651
|
+
try {
|
|
652
|
+
const ext = /png/.test(mediaType) ? 'png'
|
|
653
|
+
: /jpe?g/.test(mediaType) ? 'jpg'
|
|
654
|
+
: /webp/.test(mediaType) ? 'webp'
|
|
655
|
+
: /gif/.test(mediaType) ? 'gif'
|
|
656
|
+
: 'img';
|
|
657
|
+
state.imageSeq = (state.imageSeq ?? 0) + 1;
|
|
658
|
+
const name = `screenshot-${state.imageSeq}.${ext}`;
|
|
659
|
+
const dir = join(this.dataDir, 'runs', `${runId}-images`);
|
|
660
|
+
mkdirSync(dir, { recursive: true });
|
|
661
|
+
writeFileSync(join(dir, name), Buffer.from(data, 'base64'));
|
|
662
|
+
return { name, url: `/api/runs/${runId}/images/${name}` };
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
379
668
|
armIdleTimer(runId, state) {
|
|
380
669
|
this.clearIdleTimer(state);
|
|
381
670
|
state.idleTimer = setTimeout(() => {
|
|
@@ -395,11 +684,27 @@ export class RunManager {
|
|
|
395
684
|
state.idleTimer = undefined;
|
|
396
685
|
}
|
|
397
686
|
}
|
|
687
|
+
/** Autosave-commit the worktree every 90 s while the run lives (spec 006). */
|
|
688
|
+
armAutosave(state) {
|
|
689
|
+
if (state.cwd === this.repoRoot || state.autosaveTimer)
|
|
690
|
+
return;
|
|
691
|
+
state.autosaveTimer = setInterval(() => {
|
|
692
|
+
void autosaveCommit(state.cwd);
|
|
693
|
+
}, AUTOSAVE_INTERVAL_MS);
|
|
694
|
+
state.autosaveTimer.unref?.();
|
|
695
|
+
}
|
|
696
|
+
clearAutosaveTimer(state) {
|
|
697
|
+
if (state.autosaveTimer) {
|
|
698
|
+
clearInterval(state.autosaveTimer);
|
|
699
|
+
state.autosaveTimer = undefined;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
398
702
|
runCheckStep(state, step, emit) {
|
|
399
703
|
const command = step.command;
|
|
400
704
|
emit({ type: 'note', stepId: step.id, message: `$ ${command}` });
|
|
401
705
|
return new Promise((resolve) => {
|
|
402
|
-
|
|
706
|
+
// Check steps run in the same cwd as the agent steps — the worktree.
|
|
707
|
+
const child = spawn('bash', ['-lc', command], { cwd: state.cwd, env: process.env });
|
|
403
708
|
state.interrupt = () => child.kill('SIGTERM');
|
|
404
709
|
let output = '';
|
|
405
710
|
const collect = (chunk) => {
|
|
@@ -414,13 +719,13 @@ export class RunManager {
|
|
|
414
719
|
child.on('error', (err) => {
|
|
415
720
|
state.interrupt = () => undefined;
|
|
416
721
|
const message = `failed to spawn: ${err.message}`;
|
|
417
|
-
emit({ type: 'check-output', stepId: step.id, text: message, exitCode: -1 });
|
|
722
|
+
emit({ type: 'check-output', stepId: step.id, command, text: message, exitCode: -1 });
|
|
418
723
|
resolve({ ok: false, output: message });
|
|
419
724
|
});
|
|
420
725
|
child.on('close', (code) => {
|
|
421
726
|
state.interrupt = () => undefined;
|
|
422
727
|
const trimmed = output.trim() || '(no output)';
|
|
423
|
-
emit({ type: 'check-output', stepId: step.id, text: trimmed, exitCode: code ?? -1 });
|
|
728
|
+
emit({ type: 'check-output', stepId: step.id, command, text: trimmed, exitCode: code ?? -1 });
|
|
424
729
|
resolve({ ok: code === 0, output: trimmed });
|
|
425
730
|
});
|
|
426
731
|
});
|
|
@@ -432,6 +737,7 @@ export class RunManager {
|
|
|
432
737
|
finishedAt: new Date().toISOString(),
|
|
433
738
|
});
|
|
434
739
|
emit({ type: 'step-end', stepId, status, ...(error ? { error } : {}) });
|
|
740
|
+
appendHandoffHeartbeat(this.dataDir, runId, `step "${stepId}" complete — status=${status}`);
|
|
435
741
|
}
|
|
436
742
|
}
|
|
437
743
|
function findLastAgentStepIndex(workflow) {
|