@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/server/server.js
CHANGED
|
@@ -1,20 +1,86 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { Hono } from 'hono';
|
|
5
6
|
import { serve } from '@hono/node-server';
|
|
6
7
|
import { streamSSE } from 'hono/streaming';
|
|
8
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
7
9
|
import { z } from 'zod';
|
|
8
10
|
import { detectEnvironment } from '../core/backend-detect.js';
|
|
9
|
-
import { loadWorkflows } from '../workflows/load.js';
|
|
11
|
+
import { WORKFLOWS_DIR, loadWorkflows } from '../workflows/load.js';
|
|
12
|
+
import { QUICK_TASK_WORKFLOW, normalizeWorkflowDoc, skillStackOf, skillsToSteps, stepsIssue, workflowFileSchema, workflowStepSchema, } from '../workflows/types.js';
|
|
13
|
+
import { planChain, slugify } from '../planner.js';
|
|
10
14
|
import { discoverSkills } from '../skills.js';
|
|
11
|
-
import {
|
|
15
|
+
import { refreshTeamSkills } from '../skills-remote.js';
|
|
16
|
+
import { appendHandoffHeartbeat, handoffProgressExcerpt, readHandoff } from '../handoff.js';
|
|
17
|
+
import { markStarted, onTodosChanged, readTodos, removeTodo, startTodosWatch } from '../todos.js';
|
|
18
|
+
import { removeWorktree, worktreeDiff, worktreeDiffStat } from '../git-worktree.js';
|
|
19
|
+
import { getBranches, getCommit, getDiff, getLog, getRepoInfo, getStatus } from './git.js';
|
|
20
|
+
import { loadConfig } from '../config.js';
|
|
21
|
+
import { fetchGithub } from './github.js';
|
|
22
|
+
import { ensureLaunchKey } from './launch-key.js';
|
|
12
23
|
import { openInTerminal } from './open-in-terminal.js';
|
|
13
|
-
|
|
14
|
-
|
|
24
|
+
import { createDraftPr } from './pr.js';
|
|
25
|
+
// A run starts from a named workflow OR an inline chain of steps (spec 008 —
|
|
26
|
+
// the approved plan is posted as-is, never written to a file).
|
|
27
|
+
const startRunSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
workflow: z.string().min(1).optional(),
|
|
30
|
+
steps: z.array(workflowStepSchema).min(1).max(8).optional(),
|
|
15
31
|
task: z.string().min(1),
|
|
16
32
|
model: z.string().optional(),
|
|
33
|
+
// Parallel variants (spec 010): ×2/×3 runs the task as 2–3 competing
|
|
34
|
+
// agents in separate worktrees; the user compares diffs and picks one.
|
|
35
|
+
variants: z.number().int().min(1).max(3).optional(),
|
|
36
|
+
// Screenshots pasted into the new-task form — same shape and limits as a
|
|
37
|
+
// live-session message; delivered with the first agent step's opening.
|
|
38
|
+
images: z
|
|
39
|
+
.array(z.object({
|
|
40
|
+
mediaType: z.string().regex(/^image\//),
|
|
41
|
+
// ~5 MB per image once base64-decoded.
|
|
42
|
+
data: z.string().min(1).max(7_000_000),
|
|
43
|
+
}))
|
|
44
|
+
.max(4)
|
|
45
|
+
.optional(),
|
|
46
|
+
})
|
|
47
|
+
.refine((b) => Boolean(b.workflow) !== Boolean(b.steps), {
|
|
48
|
+
message: 'provide either "workflow" or "steps", not both',
|
|
17
49
|
});
|
|
50
|
+
const pickSchema = z.object({
|
|
51
|
+
runId: z.string().min(1),
|
|
52
|
+
});
|
|
53
|
+
const planSchema = z.object({
|
|
54
|
+
task: z.string().trim().min(1),
|
|
55
|
+
});
|
|
56
|
+
// A saved workflow carries full `steps` OR the builder's `skills` stack
|
|
57
|
+
// (spec 012). `overwrite: true` is the builder's Save on an existing file —
|
|
58
|
+
// the GUI asks first; a plain POST still refuses to clobber.
|
|
59
|
+
const saveWorkflowSchema = z
|
|
60
|
+
.object({
|
|
61
|
+
name: z.string().trim().min(1).max(80),
|
|
62
|
+
description: z.string().optional(),
|
|
63
|
+
steps: z.array(workflowStepSchema).min(1).max(8).optional(),
|
|
64
|
+
skills: z.array(z.string().trim().min(1)).min(1).max(8).optional(),
|
|
65
|
+
overwrite: z.boolean().optional(),
|
|
66
|
+
})
|
|
67
|
+
.refine((b) => Boolean(b.steps) !== Boolean(b.skills), {
|
|
68
|
+
message: 'provide either "steps" or "skills", not both',
|
|
69
|
+
});
|
|
70
|
+
const parseWorkflowSchema = z.object({
|
|
71
|
+
yaml: z.string().min(1).max(100_000),
|
|
72
|
+
});
|
|
73
|
+
// Small GUI preferences persisted in `.ai/cezar/ui-state.json` (files, not a
|
|
74
|
+
// DB): today just the last-used task source, so the form preselects what you
|
|
75
|
+
// actually run. Unknown keys pass through — future prefs won't need a schema
|
|
76
|
+
// dance.
|
|
77
|
+
const uiStateSchema = z
|
|
78
|
+
.object({
|
|
79
|
+
lastTask: z
|
|
80
|
+
.object({ source: z.enum(['workflow', 'skill']), ref: z.string().min(1).max(200) })
|
|
81
|
+
.optional(),
|
|
82
|
+
})
|
|
83
|
+
.passthrough();
|
|
18
84
|
const messageSchema = z
|
|
19
85
|
.object({
|
|
20
86
|
text: z.string().max(100_000).default(''),
|
|
@@ -32,6 +98,9 @@ const messageSchema = z
|
|
|
32
98
|
});
|
|
33
99
|
export function createApp(deps) {
|
|
34
100
|
const { repoRoot, store, manager, version } = deps;
|
|
101
|
+
const dataDir = join(repoRoot, '.ai/cezar');
|
|
102
|
+
startTodosWatch(dataDir); // inbox live updates (spec 007)
|
|
103
|
+
const launchKey = ensureLaunchKey(dataDir); // bookmarklet auto-start secret (spec 011)
|
|
35
104
|
const app = new Hono();
|
|
36
105
|
// ---- static GUI ----------------------------------------------------------
|
|
37
106
|
const webDir = resolveWebDir();
|
|
@@ -41,15 +110,170 @@ export function createApp(deps) {
|
|
|
41
110
|
return new Response(body, { headers: { 'content-type': type } });
|
|
42
111
|
};
|
|
43
112
|
app.get('/', staticFile('index.html', 'text/html; charset=utf-8'));
|
|
113
|
+
// Deep-link target for the bookmarklets (spec 011) — same SPA, the query
|
|
114
|
+
// string (`?skill=…&ref=…&auto=…&key=…`) is handled by web/app.js init().
|
|
115
|
+
app.get('/new', staticFile('index.html', 'text/html; charset=utf-8'));
|
|
44
116
|
app.get('/app.js', staticFile('app.js', 'text/javascript; charset=utf-8'));
|
|
45
117
|
app.get('/style.css', staticFile('style.css', 'text/css; charset=utf-8'));
|
|
118
|
+
app.get('/open-mercato.svg', staticFile('open-mercato.svg', 'image/svg+xml'));
|
|
46
119
|
// ---- meta ----------------------------------------------------------------
|
|
120
|
+
// CORS — deliberately for /api/health ONLY (spec 011): the bookmarklets
|
|
121
|
+
// fetch it cross-origin from github.com to discover which local ports run a
|
|
122
|
+
// cockpit and which repo each serves. Health exposes no secrets beyond the
|
|
123
|
+
// repo path/remote; every other endpoint stays same-origin.
|
|
124
|
+
app.use('/api/health', async (c, next) => {
|
|
125
|
+
c.header('access-control-allow-origin', '*');
|
|
126
|
+
if (c.req.method === 'OPTIONS') {
|
|
127
|
+
// Preflight (e.g. Chrome Private Network Access) — allow the plain GET.
|
|
128
|
+
c.header('access-control-allow-methods', 'GET');
|
|
129
|
+
c.header('access-control-allow-private-network', 'true');
|
|
130
|
+
return c.body(null, 204);
|
|
131
|
+
}
|
|
132
|
+
await next();
|
|
133
|
+
});
|
|
47
134
|
app.get('/api/health', async (c) => {
|
|
48
135
|
const [checks, repo] = await Promise.all([detectEnvironment(), getRepoInfo(repoRoot)]);
|
|
49
136
|
return c.json({ version, repoRoot, repo, checks });
|
|
50
137
|
});
|
|
138
|
+
// The bookmarklet generator bakes this key into the `javascript:` URLs —
|
|
139
|
+
// `/new?auto=1` is honored only with it (spec 011). Same-origin only.
|
|
140
|
+
app.get('/api/launch-key', (c) => c.json({ key: launchKey }));
|
|
51
141
|
app.get('/api/skills', async (c) => c.json(await discoverSkills(repoRoot)));
|
|
142
|
+
// ---- GUI prefs (ui-state.json) --------------------------------------------
|
|
143
|
+
const uiStatePath = join(dataDir, 'ui-state.json');
|
|
144
|
+
const readUiState = async () => {
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(await readFile(uiStatePath, 'utf8'));
|
|
147
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return {};
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
app.get('/api/ui-state', async (c) => c.json(await readUiState()));
|
|
154
|
+
app.put('/api/ui-state', async (c) => {
|
|
155
|
+
const parsed = uiStateSchema.safeParse(await c.req.json().catch(() => null));
|
|
156
|
+
if (!parsed.success) {
|
|
157
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
158
|
+
}
|
|
159
|
+
const merged = { ...(await readUiState()), ...parsed.data };
|
|
160
|
+
try {
|
|
161
|
+
await mkdir(dataDir, { recursive: true });
|
|
162
|
+
await writeFile(uiStatePath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
166
|
+
}
|
|
167
|
+
return c.json(merged);
|
|
168
|
+
});
|
|
169
|
+
// Refresh team skills (spec 005): clone/fetch the configured skills repos,
|
|
170
|
+
// then return the merged catalog. Degrades quietly — offline just means the
|
|
171
|
+
// team entries stay as they were (or absent).
|
|
172
|
+
app.post('/api/skills/refresh', async (c) => {
|
|
173
|
+
await refreshTeamSkills(repoRoot);
|
|
174
|
+
return c.json(await discoverSkills(repoRoot));
|
|
175
|
+
});
|
|
52
176
|
app.get('/api/workflows', async (c) => c.json(await loadWorkflows(repoRoot)));
|
|
177
|
+
// Save an approved plan as a reusable chain (spec 008): YAML in
|
|
178
|
+
// `.ai/cezar/workflows/<slug>.yaml` — from then on it's in the dropdown
|
|
179
|
+
// like any other workflow.
|
|
180
|
+
app.post('/api/workflows', async (c) => {
|
|
181
|
+
const parsed = saveWorkflowSchema.safeParse(await c.req.json().catch(() => null));
|
|
182
|
+
if (!parsed.success) {
|
|
183
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
184
|
+
}
|
|
185
|
+
const steps = parsed.data.steps ?? skillsToSteps(parsed.data.skills ?? []);
|
|
186
|
+
const issue = stepsIssue(steps);
|
|
187
|
+
if (issue)
|
|
188
|
+
return c.json({ error: issue }, 400);
|
|
189
|
+
const slug = slugify(parsed.data.name) || 'chain';
|
|
190
|
+
const dir = join(repoRoot, WORKFLOWS_DIR);
|
|
191
|
+
const path = join(dir, `${slug}.yaml`);
|
|
192
|
+
// Pure skill stacks are written in the portable compact form (spec 012) —
|
|
193
|
+
// `name` + `skills:` — so the file imports cleanly in any repo.
|
|
194
|
+
const stack = skillStackOf(steps);
|
|
195
|
+
const doc = {
|
|
196
|
+
name: parsed.data.name,
|
|
197
|
+
...(parsed.data.description ? { description: parsed.data.description } : {}),
|
|
198
|
+
...(stack ? { skills: stack } : { steps }),
|
|
199
|
+
};
|
|
200
|
+
try {
|
|
201
|
+
await mkdir(dir, { recursive: true });
|
|
202
|
+
// `wx` = fail if the file exists — no silent overwrite of a chain.
|
|
203
|
+
await writeFile(path, stringifyYaml(doc), {
|
|
204
|
+
encoding: 'utf8',
|
|
205
|
+
flag: parsed.data.overwrite ? 'w' : 'wx',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
if (err instanceof Error && 'code' in err && err.code === 'EEXIST') {
|
|
210
|
+
return c.json({ error: `workflow file already exists: ${path}`, exists: true }, 409);
|
|
211
|
+
}
|
|
212
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
213
|
+
return c.json({ error: message }, 500);
|
|
214
|
+
}
|
|
215
|
+
return c.json({ path, name: parsed.data.name }, 201);
|
|
216
|
+
});
|
|
217
|
+
// Delete a saved workflow (spec 012 follow-up): file workflows only —
|
|
218
|
+
// built-ins have no file and always come back.
|
|
219
|
+
app.delete('/api/workflows/:name', async (c) => {
|
|
220
|
+
const name = c.req.param('name');
|
|
221
|
+
const { workflows } = await loadWorkflows(repoRoot);
|
|
222
|
+
const wf = workflows.find((w) => w.name === name);
|
|
223
|
+
if (!wf)
|
|
224
|
+
return c.json({ error: `unknown workflow: ${name}` }, 404);
|
|
225
|
+
if (wf.source !== 'file' || !wf.path) {
|
|
226
|
+
return c.json({ error: 'built-in workflows cannot be deleted' }, 400);
|
|
227
|
+
}
|
|
228
|
+
const dir = resolve(repoRoot, WORKFLOWS_DIR);
|
|
229
|
+
const target = resolve(wf.path);
|
|
230
|
+
if (!target.startsWith(dir + sep)) {
|
|
231
|
+
return c.json({ error: 'refusing to delete a file outside the workflows dir' }, 400);
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
await unlink(target);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
238
|
+
}
|
|
239
|
+
return c.json({ ok: true, path: target });
|
|
240
|
+
});
|
|
241
|
+
// Import support for the builder (spec 012): parse + validate a pasted
|
|
242
|
+
// workflow YAML (either form) and hand back the normalized definition. The
|
|
243
|
+
// server owns YAML parsing — the GUI stays dependency-free.
|
|
244
|
+
app.post('/api/workflows/parse', async (c) => {
|
|
245
|
+
const parsed = parseWorkflowSchema.safeParse(await c.req.json().catch(() => null));
|
|
246
|
+
if (!parsed.success) {
|
|
247
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
248
|
+
}
|
|
249
|
+
let raw;
|
|
250
|
+
try {
|
|
251
|
+
raw = parseYaml(parsed.data.yaml);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
255
|
+
return c.json({ error: `not valid YAML: ${message}` }, 400);
|
|
256
|
+
}
|
|
257
|
+
const doc = workflowFileSchema.safeParse(raw);
|
|
258
|
+
if (!doc.success) {
|
|
259
|
+
return c.json({ error: doc.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
260
|
+
}
|
|
261
|
+
const normalized = normalizeWorkflowDoc(doc.data);
|
|
262
|
+
const issue = stepsIssue(normalized.steps);
|
|
263
|
+
if (issue)
|
|
264
|
+
return c.json({ error: issue }, 400);
|
|
265
|
+
return c.json(normalized);
|
|
266
|
+
});
|
|
267
|
+
// Chain-from-prompt (spec 008): one cheap claude call proposes a chain of
|
|
268
|
+
// steps for the task. Never blocks — degraded answers come back as a
|
|
269
|
+
// one-step quick-task plan with `fallback: true`.
|
|
270
|
+
app.post('/api/plan', async (c) => {
|
|
271
|
+
const parsed = planSchema.safeParse(await c.req.json().catch(() => null));
|
|
272
|
+
if (!parsed.success) {
|
|
273
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
274
|
+
}
|
|
275
|
+
return c.json(await planChain(repoRoot, parsed.data.task));
|
|
276
|
+
});
|
|
53
277
|
// ---- runs ----------------------------------------------------------------
|
|
54
278
|
app.get('/api/runs', (c) => c.json(store.listRuns()));
|
|
55
279
|
// Registered before the `/:id/...` routes so "archive-finished" never
|
|
@@ -66,13 +290,111 @@ export function createApp(deps) {
|
|
|
66
290
|
if (!parsed.success) {
|
|
67
291
|
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
68
292
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
293
|
+
let workflow;
|
|
294
|
+
if (parsed.data.steps) {
|
|
295
|
+
// Inline chain (spec 008): an approved plan runs as an ad-hoc workflow.
|
|
296
|
+
const issue = stepsIssue(parsed.data.steps);
|
|
297
|
+
if (issue)
|
|
298
|
+
return c.json({ error: issue }, 400);
|
|
299
|
+
workflow = { name: '(planned)', source: 'built-in', steps: parsed.data.steps };
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
const { workflows } = await loadWorkflows(repoRoot);
|
|
303
|
+
workflow = workflows.find((w) => w.name === parsed.data.workflow);
|
|
304
|
+
if (!workflow)
|
|
305
|
+
return c.json({ error: `unknown workflow: ${parsed.data.workflow}` }, 404);
|
|
306
|
+
}
|
|
307
|
+
const images = parsed.data.images?.map((img) => ({
|
|
308
|
+
type: 'image',
|
|
309
|
+
source: { type: 'base64', media_type: img.mediaType, data: img.data },
|
|
310
|
+
}));
|
|
311
|
+
const input = { task: parsed.data.task, model: parsed.data.model, images };
|
|
312
|
+
const variants = parsed.data.variants ?? 1;
|
|
313
|
+
if (variants > 1) {
|
|
314
|
+
// Variants live in worktrees — without git there's nothing to isolate
|
|
315
|
+
// them with, so this degrades to a clear 400 instead of stepping on
|
|
316
|
+
// one shared working tree.
|
|
317
|
+
const repo = await getRepoInfo(repoRoot);
|
|
318
|
+
if (!repo) {
|
|
319
|
+
return c.json({ error: 'parallel variants need a git repository (each variant runs in its own worktree) — run ×1 here, or start cezar inside a git repo' }, 400);
|
|
320
|
+
}
|
|
321
|
+
return c.json({ runs: manager.startVariants(workflow, input, variants) }, 201);
|
|
322
|
+
}
|
|
323
|
+
const run = manager.startRun(workflow, input);
|
|
74
324
|
return c.json(run, 201);
|
|
75
325
|
});
|
|
326
|
+
// ---- parallel variants (spec 010) -----------------------------------------
|
|
327
|
+
const groupRuns = (groupId) => store
|
|
328
|
+
.listRuns()
|
|
329
|
+
.filter((r) => r.groupId === groupId)
|
|
330
|
+
.sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''));
|
|
331
|
+
// Comparison data: per variant the status, cost, `git diff --stat` and the
|
|
332
|
+
// first Progress-log lines from the handoff. The full diff is fetched per
|
|
333
|
+
// variant via the existing GET /api/runs/:id/diff.
|
|
334
|
+
app.get('/api/groups/:groupId', async (c) => {
|
|
335
|
+
const runs = groupRuns(c.req.param('groupId'));
|
|
336
|
+
if (runs.length === 0)
|
|
337
|
+
return c.json({ error: 'not found' }, 404);
|
|
338
|
+
const detailed = await Promise.all(runs.map(async (r) => ({
|
|
339
|
+
id: r.id,
|
|
340
|
+
variant: r.variant ?? '?',
|
|
341
|
+
title: r.title,
|
|
342
|
+
status: r.status,
|
|
343
|
+
archived: r.archived,
|
|
344
|
+
tokensUsed: r.tokensUsed,
|
|
345
|
+
costUsd: r.costUsd,
|
|
346
|
+
diffStat: r.worktreePath && existsSync(r.worktreePath)
|
|
347
|
+
? await worktreeDiffStat(r.worktreePath, r.baseBranch ?? 'HEAD')
|
|
348
|
+
: '',
|
|
349
|
+
handoffExcerpt: handoffProgressExcerpt(readHandoff(dataDir, r.id)),
|
|
350
|
+
})));
|
|
351
|
+
return c.json({ groupId: c.req.param('groupId'), runs: detailed });
|
|
352
|
+
});
|
|
353
|
+
// "Pick this one": the winner rests at `review` (spec 009 takes it from
|
|
354
|
+
// there — send back / draft PR / finish); the losers are cancelled if
|
|
355
|
+
// alive, archived, and their worktrees + branches removed.
|
|
356
|
+
app.post('/api/groups/:groupId/pick', async (c) => {
|
|
357
|
+
const runs = groupRuns(c.req.param('groupId'));
|
|
358
|
+
if (runs.length === 0)
|
|
359
|
+
return c.json({ error: 'not found' }, 404);
|
|
360
|
+
const parsed = pickSchema.safeParse(await c.req.json().catch(() => null));
|
|
361
|
+
if (!parsed.success) {
|
|
362
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
363
|
+
}
|
|
364
|
+
const winner = runs.find((r) => r.id === parsed.data.runId);
|
|
365
|
+
if (!winner)
|
|
366
|
+
return c.json({ error: 'runId is not part of this group' }, 404);
|
|
367
|
+
if (manager.isActive(winner.id)) {
|
|
368
|
+
return c.json({ error: 'this variant is still active — wait for it to finish first' }, 409);
|
|
369
|
+
}
|
|
370
|
+
// Winner: a non-review terminal state with a non-empty diff flips to
|
|
371
|
+
// `review` (the settleSuccess rule); an empty diff (or no worktree) stays.
|
|
372
|
+
if (winner.status !== 'review' && winner.worktreePath && existsSync(winner.worktreePath)) {
|
|
373
|
+
const diff = await worktreeDiff(winner.worktreePath, winner.baseBranch ?? 'HEAD');
|
|
374
|
+
if (diff.trim().length > 0 && !diff.startsWith('(diff failed')) {
|
|
375
|
+
store.updateRun(winner.id, { status: 'review' });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const losers = runs.filter((r) => r.id !== winner.id);
|
|
379
|
+
store.appendEvent(winner.id, {
|
|
380
|
+
type: 'lifecycle',
|
|
381
|
+
message: `picked from ${runs.length} variants — ${losers.length} other variant(s) archived`,
|
|
382
|
+
});
|
|
383
|
+
appendHandoffHeartbeat(dataDir, winner.id, `picked from ${runs.length} variants`);
|
|
384
|
+
for (const loser of losers) {
|
|
385
|
+
if (manager.isActive(loser.id))
|
|
386
|
+
manager.cancel(loser.id);
|
|
387
|
+
if (loser.worktreePath)
|
|
388
|
+
await removeWorktree(repoRoot, loser.worktreePath, loser.branch);
|
|
389
|
+
store.updateRun(loser.id, { worktreePath: undefined, branch: undefined });
|
|
390
|
+
store.setArchived(loser.id, true);
|
|
391
|
+
store.appendEvent(loser.id, {
|
|
392
|
+
type: 'lifecycle',
|
|
393
|
+
message: `variant ${winner.variant ?? '?'} was picked — this variant is archived, its worktree removed`,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return c.json({ winner: store.getRun(winner.id) });
|
|
397
|
+
});
|
|
76
398
|
app.get('/api/runs/:id', (c) => {
|
|
77
399
|
const run = store.getRun(c.req.param('id'));
|
|
78
400
|
return run ? c.json(run) : c.json({ error: 'not found' }, 404);
|
|
@@ -129,7 +451,8 @@ export function createApp(deps) {
|
|
|
129
451
|
return c.json({ error: result.error }, 409);
|
|
130
452
|
return c.json({ continued: true });
|
|
131
453
|
});
|
|
132
|
-
// "Open in terminal" (spec 003): hand the session off to a real terminal
|
|
454
|
+
// "Open in terminal" (spec 003): hand the session off to a real terminal —
|
|
455
|
+
// in the task's worktree when it still exists (spec 006).
|
|
133
456
|
app.post('/api/runs/:id/open-in-cli', async (c) => {
|
|
134
457
|
const id = c.req.param('id');
|
|
135
458
|
const run = store.getRun(id);
|
|
@@ -138,19 +461,149 @@ export function createApp(deps) {
|
|
|
138
461
|
const sessionId = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
|
|
139
462
|
if (!sessionId)
|
|
140
463
|
return c.json({ error: 'no agent session to resume' }, 409);
|
|
464
|
+
const cwd = run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : repoRoot;
|
|
141
465
|
const command = `claude --resume ${sessionId}`;
|
|
142
|
-
const opened = await openInTerminal(
|
|
466
|
+
const opened = await openInTerminal(cwd, command);
|
|
143
467
|
if (!opened) {
|
|
144
|
-
return c.json({ error: 'no terminal emulator found', command: `cd '${
|
|
468
|
+
return c.json({ error: 'no terminal emulator found', command: `cd '${cwd}' && ${command}` }, 409);
|
|
145
469
|
}
|
|
146
470
|
return c.json({ opened: true, command });
|
|
147
471
|
});
|
|
148
|
-
|
|
472
|
+
// Handoff journal (spec 007): the per-task handoff.md as markdown. 404 only
|
|
473
|
+
// when the task is unknown; a task without a (yet) seeded file returns ''.
|
|
474
|
+
app.get('/api/runs/:id/handoff', (c) => {
|
|
475
|
+
const run = store.getRun(c.req.param('id'));
|
|
476
|
+
if (!run)
|
|
477
|
+
return c.json({ error: 'not found' }, 404);
|
|
478
|
+
return c.text(readHandoff(dataDir, run.id), 200, {
|
|
479
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
// Agent screenshots — image blocks the run manager persisted out of tool
|
|
483
|
+
// results (persistImage). `basename` pins reads inside the run's own dir.
|
|
484
|
+
const IMAGE_TYPES = {
|
|
485
|
+
png: 'image/png',
|
|
486
|
+
jpg: 'image/jpeg',
|
|
487
|
+
webp: 'image/webp',
|
|
488
|
+
gif: 'image/gif',
|
|
489
|
+
};
|
|
490
|
+
app.get('/api/runs/:id/images/:file', (c) => {
|
|
491
|
+
const run = store.getRun(c.req.param('id'));
|
|
492
|
+
if (!run)
|
|
493
|
+
return c.json({ error: 'not found' }, 404);
|
|
494
|
+
const file = basename(c.req.param('file'));
|
|
495
|
+
const path = join(dataDir, 'runs', `${run.id}-images`, file);
|
|
496
|
+
if (!existsSync(path))
|
|
497
|
+
return c.json({ error: 'not found' }, 404);
|
|
498
|
+
const type = IMAGE_TYPES[file.split('.').pop() ?? ''] ?? 'application/octet-stream';
|
|
499
|
+
return new Response(readFileSync(path), {
|
|
500
|
+
headers: { 'content-type': type, 'cache-control': 'private, max-age=31536000, immutable' },
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
// Task diff (spec 006): what this run changed — its worktree vs its base.
|
|
504
|
+
app.get('/api/runs/:id/diff', async (c) => {
|
|
505
|
+
const run = store.getRun(c.req.param('id'));
|
|
506
|
+
if (!run)
|
|
507
|
+
return c.json({ error: 'not found' }, 404);
|
|
508
|
+
if (!run.worktreePath || !existsSync(run.worktreePath)) {
|
|
509
|
+
return c.text('(no worktree — this task ran directly in the repo working tree)');
|
|
510
|
+
}
|
|
511
|
+
return c.text(await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD'));
|
|
512
|
+
});
|
|
513
|
+
// Draft PR from the review gate (spec 009): final autosave → push →
|
|
514
|
+
// `gh pr create --draft`; on success the run completes as done with the PR
|
|
515
|
+
// badge. Failures come back as 409 with a `manual` merge command the GUI
|
|
516
|
+
// shows next to the toast. CEZ_DRY_RUN=1 fakes the URL (no push, no gh).
|
|
517
|
+
app.post('/api/runs/:id/pr', async (c) => {
|
|
518
|
+
const id = c.req.param('id');
|
|
519
|
+
const run = store.getRun(id);
|
|
520
|
+
if (!run)
|
|
521
|
+
return c.json({ error: 'not found' }, 404);
|
|
522
|
+
if (manager.isActive(id))
|
|
523
|
+
return c.json({ error: 'run is still active — wait for the review gate' }, 409);
|
|
524
|
+
if (!run.worktreePath || !existsSync(run.worktreePath) || !run.branch) {
|
|
525
|
+
return c.json({ error: 'no worktree/branch to publish — this task ran in the repo working tree' }, 400);
|
|
526
|
+
}
|
|
527
|
+
const outcome = await createDraftPr({ repoRoot, run, handoffText: readHandoff(dataDir, id) });
|
|
528
|
+
if (!outcome.ok) {
|
|
529
|
+
return c.json({ error: outcome.error, manual: `git merge ${run.branch}` }, 409);
|
|
530
|
+
}
|
|
531
|
+
store.updateRun(id, {
|
|
532
|
+
pullRequestUrl: outcome.url,
|
|
533
|
+
status: 'done',
|
|
534
|
+
finishedAt: run.finishedAt ?? new Date().toISOString(),
|
|
535
|
+
});
|
|
536
|
+
store.appendEvent(id, {
|
|
537
|
+
type: 'note',
|
|
538
|
+
message: `draft PR created: ${outcome.url}${outcome.dryRun ? ' (dry run — no real PR)' : ''}`,
|
|
539
|
+
});
|
|
540
|
+
return c.json({ url: outcome.url, dryRun: outcome.dryRun }, 201);
|
|
541
|
+
});
|
|
542
|
+
// Archived tasks keep their worktree for inspection; this is the explicit
|
|
543
|
+
// "🧹 Remove worktree" cleanup (spec 006).
|
|
544
|
+
app.post('/api/runs/:id/remove-worktree', async (c) => {
|
|
149
545
|
const id = c.req.param('id');
|
|
546
|
+
const run = store.getRun(id);
|
|
547
|
+
if (!run)
|
|
548
|
+
return c.json({ error: 'not found' }, 404);
|
|
150
549
|
if (manager.isActive(id))
|
|
151
550
|
return c.json({ error: 'run is active — cancel it first' }, 409);
|
|
551
|
+
if (run.worktreePath)
|
|
552
|
+
await removeWorktree(repoRoot, run.worktreePath, run.branch);
|
|
553
|
+
store.updateRun(id, { worktreePath: undefined, branch: undefined });
|
|
554
|
+
return c.json({ removed: true });
|
|
555
|
+
});
|
|
556
|
+
app.delete('/api/runs/:id', async (c) => {
|
|
557
|
+
const id = c.req.param('id');
|
|
558
|
+
if (manager.isActive(id))
|
|
559
|
+
return c.json({ error: 'run is active — cancel it first' }, 409);
|
|
560
|
+
const run = store.getRun(id);
|
|
561
|
+
if (!run)
|
|
562
|
+
return c.json({ error: 'not found' }, 404);
|
|
563
|
+
// Delete cleans up after itself: worktree + branch go with the run (spec 006).
|
|
564
|
+
if (run.worktreePath)
|
|
565
|
+
await removeWorktree(repoRoot, run.worktreePath, run.branch);
|
|
152
566
|
return store.deleteRun(id) ? c.json({ deleted: true }) : c.json({ error: 'not found' }, 404);
|
|
153
567
|
});
|
|
568
|
+
// ---- inbox (spec 007) ------------------------------------------------------
|
|
569
|
+
app.get('/api/todos', async (c) => c.json(await readTodos(dataDir)));
|
|
570
|
+
// Check off = delete the entry.
|
|
571
|
+
app.delete('/api/todos/:id', async (c) => {
|
|
572
|
+
const removed = await removeTodo(dataDir, c.req.param('id'));
|
|
573
|
+
return removed ? c.json({ removed: true }) : c.json({ error: 'not found' }, 404);
|
|
574
|
+
});
|
|
575
|
+
// "▶ Run": turn an inbox entry into a task — a one-off single-step workflow
|
|
576
|
+
// around the suggested skill when it exists, plain quick-task otherwise.
|
|
577
|
+
app.post('/api/todos/:id/start', async (c) => {
|
|
578
|
+
const id = c.req.param('id');
|
|
579
|
+
const todo = (await readTodos(dataDir)).find((t) => t.id === id);
|
|
580
|
+
if (!todo)
|
|
581
|
+
return c.json({ error: 'not found' }, 404);
|
|
582
|
+
if (todo.startedTaskId)
|
|
583
|
+
return c.json({ error: 'already started' }, 409);
|
|
584
|
+
let task = (todo.suggestedPrompt ?? todo.summary).trim() || todo.summary;
|
|
585
|
+
if (todo.suggestedArgs)
|
|
586
|
+
task += `\n\nArguments: ${todo.suggestedArgs}`;
|
|
587
|
+
let workflow;
|
|
588
|
+
if (todo.suggestedSkill) {
|
|
589
|
+
const skills = await discoverSkills(repoRoot);
|
|
590
|
+
if (skills.some((s) => s.name === todo.suggestedSkill)) {
|
|
591
|
+
workflow = {
|
|
592
|
+
name: '(inbox)',
|
|
593
|
+
description: `Follow-up from the inbox — skill "${todo.suggestedSkill}"`,
|
|
594
|
+
source: 'built-in',
|
|
595
|
+
steps: [{ id: 'task', name: 'Do the task', skill: todo.suggestedSkill, prompt: '{{task}}' }],
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (!workflow) {
|
|
600
|
+
const { workflows } = await loadWorkflows(repoRoot);
|
|
601
|
+
workflow = workflows.find((w) => w.name === 'quick-task') ?? QUICK_TASK_WORKFLOW;
|
|
602
|
+
}
|
|
603
|
+
const run = manager.startRun(workflow, { task });
|
|
604
|
+
await markStarted(dataDir, id, run.id);
|
|
605
|
+
return c.json({ run }, 201);
|
|
606
|
+
});
|
|
154
607
|
// Per-run SSE: full replay from the NDJSON file, then live events. The
|
|
155
608
|
// listener attaches before the replay and buffers, so nothing emitted
|
|
156
609
|
// during the replay is lost or duplicated (dedup by seq).
|
|
@@ -200,28 +653,81 @@ export function createApp(deps) {
|
|
|
200
653
|
}
|
|
201
654
|
});
|
|
202
655
|
});
|
|
203
|
-
// Global SSE: run-summary updates for the list view.
|
|
656
|
+
// Global SSE: run-summary updates for the list view + inbox changes.
|
|
204
657
|
app.get('/api/events', (c) => streamSSE(c, async (stream) => {
|
|
205
658
|
const onRun = (run) => void stream.writeSSE({ event: 'run', data: JSON.stringify(run) });
|
|
206
659
|
const onDeleted = (id) => void stream.writeSSE({ event: 'run-deleted', data: JSON.stringify({ id }) });
|
|
660
|
+
const sendTodos = async () => {
|
|
661
|
+
const items = await readTodos(dataDir).catch(() => []);
|
|
662
|
+
await stream.writeSSE({ event: 'todos', data: JSON.stringify(items) });
|
|
663
|
+
};
|
|
664
|
+
const offTodos = onTodosChanged(() => void sendTodos());
|
|
207
665
|
store.on('run', onRun);
|
|
208
666
|
store.on('deleted', onDeleted);
|
|
209
667
|
stream.onAbort(() => {
|
|
210
668
|
store.off('run', onRun);
|
|
211
669
|
store.off('deleted', onDeleted);
|
|
670
|
+
offTodos();
|
|
212
671
|
});
|
|
213
672
|
while (!stream.aborted) {
|
|
214
673
|
await stream.writeSSE({ event: 'ping', data: '' });
|
|
215
674
|
await stream.sleep(15_000);
|
|
216
675
|
}
|
|
217
676
|
}));
|
|
677
|
+
// ---- GitHub tab ------------------------------------------------------------
|
|
678
|
+
// Issues + PRs of the repo's origin, read through the logged-in `gh` CLI.
|
|
679
|
+
// Degrades to `{available:false, reason}` — no gh / no remote / offline all
|
|
680
|
+
// just render as a hint in the tab, never an error.
|
|
681
|
+
app.get('/api/github', async (c) => {
|
|
682
|
+
const limit = Number.parseInt(c.req.query('limit') ?? '', 10);
|
|
683
|
+
return c.json(await fetchGithub(repoRoot, c.req.query('refresh') === '1', Number.isFinite(limit) ? limit : 30));
|
|
684
|
+
});
|
|
218
685
|
// ---- repo view -----------------------------------------------------------
|
|
219
686
|
app.get('/api/repo', async (c) => {
|
|
220
687
|
const info = await getRepoInfo(repoRoot);
|
|
221
688
|
if (!info)
|
|
222
|
-
return c.json({ info: null, status: [], log: [] });
|
|
223
|
-
const [status, log] = await Promise.all([
|
|
224
|
-
|
|
689
|
+
return c.json({ info: null, status: [], log: [], branches: [], baseBranch: null });
|
|
690
|
+
const [status, log, branches, config] = await Promise.all([
|
|
691
|
+
getStatus(info.root),
|
|
692
|
+
getLog(info.root),
|
|
693
|
+
getBranches(info.root),
|
|
694
|
+
loadConfig(repoRoot),
|
|
695
|
+
]);
|
|
696
|
+
return c.json({ info, status, log, branches, baseBranch: config.baseBranch ?? null });
|
|
697
|
+
});
|
|
698
|
+
// Set/clear the agents' base branch (Repo tab). Merges into the RAW
|
|
699
|
+
// config.json so user keys (skillsRepos…) survive and schema defaults are
|
|
700
|
+
// never materialized into the file.
|
|
701
|
+
const setConfigSchema = z.object({
|
|
702
|
+
baseBranch: z.string().trim().min(1).max(200).nullable(),
|
|
703
|
+
});
|
|
704
|
+
app.put('/api/config', async (c) => {
|
|
705
|
+
const parsed = setConfigSchema.safeParse(await c.req.json().catch(() => null));
|
|
706
|
+
if (!parsed.success) {
|
|
707
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
708
|
+
}
|
|
709
|
+
const configPath = join(dataDir, 'config.json');
|
|
710
|
+
let raw = {};
|
|
711
|
+
try {
|
|
712
|
+
const existing = JSON.parse(await readFile(configPath, 'utf8'));
|
|
713
|
+
if (existing && typeof existing === 'object')
|
|
714
|
+
raw = existing;
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
// missing or malformed — start fresh
|
|
718
|
+
}
|
|
719
|
+
if (parsed.data.baseBranch === null)
|
|
720
|
+
delete raw.baseBranch;
|
|
721
|
+
else
|
|
722
|
+
raw.baseBranch = parsed.data.baseBranch;
|
|
723
|
+
try {
|
|
724
|
+
await mkdir(dataDir, { recursive: true });
|
|
725
|
+
await writeFile(configPath, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
|
|
726
|
+
}
|
|
727
|
+
catch (err) {
|
|
728
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
729
|
+
}
|
|
730
|
+
return c.json({ baseBranch: parsed.data.baseBranch });
|
|
225
731
|
});
|
|
226
732
|
app.get('/api/repo/diff', async (c) => {
|
|
227
733
|
const info = await getRepoInfo(repoRoot);
|
|
@@ -229,6 +735,18 @@ export function createApp(deps) {
|
|
|
229
735
|
return c.text('not a git repository');
|
|
230
736
|
return c.text(await getDiff(info.root));
|
|
231
737
|
});
|
|
738
|
+
// One commit's message + stat + patch — the Repo view expands it inline.
|
|
739
|
+
app.get('/api/repo/commit/:sha', async (c) => {
|
|
740
|
+
const info = await getRepoInfo(repoRoot);
|
|
741
|
+
if (!info)
|
|
742
|
+
return c.text('not a git repository');
|
|
743
|
+
try {
|
|
744
|
+
return c.text(await getCommit(info.root, c.req.param('sha')));
|
|
745
|
+
}
|
|
746
|
+
catch (err) {
|
|
747
|
+
return c.text(`(git show failed: ${err instanceof Error ? err.message : String(err)})`);
|
|
748
|
+
}
|
|
749
|
+
});
|
|
232
750
|
return app;
|
|
233
751
|
}
|
|
234
752
|
export function startServer(deps, port) {
|