@pat-lewczuk/cezar 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +124 -37
  2. package/dist/config.d.ts +46 -0
  3. package/dist/config.js +31 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/core/agent-runner.d.ts +16 -0
  6. package/dist/core/agent-runner.js +9 -0
  7. package/dist/core/agent-runner.js.map +1 -1
  8. package/dist/core/claude-cli-runner.d.ts +6 -0
  9. package/dist/core/claude-cli-runner.js +27 -43
  10. package/dist/core/claude-cli-runner.js.map +1 -1
  11. package/dist/core/codex-app-server-runner.js +42 -5
  12. package/dist/core/codex-app-server-runner.js.map +1 -1
  13. package/dist/core/opencode-server-runner.js +42 -5
  14. package/dist/core/opencode-server-runner.js.map +1 -1
  15. package/dist/git-worktree.d.ts +21 -0
  16. package/dist/git-worktree.js +48 -4
  17. package/dist/git-worktree.js.map +1 -1
  18. package/dist/handoff.d.ts +1 -1
  19. package/dist/handoff.js +2 -0
  20. package/dist/handoff.js.map +1 -1
  21. package/dist/index.js +34 -4
  22. package/dist/index.js.map +1 -1
  23. package/dist/runs/store.d.ts +82 -1
  24. package/dist/runs/store.js +57 -5
  25. package/dist/runs/store.js.map +1 -1
  26. package/dist/server/git.js +12 -1
  27. package/dist/server/git.js.map +1 -1
  28. package/dist/server/github.d.ts +8 -29
  29. package/dist/server/github.js +7 -139
  30. package/dist/server/github.js.map +1 -1
  31. package/dist/server/pr.d.ts +5 -20
  32. package/dist/server/pr.js +4 -95
  33. package/dist/server/pr.js.map +1 -1
  34. package/dist/server/server.d.ts +30 -1
  35. package/dist/server/server.js +448 -19
  36. package/dist/server/server.js.map +1 -1
  37. package/dist/workflows/run.d.ts +90 -0
  38. package/dist/workflows/run.js +412 -29
  39. package/dist/workflows/run.js.map +1 -1
  40. package/dist/workflows/types.d.ts +16 -16
  41. package/package.json +44 -6
  42. package/scripts/check-pack.mjs +46 -0
  43. package/scripts/dev.mjs +74 -0
  44. package/scripts/mock-claude.mjs +54 -2
  45. package/scripts/test-process-usage.mjs +47 -0
  46. package/web/dist/assets/{toaster-BS2E1bhz.js → centered-state-zgEVNWsj.js} +1 -1
  47. package/web/dist/assets/compare-variants-ChHCcSQX.js +1 -0
  48. package/web/dist/assets/dialog-DDRjj-uK.js +1 -0
  49. package/web/dist/assets/{diff-view-CctnJHWe.js → diff-view-CWRO0GMe.js} +1 -1
  50. package/web/dist/assets/dist-id2CeyV-.js +1 -0
  51. package/web/dist/assets/git-toolbar-C4jo602L.js +1 -0
  52. package/web/dist/assets/github-BOIzQFwE.js +1 -0
  53. package/web/dist/assets/index-Cv1pQs9U.js +11 -0
  54. package/web/dist/assets/repo-git-BuyJiqna.js +1 -0
  55. package/web/dist/assets/{run-diff-C7mfxj7f.js → run-diff-BocG6LVd.js} +1 -1
  56. package/web/dist/assets/run-header-CDKw22ek.js +1 -0
  57. package/web/dist/assets/skill-detail-CcFFKmGO.js +1 -0
  58. package/web/dist/assets/skills-Bc_4U3yq.js +1 -0
  59. package/web/dist/assets/tab-link-C77EP3i6.js +1 -0
  60. package/web/dist/assets/task-changes-DsDgN8-x.js +1 -0
  61. package/web/dist/assets/task-commits-Dve6Fozc.js +1 -0
  62. package/web/dist/assets/{task-files-FOAAz2FB.js → task-files-BDMc_z0B.js} +1 -1
  63. package/web/dist/assets/{task-thread-Bncxe8Os.js → task-thread-CmRuPj9H.js} +2 -2
  64. package/web/dist/assets/use-desktop-CyEWsxSU.js +3 -0
  65. package/web/dist/assets/{workflows-Dg2wQg6i.js → workflows-J3z3kYic.js} +1 -1
  66. package/web/dist/index.html +5 -5
  67. package/web/app/node_modules/.vite/vitest/ca84d1343b96baa8137c943ed1860e522cacb238/results.json +0 -1
  68. package/web/app/src/components/app-shell-container.tsx +0 -76
  69. package/web/app.js +0 -3428
  70. package/web/dist/assets/compare-variants-D6fPL9cS.js +0 -1
  71. package/web/dist/assets/dialog-BEgEWFDS.js +0 -1
  72. package/web/dist/assets/dist-CW7M_Jhs.js +0 -1
  73. package/web/dist/assets/git-toolbar-D8OPPqBP.js +0 -1
  74. package/web/dist/assets/github-BYIHwIJB.js +0 -1
  75. package/web/dist/assets/index-9qG98Orw.js +0 -11
  76. package/web/dist/assets/repo-git-XTqlVOl2.js +0 -1
  77. package/web/dist/assets/run-header-CfVeY3uM.js +0 -1
  78. package/web/dist/assets/skill-detail-C9cniyId.js +0 -1
  79. package/web/dist/assets/skills-DXgY1uXJ.js +0 -1
  80. package/web/dist/assets/tab-link-phWEqSBy.js +0 -1
  81. package/web/dist/assets/task-changes-DYwnPnTN.js +0 -1
  82. package/web/dist/assets/task-commits-DZzxEzbn.js +0 -1
  83. package/web/dist/assets/use-desktop-eHm2iTXX.js +0 -3
  84. package/web/index.html +0 -108
  85. package/web/style.css +0 -1767
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
3
3
  import { basename, dirname, join, resolve, sep } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
@@ -8,6 +8,7 @@ import { streamSSE } from 'hono/streaming';
8
8
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
9
9
  import { z } from 'zod';
10
10
  import { detectEnvironment } from '../core/backend-detect.js';
11
+ import { currentUsage, onUsage } from '../core/process-usage.js';
11
12
  import { WORKFLOWS_DIR, loadWorkflows } from '../workflows/load.js';
12
13
  import { QUICK_TASK_WORKFLOW, normalizeWorkflowDoc, skillStackOf, skillsToSteps, stepsIssue, workflowFileSchema, workflowStepSchema, } from '../workflows/types.js';
13
14
  import { planChain, slugify } from '../planner.js';
@@ -15,13 +16,19 @@ import { discoverSkills } from '../skills.js';
15
16
  import { refreshTeamSkills } from '../skills-remote.js';
16
17
  import { appendHandoffHeartbeat, handoffProgressExcerpt, readHandoff } from '../handoff.js';
17
18
  import { markStarted, onTodosChanged, readTodos, removeTodo, startTodosWatch } from '../todos.js';
19
+ import { isV2WireEventType } from '../runs/ui-event-sink.js';
18
20
  import { removeWorktree, worktreeDiff, worktreeDiffStat } from '../git-worktree.js';
19
21
  import { getBranches, getCommit, getDiff, getLog, getRepoInfo, getStatus } from './git.js';
22
+ import { collectChanges, collectCommitChanges, collectRunCommits, commitAll, createOrSwitchBranch, imageMimeType, pushCurrentBranch, readWorktreePath, } from './git-changes.js';
20
23
  import { loadConfig } from '../config.js';
24
+ import { resolveCapabilities } from './capabilities.js';
25
+ import { resolveForge } from './forge/index.js';
21
26
  import { fetchGithub } from './github.js';
22
27
  import { ensureLaunchKey } from './launch-key.js';
23
28
  import { openInTerminal } from './open-in-terminal.js';
29
+ import { agentCliRunner, detectOpenTargets, openInApp } from './open-in-app.js';
24
30
  import { createDraftPr } from './pr.js';
31
+ import { ASSET_CACHE_CONTROL, BUILD_HINT_HTML, assetContentType, isSafeAssetFilename, resolveGetRequest } from './static-ui.js';
25
32
  // A run starts from a named workflow OR an inline chain of steps (spec 008 —
26
33
  // the approved plan is posted as-is, never written to a file).
27
34
  const startRunSchema = z
@@ -35,6 +42,21 @@ const startRunSchema = z
35
42
  // Parallel variants (spec 010): ×2/×3 runs the task as 2–3 competing
36
43
  // agents in separate worktrees; the user compares diffs and picks one.
37
44
  variants: z.number().int().min(1).max(3).optional(),
45
+ // Composer worktree opt-out (#worktree-toggle): false runs in the repo
46
+ // working tree (read-only skills). Ignored when variants > 1.
47
+ worktree: z.boolean().optional(),
48
+ // Autonomous mode (#autonomous): the run never parks at `waiting` — it
49
+ // auto-continues until the agent signals done. No "needs you" is raised.
50
+ autonomous: z.boolean().optional(),
51
+ // Per-run system-prompt override (R2 2.3) — programmatic callers only
52
+ // (bookmarklets, scripts); deliberately NOT a composer-UI control. Wins
53
+ // over the config.json default; whitespace-only degrades to absent.
54
+ systemPrompt: z
55
+ .string()
56
+ .trim()
57
+ .max(20_000, 'systemPrompt must be at most 20000 characters')
58
+ .optional()
59
+ .transform((s) => (s ? s : undefined)),
38
60
  // Screenshots pasted into the new-task form — same shape and limits as a
39
61
  // live-session message; delivered with the first agent step's opening.
40
62
  images: z
@@ -81,8 +103,36 @@ const uiStateSchema = z
81
103
  lastTask: z
82
104
  .object({ source: z.enum(['workflow', 'skill']), ref: z.string().min(1).max(200) })
83
105
  .optional(),
106
+ // Composer picker recency (newest first, capped) + the remembered worktree
107
+ // choice for single-skill runs. Additive prefs, like the rest of ui-state.
108
+ recentSources: z
109
+ .array(z.object({ source: z.enum(['workflow', 'skill']), ref: z.string().min(1).max(200) }))
110
+ .max(50)
111
+ .optional(),
112
+ lastWorktree: z.boolean().optional(),
113
+ lastAutonomous: z.boolean().optional(),
114
+ // Runs area presentation (#348): the sidebar-list + detail pane, or the
115
+ // full-width table ("task manager") view.
116
+ runsView: z.enum(['list', 'table']).optional(),
117
+ // Settings → Appearance (redesign R6): accent + density. ADDITIVE — the theme itself
118
+ // stays in the browser (`cez-theme` localStorage, pre-paint). The cockpit always PUTs
119
+ // the whole object because the top-level merge below is shallow.
120
+ appearance: z
121
+ .object({
122
+ accent: z.enum(['lime', 'violet']).optional(),
123
+ density: z.enum(['comfortable', 'compact', 'ultra']).optional(),
124
+ })
125
+ .optional(),
84
126
  })
85
127
  .passthrough();
128
+ // Editable titles (#389). `title` is the only editable field for now.
129
+ const patchRunSchema = z.object({
130
+ title: z.string().trim().min(1).max(300).optional(),
131
+ });
132
+ // Session commit (redesign R5 — §"Git/session API additions").
133
+ const gitCommitSchema = z.object({
134
+ message: z.string().trim().min(1, 'commit message must not be empty').max(5_000),
135
+ });
86
136
  const messageSchema = z
87
137
  .object({
88
138
  text: z.string().max(100_000).default(''),
@@ -99,24 +149,59 @@ const messageSchema = z
99
149
  message: 'message needs text or at least one image',
100
150
  });
101
151
  export function createApp(deps) {
102
- const { repoRoot, store, manager, version } = deps;
152
+ const { repoRoot, store, manager, version, update, bindHost } = deps;
103
153
  const dataDir = join(repoRoot, '.ai/cezar');
154
+ // Hosted-mode gate (spec §"Deployment modes") — read per request so
155
+ // CEZ_REMOTE flips take effect live (and tests can toggle it).
156
+ const capabilities = () => resolveCapabilities(process.env, bindHost);
104
157
  startTodosWatch(dataDir); // inbox live updates (spec 007)
105
158
  const launchKey = ensureLaunchKey(dataDir); // bookmarklet auto-start secret (spec 011)
106
159
  const app = new Hono();
107
160
  // ---- static GUI ----------------------------------------------------------
108
161
  const webDir = resolveWebDir();
162
+ const distDir = join(webDir, 'dist');
163
+ const HTML_TYPE = 'text/html; charset=utf-8';
109
164
  const staticFile = (name, type) => () => {
110
165
  // Read per request — the files are tiny and this keeps dev iteration live.
111
166
  const body = readFileSync(join(webDir, name));
112
167
  return new Response(body, { headers: { 'content-type': type } });
113
168
  };
114
- app.get('/', staticFile('index.html', 'text/html; charset=utf-8'));
115
- // Deep-link target for the bookmarklets (spec 011) same SPA, the query
116
- // string (`?skill=…&ref=…&auto=…&key=…`) is handled by web/app.js init().
117
- app.get('/new', staticFile('index.html', 'text/html; charset=utf-8'));
118
- app.get('/app.js', staticFile('app.js', 'text/javascript; charset=utf-8'));
119
- app.get('/style.css', staticFile('style.css', 'text/css; charset=utf-8'));
169
+ let hintLogged = false;
170
+ const serveShell = (c) => {
171
+ const distIndex = join(distDir, 'index.html');
172
+ // existsSync per request, like the reads below: `npm run build:web` in a
173
+ // running cockpit takes effect on the next reload, no restart.
174
+ const target = resolveGetRequest({ path: c.req.path, distExists: existsSync(distIndex) });
175
+ if (target === 'passthrough')
176
+ return undefined;
177
+ if (target === 'build-hint') {
178
+ // Dev-only state (the tarball ships web/dist): serve the built-in hint
179
+ // page instead of the app — the legacy fallback UI was deleted in R7.
180
+ if (!hintLogged) {
181
+ hintLogged = true;
182
+ console.log('cezar: web/dist is missing — run `npm run build:web` to build the cockpit');
183
+ }
184
+ return new Response(BUILD_HINT_HTML, { headers: { 'content-type': HTML_TYPE } });
185
+ }
186
+ return new Response(readFileSync(distIndex), { headers: { 'content-type': HTML_TYPE } });
187
+ };
188
+ // Hashed bundles/fonts of the built app. Vite fingerprints every name, so
189
+ // the bytes behind a URL never change — cache them hard. Only plain
190
+ // filenames are served: `basename('..')` is `'..'` (it resolves to the
191
+ // assets dir itself and readFileSync would throw EISDIR), so dot-segments
192
+ // and separator-bearing params get a 404, not a 500.
193
+ app.get('/assets/:file', (c) => {
194
+ const file = c.req.param('file');
195
+ if (!isSafeAssetFilename(file))
196
+ return c.json({ error: 'not found' }, 404);
197
+ const path = join(distDir, 'assets', file);
198
+ if (!existsSync(path) || !statSync(path).isFile())
199
+ return c.json({ error: 'not found' }, 404);
200
+ return new Response(readFileSync(path), {
201
+ headers: { 'content-type': assetContentType(file), 'cache-control': ASSET_CACHE_CONTROL },
202
+ });
203
+ });
204
+ // The favicon web/app/index.html points at (`../open-mercato.svg`).
120
205
  app.get('/open-mercato.svg', staticFile('open-mercato.svg', 'image/svg+xml'));
121
206
  // ---- meta ----------------------------------------------------------------
122
207
  // CORS — deliberately for /api/health ONLY (spec 011): the bookmarklets
@@ -139,7 +224,21 @@ export function createApp(deps) {
139
224
  getRepoInfo(repoRoot),
140
225
  loadConfig(repoRoot),
141
226
  ]);
142
- return c.json({ version, repoRoot, repo, checks, defaultRunner: config.defaultRunner });
227
+ // Additive fields only below the pre-forge shape is the most
228
+ // externally-depended-on JSON in the app (BACKWARD_COMPATIBILITY.md §2).
229
+ const forge = resolveForge(repo);
230
+ return c.json({
231
+ version,
232
+ latestVersion: update?.latest,
233
+ repoRoot,
234
+ repo,
235
+ checks,
236
+ defaultRunner: config.defaultRunner,
237
+ // Non-blocking: cached availability or null-until-warm — health must never pay a `gh`
238
+ // shell-out (the bookmarklet aborts its port probe at 800 ms). See detectGithubCached.
239
+ forge: forge ? { kind: forge.kind, ...(forge.detectCached() ?? {}) } : null,
240
+ capabilities: capabilities(),
241
+ });
143
242
  });
144
243
  // The bookmarklet generator bakes this key into the `javascript:` URLs —
145
244
  // `/new?auto=1` is honored only with it (spec 011). Same-origin only.
@@ -281,7 +380,14 @@ export function createApp(deps) {
281
380
  return c.json(await planChain(repoRoot, parsed.data.task));
282
381
  });
283
382
  // ---- runs ----------------------------------------------------------------
284
- app.get('/api/runs', (c) => c.json(store.listRuns()));
383
+ // Additive `usage` field (#348): the latest CPU/RSS/proc-count sample of the
384
+ // run's live process tree — absent for finished runs and when `ps` yields
385
+ // nothing. The stored record itself is never touched.
386
+ const withUsage = (run) => {
387
+ const usage = currentUsage(run.id);
388
+ return usage ? { ...run, usage } : run;
389
+ };
390
+ app.get('/api/runs', (c) => c.json(store.listRuns().map(withUsage)));
285
391
  // Registered before the `/:id/...` routes so "archive-finished" never
286
392
  // matches as a run id.
287
393
  app.post('/api/runs/archive-finished', (c) => c.json({ archived: store.archiveFinished() }));
@@ -314,7 +420,15 @@ export function createApp(deps) {
314
420
  type: 'image',
315
421
  source: { type: 'base64', media_type: img.mediaType, data: img.data },
316
422
  }));
317
- const input = { task: parsed.data.task, model: parsed.data.model, runner: parsed.data.runner, images };
423
+ const input = {
424
+ task: parsed.data.task,
425
+ model: parsed.data.model,
426
+ runner: parsed.data.runner,
427
+ images,
428
+ systemPrompt: parsed.data.systemPrompt,
429
+ worktree: parsed.data.worktree,
430
+ autonomous: parsed.data.autonomous,
431
+ };
318
432
  const variants = parsed.data.variants ?? 1;
319
433
  if (variants > 1) {
320
434
  // Variants live in worktrees — without git there's nothing to isolate
@@ -403,7 +517,26 @@ export function createApp(deps) {
403
517
  });
404
518
  app.get('/api/runs/:id', (c) => {
405
519
  const run = store.getRun(c.req.param('id'));
406
- return run ? c.json(run) : c.json({ error: 'not found' }, 404);
520
+ return run ? c.json(withUsage(run)) : c.json({ error: 'not found' }, 404);
521
+ });
522
+ // Editable titles (#389). The UI displays `titleSummary ?? title`, so a
523
+ // user edit sets BOTH: `title` (the record's own name — the raw task stops
524
+ // being it the moment the user renames the run) and `titleSummary` (what
525
+ // actually displays). The auto-summarizer only ever fills an *unset*
526
+ // titleSummary (RunManager.recordTurnEnd), so an edit wins over any past or
527
+ // future auto-summary. Answers the updated record.
528
+ app.patch('/api/runs/:id', async (c) => {
529
+ const id = c.req.param('id');
530
+ if (!store.getRun(id))
531
+ return c.json({ error: 'not found' }, 404);
532
+ const parsed = patchRunSchema.safeParse(await c.req.json().catch(() => null));
533
+ if (!parsed.success) {
534
+ return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
535
+ }
536
+ if (parsed.data.title !== undefined) {
537
+ store.updateRun(id, { title: parsed.data.title, titleSummary: parsed.data.title });
538
+ }
539
+ return c.json(store.getRun(id));
407
540
  });
408
541
  app.post('/api/runs/:id/cancel', (c) => {
409
542
  const id = c.req.param('id');
@@ -464,6 +597,11 @@ export function createApp(deps) {
464
597
  const run = store.getRun(id);
465
598
  if (!run)
466
599
  return c.json({ error: 'not found' }, 404);
600
+ // Hosted mode: there is no "my machine" to open a terminal on. The UI
601
+ // hides the button when localHandoff is false — this is defense in depth.
602
+ if (!capabilities().localHandoff) {
603
+ return c.json({ error: 'local handoff is disabled — this cockpit runs in hosted mode (CEZ_REMOTE); resume the session from a machine that has the checkout' }, 409);
604
+ }
467
605
  const sessionId = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
468
606
  if (!sessionId)
469
607
  return c.json({ error: 'no agent session to resume' }, 409);
@@ -475,6 +613,41 @@ export function createApp(deps) {
475
613
  }
476
614
  return c.json({ opened: true, command });
477
615
  });
616
+ // "Open in…" session takeover (#open-in): the editors/file-manager/terminal
617
+ // detected on THIS machine. Empty in hosted mode (no local desktop to open).
618
+ app.get('/api/open-targets', (c) => c.json({ targets: capabilities().localHandoff ? detectOpenTargets() : [] }));
619
+ // Open a run's worktree (or the repo root) in the chosen local app.
620
+ app.post('/api/runs/:id/open-in', async (c) => {
621
+ const id = c.req.param('id');
622
+ const run = store.getRun(id);
623
+ if (!run)
624
+ return c.json({ error: 'not found' }, 404);
625
+ if (!capabilities().localHandoff) {
626
+ return c.json({ error: 'local handoff is disabled — this cockpit runs in hosted mode (CEZ_REMOTE)' }, 409);
627
+ }
628
+ const body = (await c.req.json().catch(() => ({})));
629
+ const target = typeof body.target === 'string' ? body.target : '';
630
+ if (!target)
631
+ return c.json({ error: 'target required' }, 400);
632
+ const dir = run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : repoRoot;
633
+ // Coding-agent CLI handoff (#cli-handoff): open a terminal in the worktree that resumes THIS
634
+ // run's session when the chosen CLI is the run's own runner (and a session exists), or starts
635
+ // a fresh CLI there otherwise. Same terminal launcher the Terminal button uses.
636
+ const cliRunner = agentCliRunner(target);
637
+ if (cliRunner) {
638
+ const sessionId = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
639
+ const command = sessionId && cliRunner === run.runner ? resumeCommand(cliRunner, sessionId) : cliRunner;
640
+ const opened = await openInTerminal(dir, command);
641
+ if (!opened) {
642
+ return c.json({ error: 'no terminal emulator found', command: `cd '${dir}' && ${command}` }, 409);
643
+ }
644
+ return c.json({ opened: true, path: dir, command });
645
+ }
646
+ const opened = await openInApp(target, dir);
647
+ if (!opened)
648
+ return c.json({ error: `could not open ${target}`, path: dir }, 409);
649
+ return c.json({ opened: true, path: dir });
650
+ });
478
651
  // Handoff journal (spec 007): the per-task handoff.md as markdown. 404 only
479
652
  // when the task is unknown; a task without a (yet) seeded file returns ''.
480
653
  app.get('/api/runs/:id/handoff', (c) => {
@@ -516,6 +689,121 @@ export function createApp(deps) {
516
689
  }
517
690
  return c.text(await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD'));
518
691
  });
692
+ // ---- session git view (redesign R5 Step 1.2 — §"Git/session API additions").
693
+ // Structured sibling of the text-blob /diff above (which stays untouched —
694
+ // protected surface). Same worktree/base resolution; every predictable git
695
+ // failure degrades to 409 + human-readable reason, 404 only for unknown ids.
696
+ const worktreeOf = (run) => run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : null;
697
+ const NO_WORKTREE = 'no worktree — this task ran directly in the repo working tree';
698
+ app.get('/api/runs/:id/changes', async (c) => {
699
+ const run = store.getRun(c.req.param('id'));
700
+ if (!run)
701
+ return c.json({ error: 'not found' }, 404);
702
+ const worktree = worktreeOf(run);
703
+ if (!worktree)
704
+ return c.json({ error: NO_WORKTREE }, 409);
705
+ const result = await collectChanges(worktree, run.baseBranch ?? 'HEAD');
706
+ if (!result.ok)
707
+ return c.json({ error: result.error }, 409);
708
+ return c.json(result.changes);
709
+ });
710
+ // The run's own commits (<base>..HEAD on the worktree branch) — the Commits tab.
711
+ app.get('/api/runs/:id/commits', async (c) => {
712
+ const run = store.getRun(c.req.param('id'));
713
+ if (!run)
714
+ return c.json({ error: 'not found' }, 404);
715
+ const worktree = worktreeOf(run);
716
+ if (!worktree)
717
+ return c.json({ error: NO_WORKTREE }, 409);
718
+ const result = await collectRunCommits(worktree, run.baseBranch ?? 'HEAD');
719
+ if (!result.ok)
720
+ return c.json({ error: result.error }, 409);
721
+ return c.json({ commits: result.commits });
722
+ });
723
+ // One of the run's commits, structured like the Changes tab (reuses collectCommitChanges).
724
+ app.get('/api/runs/:id/commit/:sha', async (c) => {
725
+ const run = store.getRun(c.req.param('id'));
726
+ if (!run)
727
+ return c.json({ error: 'not found' }, 404);
728
+ const worktree = worktreeOf(run);
729
+ if (!worktree)
730
+ return c.json({ error: NO_WORKTREE }, 409);
731
+ const result = await collectCommitChanges(worktree, c.req.param('sha'));
732
+ if (!result.ok)
733
+ return c.json({ error: result.error }, 409);
734
+ return c.json(result.commit);
735
+ });
736
+ // Files tab: directory listing (path omitted or a dir) or file content
737
+ // (size-capped, binary flagged). Traversal-safe — readWorktreePath rejects
738
+ // anything escaping the worktree. `raw=1` (R5 Step 1.6) serves the BYTES of
739
+ // image files only, for the preview's inline <img> — never HTML/JS/etc., so
740
+ // no worktree file can become a same-origin document, and never past the
741
+ // size cap. The no-script CSP neutralizes SVG opened as a top-level URL.
742
+ app.get('/api/runs/:id/files', async (c) => {
743
+ const run = store.getRun(c.req.param('id'));
744
+ if (!run)
745
+ return c.json({ error: 'not found' }, 404);
746
+ const worktree = worktreeOf(run);
747
+ if (!worktree)
748
+ return c.json({ error: NO_WORKTREE }, 409);
749
+ const result = await readWorktreePath(worktree, c.req.query('path') ?? '');
750
+ if (result.kind === 'invalid' || result.kind === 'missing') {
751
+ return c.json({ error: result.error }, 409);
752
+ }
753
+ if (result.kind === 'dir') {
754
+ return c.json({ type: 'dir', path: result.path, entries: result.entries });
755
+ }
756
+ if (c.req.query('raw') === '1') {
757
+ const mime = imageMimeType(result.path);
758
+ if (!mime)
759
+ return c.json({ error: `raw serving is limited to images: ${result.path}` }, 409);
760
+ if (result.tooLarge) {
761
+ return c.json({ error: `file too large to serve raw (${result.size} bytes): ${result.path}` }, 409);
762
+ }
763
+ const bytes = await readFile(join(worktree, result.path));
764
+ return c.body(new Uint8Array(bytes).buffer, 200, {
765
+ 'content-type': mime,
766
+ 'x-content-type-options': 'nosniff',
767
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
768
+ });
769
+ }
770
+ return c.json({
771
+ type: 'file',
772
+ path: result.path,
773
+ size: result.size,
774
+ binary: result.binary,
775
+ tooLarge: result.tooLarge,
776
+ ...(result.content !== undefined ? { content: result.content } : {}),
777
+ });
778
+ });
779
+ app.post('/api/runs/:id/git/commit', async (c) => {
780
+ const run = store.getRun(c.req.param('id'));
781
+ if (!run)
782
+ return c.json({ error: 'not found' }, 404);
783
+ const worktree = worktreeOf(run);
784
+ if (!worktree)
785
+ return c.json({ error: NO_WORKTREE }, 409);
786
+ const parsed = gitCommitSchema.safeParse(await c.req.json().catch(() => null));
787
+ if (!parsed.success) {
788
+ return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
789
+ }
790
+ const result = await commitAll(worktree, parsed.data.message);
791
+ if (!result.ok)
792
+ return c.json({ error: result.error }, 409);
793
+ return c.json({ committed: true, sha: result.sha });
794
+ });
795
+ app.post('/api/runs/:id/git/push', async (c) => {
796
+ const run = store.getRun(c.req.param('id'));
797
+ if (!run)
798
+ return c.json({ error: 'not found' }, 404);
799
+ const worktree = worktreeOf(run);
800
+ if (!worktree)
801
+ return c.json({ error: NO_WORKTREE }, 409);
802
+ const result = await pushCurrentBranch(worktree);
803
+ if (!result.ok)
804
+ return c.json({ error: result.error }, 409);
805
+ return c.json({ pushed: true, branch: result.branch, remote: result.remote, upstreamSet: result.upstreamSet });
806
+ });
519
807
  // Draft PR from the review gate (spec 009): final autosave → push →
520
808
  // `gh pr create --draft`; on success the run completes as done with the PR
521
809
  // badge. Failures come back as 409 with a `manual` merge command the GUI
@@ -621,7 +909,17 @@ export function createApp(deps) {
621
909
  let replaying = true;
622
910
  let maxSeq = 0;
623
911
  const buffered = [];
624
- const writeEvent = (event) => stream.writeSSE({ event: 'run-event', data: JSON.stringify(event) });
912
+ // One endpoint, two SSE event names: v1 lines stay `run-event` (the name
913
+ // the legacy UI listened to — its default branch JSON-dumped unknown
914
+ // types into the transcript, which is why v2 never rode that name; the
915
+ // split outlives the R7 retirement as wire shape); protocol-v2 lines
916
+ // (dotted types, persisted snapshots AND ephemeral coalesced deltas)
917
+ // ride `ui-event`, which only v2-aware clients subscribe to.
918
+ // EventSource ignores names it has no listener for.
919
+ const writeEvent = (event) => stream.writeSSE({
920
+ event: isV2WireEventType(event.type) ? 'ui-event' : 'run-event',
921
+ data: JSON.stringify(event),
922
+ });
625
923
  const onEvent = (payload) => {
626
924
  if (payload.runId !== id)
627
925
  return;
@@ -668,12 +966,18 @@ export function createApp(deps) {
668
966
  await stream.writeSSE({ event: 'todos', data: JSON.stringify(items) });
669
967
  };
670
968
  const offTodos = onTodosChanged(() => void sendTodos());
969
+ // Live resource telemetry (#348): the sampler ticks ~every 2 s only
970
+ // while some run has a registered process; each tick is relayed as one
971
+ // `usage` message (runId → {cpuPct, rssBytes, procCount}). Never
972
+ // persisted — the NDJSON transcripts stay usage-free.
973
+ const offUsage = onUsage((usage) => void stream.writeSSE({ event: 'usage', data: JSON.stringify(usage) }));
671
974
  store.on('run', onRun);
672
975
  store.on('deleted', onDeleted);
673
976
  stream.onAbort(() => {
674
977
  store.off('run', onRun);
675
978
  store.off('deleted', onDeleted);
676
979
  offTodos();
980
+ offUsage();
677
981
  });
678
982
  while (!stream.aborted) {
679
983
  await stream.writeSSE({ event: 'ping', data: '' });
@@ -701,12 +1005,39 @@ export function createApp(deps) {
701
1005
  ]);
702
1006
  return c.json({ info, status, log, branches, baseBranch: config.baseBranch ?? null });
703
1007
  });
704
- // Set/clear the agents' base branch (Repo tab). Merges into the RAW
705
- // config.json so user keys (skillsRepos…) survive and schema defaults are
706
- // never materialized into the file.
1008
+ // The Settings Agents knobs in one read (R6 Step 1.5) an ADDITIVE
1009
+ // sibling of PUT /api/config below; /api/health keeps its protected shape.
1010
+ const configAnswer = (config) => ({
1011
+ baseBranch: config.baseBranch ?? null,
1012
+ defaultRunner: config.defaultRunner,
1013
+ systemPrompt: config.systemPrompt ?? null,
1014
+ defaultModels: config.defaultModels ?? {},
1015
+ maxParallel: config.maxParallel,
1016
+ memoryLimitMb: config.memoryLimitMb ?? null,
1017
+ });
1018
+ app.get('/api/config', async (c) => c.json(configAnswer(await loadConfig(repoRoot))));
1019
+ // Set/clear the agents' config knobs (Settings → Agents; the Repo tab's
1020
+ // base-branch picker). Merges into the RAW config.json so user keys
1021
+ // (skillsRepos…) survive and schema defaults are never materialized into
1022
+ // the file. All fields optional + additive: `null` (and `''` for the
1023
+ // R6 keys) clears a knob back to its default.
1024
+ const modelPresetSchema = z.string().trim().max(200).nullable().optional();
707
1025
  const setConfigSchema = z.object({
708
1026
  baseBranch: z.string().trim().min(1).max(200).nullable().optional(),
709
1027
  defaultRunner: z.enum(['claude', 'codex', 'opencode']).optional(),
1028
+ systemPrompt: z
1029
+ .string()
1030
+ .trim()
1031
+ .max(20_000, 'systemPrompt must be at most 20000 characters')
1032
+ .nullable()
1033
+ .optional(),
1034
+ defaultModels: z
1035
+ .object({ claude: modelPresetSchema, codex: modelPresetSchema, opencode: modelPresetSchema })
1036
+ .optional(),
1037
+ // Concurrency + memory guard (Settings → Resources). maxParallel clamps to
1038
+ // the schema's 1–16; memoryLimitMb null/0 clears the ceiling.
1039
+ maxParallel: z.number().int().min(1).max(16).optional(),
1040
+ memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
710
1041
  });
711
1042
  app.put('/api/config', async (c) => {
712
1043
  const parsed = setConfigSchema.safeParse(await c.req.json().catch(() => null));
@@ -731,6 +1062,44 @@ export function createApp(deps) {
731
1062
  }
732
1063
  if (parsed.data.defaultRunner !== undefined)
733
1064
  raw.defaultRunner = parsed.data.defaultRunner;
1065
+ if (parsed.data.systemPrompt !== undefined) {
1066
+ // '' and null both clear: an emptied textarea means "no extra prompt".
1067
+ if (parsed.data.systemPrompt === null || parsed.data.systemPrompt === '') {
1068
+ delete raw.systemPrompt;
1069
+ }
1070
+ else {
1071
+ raw.systemPrompt = parsed.data.systemPrompt;
1072
+ }
1073
+ }
1074
+ if (parsed.data.maxParallel !== undefined)
1075
+ raw.maxParallel = parsed.data.maxParallel;
1076
+ if (parsed.data.memoryLimitMb !== undefined) {
1077
+ // null or 0 both mean "no ceiling" — drop the key back to the default.
1078
+ if (parsed.data.memoryLimitMb === null || parsed.data.memoryLimitMb === 0) {
1079
+ delete raw.memoryLimitMb;
1080
+ }
1081
+ else {
1082
+ raw.memoryLimitMb = parsed.data.memoryLimitMb;
1083
+ }
1084
+ }
1085
+ if (parsed.data.defaultModels !== undefined) {
1086
+ // Per-runner merge, so setting codex's preset never clobbers claude's.
1087
+ const current = raw.defaultModels && typeof raw.defaultModels === 'object'
1088
+ ? { ...raw.defaultModels }
1089
+ : {};
1090
+ for (const [runner, model] of Object.entries(parsed.data.defaultModels)) {
1091
+ if (model === undefined)
1092
+ continue;
1093
+ if (model === null || model === '')
1094
+ delete current[runner];
1095
+ else
1096
+ current[runner] = model;
1097
+ }
1098
+ if (Object.keys(current).length === 0)
1099
+ delete raw.defaultModels;
1100
+ else
1101
+ raw.defaultModels = current;
1102
+ }
734
1103
  try {
735
1104
  await mkdir(dataDir, { recursive: true });
736
1105
  await writeFile(configPath, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
@@ -738,8 +1107,8 @@ export function createApp(deps) {
738
1107
  catch (err) {
739
1108
  return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
740
1109
  }
741
- const config = await loadConfig(repoRoot);
742
- return c.json({ baseBranch: config.baseBranch ?? null, defaultRunner: config.defaultRunner });
1110
+ // Pre-R6 answer shape ({baseBranch, defaultRunner}) + additive R6 fields.
1111
+ return c.json(configAnswer(await loadConfig(repoRoot)));
743
1112
  });
744
1113
  app.get('/api/repo/diff', async (c) => {
745
1114
  const info = await getRepoInfo(repoRoot);
@@ -748,8 +1117,20 @@ export function createApp(deps) {
748
1117
  return c.text(await getDiff(info.root));
749
1118
  });
750
1119
  // One commit's message + stat + patch — the Repo view expands it inline.
1120
+ // `?structured=1` is the ADDITIVE sibling (R5 Step 1.7): the new repo view's commit-diff
1121
+ // shape `{sha, subject, author, when, files, stat}` with 409 + reason on failure. The
1122
+ // legacy text answer below is a protected surface (BACKWARD_COMPATIBILITY.md §2) — its
1123
+ // shape, including the in-band failure sentences, stays exactly as it was.
751
1124
  app.get('/api/repo/commit/:sha', async (c) => {
752
1125
  const info = await getRepoInfo(repoRoot);
1126
+ if (c.req.query('structured') === '1') {
1127
+ if (!info)
1128
+ return c.json({ error: 'not a git repository' }, 409);
1129
+ const result = await collectCommitChanges(info.root, c.req.param('sha'));
1130
+ if (!result.ok)
1131
+ return c.json({ error: result.error }, 409);
1132
+ return c.json(result.commit);
1133
+ }
753
1134
  if (!info)
754
1135
  return c.text('not a git repository');
755
1136
  try {
@@ -759,11 +1140,59 @@ export function createApp(deps) {
759
1140
  return c.text(`(git show failed: ${err instanceof Error ? err.message : String(err)})`);
760
1141
  }
761
1142
  });
1143
+ // Structured sibling of the text-blob /api/repo/diff above (protected
1144
+ // surface, untouched): the same {files, stat} shape the session /changes
1145
+ // route serves, here for the MAIN working tree's uncommitted changes vs
1146
+ // HEAD (redesign R5 Step 1.3 — §"Git/session API additions").
1147
+ app.get('/api/repo/changes', async (c) => {
1148
+ const info = await getRepoInfo(repoRoot);
1149
+ if (!info)
1150
+ return c.json({ error: 'not a git repository' }, 409);
1151
+ // The user's REAL working tree — never stage into their index (a GET must not write).
1152
+ const result = await collectChanges(info.root, 'HEAD', { intentToAdd: false });
1153
+ if (!result.ok)
1154
+ return c.json({ error: result.error }, 409);
1155
+ return c.json(result.changes);
1156
+ });
1157
+ // Repo view branch actions: switch to an existing branch, or create one
1158
+ // (from `from` or HEAD) and switch. Predictable git failures — invalid
1159
+ // name, unknown `from`, dirty-tree checkout conflict — are 409 + reason.
1160
+ const repoBranchSchema = z.object({
1161
+ name: z.string().trim().min(1).max(200),
1162
+ from: z.string().trim().min(1).max(200).optional(),
1163
+ });
1164
+ app.post('/api/repo/branch', async (c) => {
1165
+ const info = await getRepoInfo(repoRoot);
1166
+ if (!info)
1167
+ return c.json({ error: 'not a git repository' }, 409);
1168
+ const parsed = repoBranchSchema.safeParse(await c.req.json().catch(() => null));
1169
+ if (!parsed.success) {
1170
+ return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
1171
+ }
1172
+ const result = await createOrSwitchBranch(info.root, parsed.data.name, parsed.data.from);
1173
+ if (!result.ok)
1174
+ return c.json({ error: result.error }, 409);
1175
+ return c.json({ branch: result.branch, created: result.created });
1176
+ });
1177
+ // ---- SPA catch-all -------------------------------------------------------
1178
+ // Last, so every route above still wins. Any other GET gets the cockpit shell:
1179
+ // react-router owns the route map, including the 404, so `/tasks/:id/changes`
1180
+ // cold-loads and survives a refresh instead of 404ing. `/api/*` and the static
1181
+ // files above resolve to `passthrough` and fall through to Hono's own 404 —
1182
+ // an unknown API path must never answer with HTML.
1183
+ // Without a web/dist build this serves the built-in build-hint page (dev-only
1184
+ // state — the published tarball ships web/dist), never a 404.
1185
+ app.get('*', (c) => serveShell(c) ?? c.notFound());
762
1186
  return app;
763
1187
  }
764
1188
  export function startServer(deps, port) {
765
1189
  const app = createApp(deps);
766
- return serve({ fetch: app.fetch, port, hostname: '127.0.0.1' });
1190
+ // SECURITY: default to loopback. This server executes agents locally and its endpoints are
1191
+ // same-origin-trusted (only /api/health is CORS-open); binding to a non-loopback host would
1192
+ // expose an agent-executing box to the network. `bindHost` exists only for a deliberate
1193
+ // hosted/VPS deployment (which also flips CEZ_REMOTE to gate the local-handoff endpoints) —
1194
+ // src/index.ts never passes it, so the loopback guarantee holds for the normal CLI.
1195
+ return serve({ fetch: app.fetch, port, hostname: deps.bindHost ?? '127.0.0.1' });
767
1196
  }
768
1197
  function resolveWebDir() {
769
1198
  const here = dirname(fileURLToPath(import.meta.url));