@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.
Files changed (57) hide show
  1. package/dist/config.d.ts +62 -0
  2. package/dist/config.js +49 -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 +56 -0
  8. package/dist/git-worktree.js +143 -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/git.d.ts +5 -0
  22. package/dist/server/git.js +38 -0
  23. package/dist/server/git.js.map +1 -1
  24. package/dist/server/github.d.ts +29 -0
  25. package/dist/server/github.js +140 -0
  26. package/dist/server/github.js.map +1 -0
  27. package/dist/server/launch-key.d.ts +7 -0
  28. package/dist/server/launch-key.js +33 -0
  29. package/dist/server/launch-key.js.map +1 -0
  30. package/dist/server/pr.d.ts +22 -0
  31. package/dist/server/pr.js +98 -0
  32. package/dist/server/pr.js.map +1 -0
  33. package/dist/server/server.js +537 -19
  34. package/dist/server/server.js.map +1 -1
  35. package/dist/skills-remote.d.ts +35 -0
  36. package/dist/skills-remote.js +266 -0
  37. package/dist/skills-remote.js.map +1 -0
  38. package/dist/skills.d.ts +20 -6
  39. package/dist/skills.js +77 -12
  40. package/dist/skills.js.map +1 -1
  41. package/dist/todos.d.ts +60 -0
  42. package/dist/todos.js +166 -0
  43. package/dist/todos.js.map +1 -0
  44. package/dist/workflows/load.js +8 -10
  45. package/dist/workflows/load.js.map +1 -1
  46. package/dist/workflows/run.d.ts +62 -3
  47. package/dist/workflows/run.js +351 -45
  48. package/dist/workflows/run.js.map +1 -1
  49. package/dist/workflows/types.d.ts +96 -26
  50. package/dist/workflows/types.js +73 -2
  51. package/dist/workflows/types.js.map +1 -1
  52. package/package.json +1 -1
  53. package/scripts/mock-claude.mjs +118 -0
  54. package/web/app.js +2821 -154
  55. package/web/index.html +82 -23
  56. package/web/open-mercato.svg +11 -0
  57. package/web/style.css +1652 -222
@@ -1,20 +1,86 @@
1
- import { readFileSync } from 'node:fs';
2
- import { dirname, join } from 'node:path';
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 { getDiff, getLog, getRepoInfo, getStatus } from './git.js';
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
- const startRunSchema = z.object({
14
- workflow: z.string().min(1),
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
- const { workflows } = await loadWorkflows(repoRoot);
70
- const workflow = workflows.find((w) => w.name === parsed.data.workflow);
71
- if (!workflow)
72
- return c.json({ error: `unknown workflow: ${parsed.data.workflow}` }, 404);
73
- const run = manager.startRun(workflow, { task: parsed.data.task, model: parsed.data.model });
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(repoRoot, command);
466
+ const opened = await openInTerminal(cwd, command);
143
467
  if (!opened) {
144
- return c.json({ error: 'no terminal emulator found', command: `cd '${repoRoot}' && ${command}` }, 409);
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
- app.delete('/api/runs/:id', (c) => {
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([getStatus(info.root), getLog(info.root)]);
224
- return c.json({ info, status, log });
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) {