@open-mercato/cezar 0.1.5-pr506.357
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/README.md +573 -0
- package/dist/config.d.ts +144 -0
- package/dist/config.js +106 -0
- package/dist/config.js.map +1 -0
- package/dist/core/agent-runner.d.ts +169 -0
- package/dist/core/agent-runner.js +23 -0
- package/dist/core/agent-runner.js.map +1 -0
- package/dist/core/backend-detect.d.ts +16 -0
- package/dist/core/backend-detect.js +118 -0
- package/dist/core/backend-detect.js.map +1 -0
- package/dist/core/claude-cli-runner.d.ts +56 -0
- package/dist/core/claude-cli-runner.js +416 -0
- package/dist/core/claude-cli-runner.js.map +1 -0
- package/dist/core/claude-ui-mapper.d.ts +71 -0
- package/dist/core/claude-ui-mapper.js +567 -0
- package/dist/core/claude-ui-mapper.js.map +1 -0
- package/dist/core/codex-app-server-runner.d.ts +29 -0
- package/dist/core/codex-app-server-runner.js +489 -0
- package/dist/core/codex-app-server-runner.js.map +1 -0
- package/dist/core/codex-ui-mapper.d.ts +63 -0
- package/dist/core/codex-ui-mapper.js +491 -0
- package/dist/core/codex-ui-mapper.js.map +1 -0
- package/dist/core/ndjson.d.ts +6 -0
- package/dist/core/ndjson.js +23 -0
- package/dist/core/ndjson.js.map +1 -0
- package/dist/core/opencode-server-runner.d.ts +30 -0
- package/dist/core/opencode-server-runner.js +488 -0
- package/dist/core/opencode-server-runner.js.map +1 -0
- package/dist/core/opencode-ui-mapper.d.ts +105 -0
- package/dist/core/opencode-ui-mapper.js +588 -0
- package/dist/core/opencode-ui-mapper.js.map +1 -0
- package/dist/core/process-usage.d.ts +63 -0
- package/dist/core/process-usage.js +181 -0
- package/dist/core/process-usage.js.map +1 -0
- package/dist/core/runner-factory.d.ts +8 -0
- package/dist/core/runner-factory.js +22 -0
- package/dist/core/runner-factory.js.map +1 -0
- package/dist/core/tool-display.d.ts +30 -0
- package/dist/core/tool-display.js +156 -0
- package/dist/core/tool-display.js.map +1 -0
- package/dist/core/ui-events.d.ts +308 -0
- package/dist/core/ui-events.js +25 -0
- package/dist/core/ui-events.js.map +1 -0
- package/dist/core/usage.d.ts +11 -0
- package/dist/core/usage.js +15 -0
- package/dist/core/usage.js.map +1 -0
- package/dist/git-worktree.d.ts +88 -0
- package/dist/git-worktree.js +297 -0
- package/dist/git-worktree.js.map +1 -0
- package/dist/handoff.d.ts +62 -0
- package/dist/handoff.js +147 -0
- package/dist/handoff.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +466 -0
- package/dist/index.js.map +1 -0
- package/dist/install-as-command.d.ts +39 -0
- package/dist/install-as-command.js +51 -0
- package/dist/install-as-command.js.map +1 -0
- package/dist/pack-check.d.ts +17 -0
- package/dist/pack-check.js +27 -0
- package/dist/pack-check.js.map +1 -0
- package/dist/paths.d.ts +23 -0
- package/dist/paths.js +34 -0
- package/dist/paths.js.map +1 -0
- package/dist/planner.d.ts +17 -0
- package/dist/planner.js +248 -0
- package/dist/planner.js.map +1 -0
- package/dist/release/snapshot.d.ts +64 -0
- package/dist/release/snapshot.js +75 -0
- package/dist/release/snapshot.js.map +1 -0
- package/dist/release/stable.d.ts +46 -0
- package/dist/release/stable.js +57 -0
- package/dist/release/stable.js.map +1 -0
- package/dist/runs/auto-name.d.ts +75 -0
- package/dist/runs/auto-name.js +168 -0
- package/dist/runs/auto-name.js.map +1 -0
- package/dist/runs/retention.d.ts +60 -0
- package/dist/runs/retention.js +86 -0
- package/dist/runs/retention.js.map +1 -0
- package/dist/runs/review-gate.d.ts +18 -0
- package/dist/runs/review-gate.js +21 -0
- package/dist/runs/review-gate.js.map +1 -0
- package/dist/runs/store.d.ts +408 -0
- package/dist/runs/store.js +526 -0
- package/dist/runs/store.js.map +1 -0
- package/dist/runs/task-refs.d.ts +24 -0
- package/dist/runs/task-refs.js +74 -0
- package/dist/runs/task-refs.js.map +1 -0
- package/dist/runs/ui-event-sink.d.ts +81 -0
- package/dist/runs/ui-event-sink.js +210 -0
- package/dist/runs/ui-event-sink.js.map +1 -0
- package/dist/server/capabilities.d.ts +33 -0
- package/dist/server/capabilities.js +43 -0
- package/dist/server/capabilities.js.map +1 -0
- package/dist/server/forge/github.d.ts +70 -0
- package/dist/server/forge/github.js +375 -0
- package/dist/server/forge/github.js.map +1 -0
- package/dist/server/forge/index.d.ts +24 -0
- package/dist/server/forge/index.js +45 -0
- package/dist/server/forge/index.js.map +1 -0
- package/dist/server/forge/types.d.ts +80 -0
- package/dist/server/forge/types.js +2 -0
- package/dist/server/forge/types.js.map +1 -0
- package/dist/server/git-changes.d.ts +195 -0
- package/dist/server/git-changes.js +497 -0
- package/dist/server/git-changes.js.map +1 -0
- package/dist/server/git.d.ts +26 -0
- package/dist/server/git.js +103 -0
- package/dist/server/git.js.map +1 -0
- package/dist/server/github.d.ts +8 -0
- package/dist/server/github.js +8 -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/open-in-app.d.ts +51 -0
- package/dist/server/open-in-app.js +189 -0
- package/dist/server/open-in-app.js.map +1 -0
- package/dist/server/open-in-terminal.d.ts +25 -0
- package/dist/server/open-in-terminal.js +120 -0
- package/dist/server/open-in-terminal.js.map +1 -0
- package/dist/server/pr.d.ts +7 -0
- package/dist/server/pr.js +7 -0
- package/dist/server/pr.js.map +1 -0
- package/dist/server/server.d.ts +41 -0
- package/dist/server/server.js +1390 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/static-ui.d.ts +49 -0
- package/dist/server/static-ui.js +92 -0
- package/dist/server/static-ui.js.map +1 -0
- package/dist/server/wsl.d.ts +25 -0
- package/dist/server/wsl.js +92 -0
- package/dist/server/wsl.js.map +1 -0
- package/dist/server-install/engine.d.ts +33 -0
- package/dist/server-install/engine.js +314 -0
- package/dist/server-install/engine.js.map +1 -0
- package/dist/server-install/platforms/macosx-ngrok.d.ts +6 -0
- package/dist/server-install/platforms/macosx-ngrok.js +345 -0
- package/dist/server-install/platforms/macosx-ngrok.js.map +1 -0
- package/dist/server-install/platforms/ubuntu-vps.d.ts +39 -0
- package/dist/server-install/platforms/ubuntu-vps.js +746 -0
- package/dist/server-install/platforms/ubuntu-vps.js.map +1 -0
- package/dist/server-install/state.d.ts +26 -0
- package/dist/server-install/state.js +125 -0
- package/dist/server-install/state.js.map +1 -0
- package/dist/server-install/steps.d.ts +109 -0
- package/dist/server-install/steps.js +330 -0
- package/dist/server-install/steps.js.map +1 -0
- package/dist/server-install/strategies.d.ts +4 -0
- package/dist/server-install/strategies.js +20 -0
- package/dist/server-install/strategies.js.map +1 -0
- package/dist/server-install/types.d.ts +597 -0
- package/dist/server-install/types.js +98 -0
- package/dist/server-install/types.js.map +1 -0
- package/dist/server-install/ui.d.ts +42 -0
- package/dist/server-install/ui.js +130 -0
- package/dist/server-install/ui.js.map +1 -0
- package/dist/skills-banner.d.ts +21 -0
- package/dist/skills-banner.js +40 -0
- package/dist/skills-banner.js.map +1 -0
- 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 +45 -0
- package/dist/skills.js +182 -0
- package/dist/skills.js.map +1 -0
- package/dist/todos.d.ts +63 -0
- package/dist/todos.js +168 -0
- package/dist/todos.js.map +1 -0
- package/dist/ui-state.d.ts +12 -0
- package/dist/ui-state.js +25 -0
- package/dist/ui-state.js.map +1 -0
- package/dist/update-check.d.ts +12 -0
- package/dist/update-check.js +41 -0
- package/dist/update-check.js.map +1 -0
- package/dist/workflows/load.d.ts +15 -0
- package/dist/workflows/load.js +56 -0
- package/dist/workflows/load.js.map +1 -0
- package/dist/workflows/run.d.ts +281 -0
- package/dist/workflows/run.js +1511 -0
- package/dist/workflows/run.js.map +1 -0
- package/dist/workflows/types.d.ts +307 -0
- package/dist/workflows/types.js +171 -0
- package/dist/workflows/types.js.map +1 -0
- package/package.json +90 -0
- package/scripts/check-pack.mjs +46 -0
- package/scripts/dev.mjs +74 -0
- package/scripts/install-as-command.mjs +120 -0
- package/scripts/mock-claude.mjs +310 -0
- package/scripts/release-snapshot.mjs +130 -0
- package/scripts/release.mjs +119 -0
- package/scripts/test-process-usage.mjs +47 -0
- package/web/dist/assets/arrow-left-BE957rJs.js +1 -0
- package/web/dist/assets/bundle-mjs-DLPpRFyl.js +1 -0
- package/web/dist/assets/centered-state-BddhEEtO.js +43 -0
- package/web/dist/assets/chunk-BO2N2NFS-f5igTtpB.js +131 -0
- package/web/dist/assets/command-CjhFbABo.js +1 -0
- package/web/dist/assets/compare-variants-Cs52IAEG.js +1 -0
- package/web/dist/assets/core-BuMqAF_1.js +12 -0
- package/web/dist/assets/css-BsVw1vtW.js +1 -0
- package/web/dist/assets/dialog-DFvXTkFv.js +1 -0
- package/web/dist/assets/diff-view-C9b8AITq.js +4 -0
- package/web/dist/assets/diff-woXpYk--.js +1 -0
- package/web/dist/assets/dist-FBs4wmIY.js +1 -0
- package/web/dist/assets/dist-o_kVMXjg.js +1 -0
- package/web/dist/assets/engine-javascript-FQvzRocB.js +141 -0
- package/web/dist/assets/file-BVuztAOd.js +1 -0
- package/web/dist/assets/folder-0pJ1NZ_S.js +1 -0
- package/web/dist/assets/git-pull-request-DJkAMT0f.js +1 -0
- package/web/dist/assets/git-toolbar-CwhQdx3J.js +1 -0
- package/web/dist/assets/github-D0rAX4-r.js +1 -0
- package/web/dist/assets/go-rLFTqkRN.js +1 -0
- package/web/dist/assets/highlighted-body-OFNGDK62-DGHHie_q.js +1 -0
- package/web/dist/assets/highlighter-BKVEsACy.js +3 -0
- package/web/dist/assets/html-BY76lXLH.js +1 -0
- package/web/dist/assets/index-CEkI7QxN.css +2 -0
- package/web/dist/assets/index-CO2lH2et.js +11 -0
- package/web/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/web/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/web/dist/assets/javascript-BgS3c2Ky.js +1 -0
- package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/web/dist/assets/json-qhed-kSA.js +1 -0
- package/web/dist/assets/jsonc-CYpm1nAK.js +1 -0
- package/web/dist/assets/jsx-Bz0zcwM4.js +1 -0
- package/web/dist/assets/lib-CNGa3Dig.js +1 -0
- package/web/dist/assets/loader-circle-BV3DO9mE.js +1 -0
- package/web/dist/assets/markdown-BYOwaDjH.js +1 -0
- package/web/dist/assets/markdown-D4-ADgH9.js +1 -0
- package/web/dist/assets/mermaid-GHXKKRXX-DM2VsGBG.js +1 -0
- package/web/dist/assets/open-mercato-toBr6SOa.svg +11 -0
- package/web/dist/assets/prompt-templates-C7ptKvDd.js +15 -0
- package/web/dist/assets/python-gzcpVVnB.js +1 -0
- package/web/dist/assets/react-dom-Ddtik4qM.js +1 -0
- package/web/dist/assets/refresh-cw-DCa-Rlwz.js +1 -0
- package/web/dist/assets/repo-git-DIhryGDx.js +1 -0
- package/web/dist/assets/run-diff-Dyow8B1q.js +3 -0
- package/web/dist/assets/run-header-jx9-18b8.js +1 -0
- package/web/dist/assets/rust-Cfkwpbl8.js +1 -0
- package/web/dist/assets/search-x-CoCyl7zT.js +1 -0
- package/web/dist/assets/shellscript-CLZ0U2zV.js +1 -0
- package/web/dist/assets/skill-detail-DB7x_FQp.js +1 -0
- package/web/dist/assets/skills-98vCroU8.js +1 -0
- package/web/dist/assets/skills-BKIoqD6K.js +1 -0
- package/web/dist/assets/sql-BsFa4tDR.js +1 -0
- package/web/dist/assets/square-terminal-B6PZX4Qp.js +1 -0
- package/web/dist/assets/tab-link-C5--YMQd.js +1 -0
- package/web/dist/assets/task-changes-ejxPGVJw.js +1 -0
- package/web/dist/assets/task-commits-C6GO5qT-.js +1 -0
- package/web/dist/assets/task-files-CatpCBXa.js +2 -0
- package/web/dist/assets/task-thread-BMbXDM3d.js +5 -0
- package/web/dist/assets/tasks-table-dgezlCcg.js +1 -0
- package/web/dist/assets/trash-2-CDtxmmc8.js +1 -0
- package/web/dist/assets/triangle-alert-6su4Js5O.js +1 -0
- package/web/dist/assets/tsx-udAQXfEw.js +1 -0
- package/web/dist/assets/typescript-bsJCZSQ-.js +1 -0
- package/web/dist/assets/upload-Bp7hQxll.js +1 -0
- package/web/dist/assets/use-desktop-CvTwojOJ.js +3 -0
- package/web/dist/assets/utils-rQGbOrwc.js +1 -0
- package/web/dist/assets/workflows-Dy2c6790.js +11 -0
- package/web/dist/assets/x-Br_jIDBw.js +1 -0
- package/web/dist/assets/yaml-rwi0_p6S.js +1 -0
- package/web/dist/index.html +70 -0
- package/web/open-mercato.svg +11 -0
|
@@ -0,0 +1,1390 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { Hono } from 'hono';
|
|
6
|
+
import { serve } from '@hono/node-server';
|
|
7
|
+
import { streamSSE } from 'hono/streaming';
|
|
8
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { detectEnvironment } from '../core/backend-detect.js';
|
|
11
|
+
import { currentUsage, onUsage } from '../core/process-usage.js';
|
|
12
|
+
import { WORKFLOWS_DIR, loadWorkflows } from '../workflows/load.js';
|
|
13
|
+
import { QUICK_TASK_WORKFLOW, normalizeWorkflowDoc, skillStackOf, skillsToSteps, stepsIssue, workflowFileSchema, workflowStepSchema, } from '../workflows/types.js';
|
|
14
|
+
import { planChain, slugify } from '../planner.js';
|
|
15
|
+
import { discoverSkills } from '../skills.js';
|
|
16
|
+
import { refreshTeamSkills } from '../skills-remote.js';
|
|
17
|
+
import { appendHandoffHeartbeat, handoffProgressExcerpt, readHandoff } from '../handoff.js';
|
|
18
|
+
import { markStarted, onTodosChanged, readTodos, removeTodo, startTodosWatch } from '../todos.js';
|
|
19
|
+
import { isV2WireEventType } from '../runs/ui-event-sink.js';
|
|
20
|
+
import { removeWorktree, worktreeDiff, worktreeDiffStat, worktreeSizeBytes } from '../git-worktree.js';
|
|
21
|
+
import { isReclaimable, reclaimWorktrees } from '../runs/retention.js';
|
|
22
|
+
import { getBranches, getCommit, getDiff, getLog, getRepoInfo, getStatus } from './git.js';
|
|
23
|
+
import { collectChanges, collectCommitChanges, collectRunCommits, commitAll, createOrSwitchBranch, imageMimeType, pushCurrentBranch, readWorktreePath, } from './git-changes.js';
|
|
24
|
+
import { loadConfig } from '../config.js';
|
|
25
|
+
import { reviewGateEnabled } from '../runs/review-gate.js';
|
|
26
|
+
import { readUiState, uiStatePath } from '../ui-state.js';
|
|
27
|
+
import { resolveCapabilities } from './capabilities.js';
|
|
28
|
+
import { resolveForge } from './forge/index.js';
|
|
29
|
+
import { fetchGithub } from './github.js';
|
|
30
|
+
import { ensureLaunchKey } from './launch-key.js';
|
|
31
|
+
import { openInTerminal } from './open-in-terminal.js';
|
|
32
|
+
import { agentCliRunner, detectOpenTargets, openInApp } from './open-in-app.js';
|
|
33
|
+
import { createDraftPr } from './pr.js';
|
|
34
|
+
import { ASSET_CACHE_CONTROL, BUILD_HINT_HTML, assetContentType, isSafeAssetFilename, resolveGetRequest } from './static-ui.js';
|
|
35
|
+
/** 409 body for the inbox mutators while the follow-up inbox is off (#471). */
|
|
36
|
+
const FOLLOWUPS_OFF = 'the follow-up inbox is disabled — set CEZ_FOLLOWUPS=1 to enable it';
|
|
37
|
+
/** streamSSE with the anti-buffering contract (#424): hono's own header is a
|
|
38
|
+
* bare `no-cache`, which lets an intermediary (reverse proxy, compression
|
|
39
|
+
* middleware, corporate MITM) transform-buffer the stream — the client then
|
|
40
|
+
* sees a silently frozen transcript while the server keeps writing. Headers
|
|
41
|
+
* are set on the returned Response because hono's helper overwrites
|
|
42
|
+
* `Cache-Control` set via `c.header()` before it. */
|
|
43
|
+
const streamSSENoBuffer = (c, cb, onError) => {
|
|
44
|
+
const res = streamSSE(c, cb, onError);
|
|
45
|
+
res.headers.set('Cache-Control', 'no-cache, no-transform');
|
|
46
|
+
res.headers.set('X-Accel-Buffering', 'no');
|
|
47
|
+
return res;
|
|
48
|
+
};
|
|
49
|
+
// A run starts from a named workflow OR an inline chain of steps (spec 008 —
|
|
50
|
+
// the approved plan is posted as-is, never written to a file).
|
|
51
|
+
const startRunSchema = z
|
|
52
|
+
.object({
|
|
53
|
+
workflow: z.string().min(1).optional(),
|
|
54
|
+
steps: z.array(workflowStepSchema).min(1).max(8).optional(),
|
|
55
|
+
task: z.string().min(1),
|
|
56
|
+
model: z.string().optional(),
|
|
57
|
+
// Agent backend for this task (falls back to config `defaultRunner`).
|
|
58
|
+
runner: z.enum(['claude', 'codex', 'opencode']).optional(),
|
|
59
|
+
// Parallel variants (spec 010): ×2/×3 runs the task as 2–3 competing
|
|
60
|
+
// agents in separate worktrees; the user compares diffs and picks one.
|
|
61
|
+
variants: z.number().int().min(1).max(3).optional(),
|
|
62
|
+
// Composer worktree opt-out (#worktree-toggle): false runs in the repo
|
|
63
|
+
// working tree (read-only skills). Ignored when variants > 1.
|
|
64
|
+
worktree: z.boolean().optional(),
|
|
65
|
+
// Autonomous mode (#autonomous): the run never parks at `waiting` — it
|
|
66
|
+
// auto-continues until the agent signals done. No "needs you" is raised.
|
|
67
|
+
autonomous: z.boolean().optional(),
|
|
68
|
+
// Generate follow-up inbox entries (spec 007, #444). Honoured only while
|
|
69
|
+
// the `followups` capability is on (#471) — off, the server pins it to
|
|
70
|
+
// false whatever the client asked for. Omitted still means "enabled" for
|
|
71
|
+
// old clients, but only within an already-enabled server. The handoff
|
|
72
|
+
// journal is unaffected either way.
|
|
73
|
+
generateFollowups: z.boolean().optional(),
|
|
74
|
+
// Per-run system-prompt override (R2 2.3) — programmatic callers only
|
|
75
|
+
// (bookmarklets, scripts); deliberately NOT a composer-UI control. Wins
|
|
76
|
+
// over the config.json default; whitespace-only degrades to absent.
|
|
77
|
+
systemPrompt: z
|
|
78
|
+
.string()
|
|
79
|
+
.trim()
|
|
80
|
+
.max(20_000, 'systemPrompt must be at most 20000 characters')
|
|
81
|
+
.optional()
|
|
82
|
+
.transform((s) => (s ? s : undefined)),
|
|
83
|
+
// Screenshots pasted into the new-task form — same shape and limits as a
|
|
84
|
+
// live-session message; delivered with the first agent step's opening.
|
|
85
|
+
images: z
|
|
86
|
+
.array(z.object({
|
|
87
|
+
mediaType: z.string().regex(/^image\//),
|
|
88
|
+
// ~5 MB per image once base64-decoded.
|
|
89
|
+
data: z.string().min(1).max(7_000_000),
|
|
90
|
+
}))
|
|
91
|
+
.max(4)
|
|
92
|
+
.optional(),
|
|
93
|
+
})
|
|
94
|
+
.refine((b) => Boolean(b.workflow) !== Boolean(b.steps), {
|
|
95
|
+
message: 'provide either "workflow" or "steps", not both',
|
|
96
|
+
});
|
|
97
|
+
const pickSchema = z.object({
|
|
98
|
+
runId: z.string().min(1),
|
|
99
|
+
});
|
|
100
|
+
const planSchema = z.object({
|
|
101
|
+
task: z.string().trim().min(1),
|
|
102
|
+
});
|
|
103
|
+
// A saved workflow carries full `steps` OR the builder's `skills` stack
|
|
104
|
+
// (spec 012). `overwrite: true` is the builder's Save on an existing file —
|
|
105
|
+
// the GUI asks first; a plain POST still refuses to clobber.
|
|
106
|
+
const saveWorkflowSchema = z
|
|
107
|
+
.object({
|
|
108
|
+
name: z.string().trim().min(1).max(80),
|
|
109
|
+
description: z.string().optional(),
|
|
110
|
+
steps: z.array(workflowStepSchema).min(1).max(8).optional(),
|
|
111
|
+
skills: z.array(z.string().trim().min(1)).min(1).max(8).optional(),
|
|
112
|
+
overwrite: z.boolean().optional(),
|
|
113
|
+
})
|
|
114
|
+
.refine((b) => Boolean(b.steps) !== Boolean(b.skills), {
|
|
115
|
+
message: 'provide either "steps" or "skills", not both',
|
|
116
|
+
});
|
|
117
|
+
const parseWorkflowSchema = z.object({
|
|
118
|
+
yaml: z.string().min(1).max(100_000),
|
|
119
|
+
});
|
|
120
|
+
// Small GUI preferences persisted in `.ai/cezar/ui-state.json` (files, not a
|
|
121
|
+
// DB): today just the last-used task source, so the form preselects what you
|
|
122
|
+
// actually run. Unknown keys pass through — future prefs won't need a schema
|
|
123
|
+
// dance.
|
|
124
|
+
const uiStateSchema = z
|
|
125
|
+
.object({
|
|
126
|
+
lastTask: z
|
|
127
|
+
.object({ source: z.enum(['workflow', 'skill']), ref: z.string().min(1).max(200) })
|
|
128
|
+
.optional(),
|
|
129
|
+
// Composer picker recency (newest first, capped) + the remembered worktree
|
|
130
|
+
// choice for single-skill runs. Additive prefs, like the rest of ui-state.
|
|
131
|
+
recentSources: z
|
|
132
|
+
.array(z.object({ source: z.enum(['workflow', 'skill']), ref: z.string().min(1).max(200) }))
|
|
133
|
+
.max(50)
|
|
134
|
+
.optional(),
|
|
135
|
+
lastWorktree: z.boolean().optional(),
|
|
136
|
+
lastAutonomous: z.boolean().optional(),
|
|
137
|
+
lastGenerateFollowups: z.boolean().optional(),
|
|
138
|
+
// Runs area presentation (#348): the sidebar-list + detail pane, or the
|
|
139
|
+
// full-width table ("task manager") view.
|
|
140
|
+
runsView: z.enum(['list', 'table']).optional(),
|
|
141
|
+
// The GitHub tab's last-selected sub-tab (#417): issues or PRs. ADDITIVE — an old
|
|
142
|
+
// ui-state.json without the key behaves as the default (issues).
|
|
143
|
+
githubView: z.enum(['issues', 'prs']).optional(),
|
|
144
|
+
// Settings → Appearance (redesign R6): accent + density. ADDITIVE — the theme itself
|
|
145
|
+
// stays in the browser (`cez-theme` localStorage, pre-paint). The cockpit always PUTs
|
|
146
|
+
// the whole object because the top-level merge below is shallow.
|
|
147
|
+
appearance: z
|
|
148
|
+
.object({
|
|
149
|
+
accent: z.enum(['lime', 'violet']).optional(),
|
|
150
|
+
density: z.enum(['comfortable', 'compact', 'ultra']).optional(),
|
|
151
|
+
})
|
|
152
|
+
.optional(),
|
|
153
|
+
// Follow-up prompt templates (#413): reusable snippets insertable into the GitHub hand-over
|
|
154
|
+
// and Inbox follow-up composers. Absent → the client's built-in defaults; present (even `[]`)
|
|
155
|
+
// is the user's own edited list, from Settings → Prompt templates. Additive, like the rest of
|
|
156
|
+
// ui-state — the cockpit is the only writer, so validation stays generous but bounded.
|
|
157
|
+
promptTemplates: z
|
|
158
|
+
.array(z.object({
|
|
159
|
+
id: z.string().min(1).max(64),
|
|
160
|
+
label: z.string().trim().min(1).max(80),
|
|
161
|
+
text: z.string().trim().min(1).max(2000),
|
|
162
|
+
// Skill names this template auto-applies for. Optional and additive: templates
|
|
163
|
+
// written before this key existed keep validating, and stay manual-only.
|
|
164
|
+
skills: z.array(z.string().trim().min(1).max(200)).max(50).optional(),
|
|
165
|
+
}))
|
|
166
|
+
.max(50)
|
|
167
|
+
.optional(),
|
|
168
|
+
// Skills promo banner (#391): set once the cockpit banner is dismissed, never unset.
|
|
169
|
+
// Server-persisted (not a cookie) so the "shown once" promise holds across browsers.
|
|
170
|
+
dismissedSkillsBanner: z.boolean().optional(),
|
|
171
|
+
})
|
|
172
|
+
.passthrough();
|
|
173
|
+
// Editable titles (#389). `title` is the only editable field for now.
|
|
174
|
+
const patchRunSchema = z.object({
|
|
175
|
+
title: z.string().trim().min(1).max(300).optional(),
|
|
176
|
+
});
|
|
177
|
+
// Inbox "▶ Run" body (#413): optional extra instructions — e.g. a prompt template inserted in
|
|
178
|
+
// the Inbox composer — appended to the entry's suggested/summary task text. Old clients that
|
|
179
|
+
// POST with no body at all keep the pre-#413 behavior exactly (see the route: a body-less
|
|
180
|
+
// request parses to `undefined`, and an absent `prompt` never touches `task`).
|
|
181
|
+
const startTodoBodySchema = z
|
|
182
|
+
.object({
|
|
183
|
+
prompt: z
|
|
184
|
+
.string()
|
|
185
|
+
.trim()
|
|
186
|
+
.max(20_000, 'prompt must be at most 20000 characters')
|
|
187
|
+
.optional()
|
|
188
|
+
.transform((s) => (s ? s : undefined)),
|
|
189
|
+
})
|
|
190
|
+
.optional();
|
|
191
|
+
// Session commit (redesign R5 — §"Git/session API additions").
|
|
192
|
+
const gitCommitSchema = z.object({
|
|
193
|
+
message: z.string().trim().min(1, 'commit message must not be empty').max(5_000),
|
|
194
|
+
});
|
|
195
|
+
const messageSchema = z
|
|
196
|
+
.object({
|
|
197
|
+
text: z.string().max(100_000).default(''),
|
|
198
|
+
images: z
|
|
199
|
+
.array(z.object({
|
|
200
|
+
mediaType: z.string().regex(/^image\//),
|
|
201
|
+
// ~5 MB per image once base64-decoded.
|
|
202
|
+
data: z.string().min(1).max(7_000_000),
|
|
203
|
+
}))
|
|
204
|
+
.max(4)
|
|
205
|
+
.default([]),
|
|
206
|
+
})
|
|
207
|
+
.refine((m) => m.text.trim().length > 0 || m.images.length > 0, {
|
|
208
|
+
message: 'message needs text or at least one image',
|
|
209
|
+
});
|
|
210
|
+
export function createApp(deps) {
|
|
211
|
+
const { repoRoot, store, manager, version, update, bindHost } = deps;
|
|
212
|
+
const dataDir = join(repoRoot, '.ai/cezar');
|
|
213
|
+
// Hosted-mode gate (spec §"Deployment modes") — read per request so
|
|
214
|
+
// CEZ_REMOTE flips take effect live (and tests can toggle it).
|
|
215
|
+
const capabilities = () => resolveCapabilities(process.env, bindHost);
|
|
216
|
+
// Inbox live updates (spec 007). Opt-in (#471): no capability, no watcher —
|
|
217
|
+
// nothing can write todos.json anyway, so a watch would only burn an fd.
|
|
218
|
+
if (capabilities().followups)
|
|
219
|
+
startTodosWatch(dataDir);
|
|
220
|
+
const launchKey = ensureLaunchKey(dataDir); // bookmarklet auto-start secret (spec 011)
|
|
221
|
+
const app = new Hono();
|
|
222
|
+
// ---- static GUI ----------------------------------------------------------
|
|
223
|
+
const webDir = resolveWebDir();
|
|
224
|
+
const distDir = join(webDir, 'dist');
|
|
225
|
+
const HTML_TYPE = 'text/html; charset=utf-8';
|
|
226
|
+
const staticFile = (name, type) => () => {
|
|
227
|
+
// Read per request — the files are tiny and this keeps dev iteration live.
|
|
228
|
+
const body = readFileSync(join(webDir, name));
|
|
229
|
+
return new Response(body, { headers: { 'content-type': type } });
|
|
230
|
+
};
|
|
231
|
+
let hintLogged = false;
|
|
232
|
+
const serveShell = (c) => {
|
|
233
|
+
const distIndex = join(distDir, 'index.html');
|
|
234
|
+
// existsSync per request, like the reads below: `npm run build:web` in a
|
|
235
|
+
// running cockpit takes effect on the next reload, no restart.
|
|
236
|
+
const target = resolveGetRequest({ path: c.req.path, distExists: existsSync(distIndex) });
|
|
237
|
+
if (target === 'passthrough')
|
|
238
|
+
return undefined;
|
|
239
|
+
if (target === 'build-hint') {
|
|
240
|
+
// Dev-only state (the tarball ships web/dist): serve the built-in hint
|
|
241
|
+
// page instead of the app — the legacy fallback UI was deleted in R7.
|
|
242
|
+
if (!hintLogged) {
|
|
243
|
+
hintLogged = true;
|
|
244
|
+
console.log('cezar: web/dist is missing — run `npm run build:web` to build the cockpit');
|
|
245
|
+
}
|
|
246
|
+
return new Response(BUILD_HINT_HTML, { headers: { 'content-type': HTML_TYPE } });
|
|
247
|
+
}
|
|
248
|
+
return new Response(readFileSync(distIndex), { headers: { 'content-type': HTML_TYPE } });
|
|
249
|
+
};
|
|
250
|
+
// Hashed bundles/fonts of the built app. Vite fingerprints every name, so
|
|
251
|
+
// the bytes behind a URL never change — cache them hard. Only plain
|
|
252
|
+
// filenames are served: `basename('..')` is `'..'` (it resolves to the
|
|
253
|
+
// assets dir itself and readFileSync would throw EISDIR), so dot-segments
|
|
254
|
+
// and separator-bearing params get a 404, not a 500.
|
|
255
|
+
app.get('/assets/:file', (c) => {
|
|
256
|
+
const file = c.req.param('file');
|
|
257
|
+
if (!isSafeAssetFilename(file))
|
|
258
|
+
return c.json({ error: 'not found' }, 404);
|
|
259
|
+
const path = join(distDir, 'assets', file);
|
|
260
|
+
if (!existsSync(path) || !statSync(path).isFile())
|
|
261
|
+
return c.json({ error: 'not found' }, 404);
|
|
262
|
+
return new Response(readFileSync(path), {
|
|
263
|
+
headers: { 'content-type': assetContentType(file), 'cache-control': ASSET_CACHE_CONTROL },
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
// The favicon web/app/index.html points at (`../open-mercato.svg`).
|
|
267
|
+
app.get('/open-mercato.svg', staticFile('open-mercato.svg', 'image/svg+xml'));
|
|
268
|
+
// ---- meta ----------------------------------------------------------------
|
|
269
|
+
// CORS — deliberately for /api/health ONLY (spec 011): the bookmarklets
|
|
270
|
+
// fetch it cross-origin from github.com to discover which local ports run a
|
|
271
|
+
// cockpit and which repo each serves. Health exposes no secrets beyond the
|
|
272
|
+
// repo path/remote; every other endpoint stays same-origin.
|
|
273
|
+
app.use('/api/health', async (c, next) => {
|
|
274
|
+
c.header('access-control-allow-origin', '*');
|
|
275
|
+
if (c.req.method === 'OPTIONS') {
|
|
276
|
+
// Preflight (e.g. Chrome Private Network Access) — allow the plain GET.
|
|
277
|
+
c.header('access-control-allow-methods', 'GET');
|
|
278
|
+
c.header('access-control-allow-private-network', 'true');
|
|
279
|
+
return c.body(null, 204);
|
|
280
|
+
}
|
|
281
|
+
await next();
|
|
282
|
+
});
|
|
283
|
+
app.get('/api/health', async (c) => {
|
|
284
|
+
const [checks, repo, config] = await Promise.all([
|
|
285
|
+
detectEnvironment(),
|
|
286
|
+
getRepoInfo(repoRoot),
|
|
287
|
+
loadConfig(repoRoot),
|
|
288
|
+
]);
|
|
289
|
+
// Additive fields only below — the pre-forge shape is the most
|
|
290
|
+
// externally-depended-on JSON in the app (BACKWARD_COMPATIBILITY.md §2).
|
|
291
|
+
const forge = resolveForge(repo);
|
|
292
|
+
return c.json({
|
|
293
|
+
version,
|
|
294
|
+
latestVersion: update?.latest,
|
|
295
|
+
repoRoot,
|
|
296
|
+
repo,
|
|
297
|
+
checks,
|
|
298
|
+
defaultRunner: config.defaultRunner,
|
|
299
|
+
// Non-blocking: cached availability or null-until-warm — health must never pay a `gh`
|
|
300
|
+
// shell-out (the bookmarklet aborts its port probe at 800 ms). See detectGithubCached.
|
|
301
|
+
forge: forge ? { kind: forge.kind, ...(forge.detectCached() ?? {}) } : null,
|
|
302
|
+
capabilities: capabilities(),
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
// The bookmarklet generator bakes this key into the `javascript:` URLs —
|
|
306
|
+
// `/new?auto=1` is honored only with it (spec 011). Same-origin only.
|
|
307
|
+
app.get('/api/launch-key', (c) => c.json({ key: launchKey }));
|
|
308
|
+
app.get('/api/skills', async (c) => c.json(await discoverSkills(repoRoot)));
|
|
309
|
+
// ---- GUI prefs (ui-state.json) --------------------------------------------
|
|
310
|
+
// The read path is shared with the CLI (`src/ui-state.ts`) so `cezar serve` can honour a
|
|
311
|
+
// preference set here — #391's dismissed skills banner — from one notion of the file.
|
|
312
|
+
app.get('/api/ui-state', async (c) => c.json(await readUiState(repoRoot)));
|
|
313
|
+
app.put('/api/ui-state', async (c) => {
|
|
314
|
+
const parsed = uiStateSchema.safeParse(await c.req.json().catch(() => null));
|
|
315
|
+
if (!parsed.success) {
|
|
316
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
317
|
+
}
|
|
318
|
+
const merged = { ...(await readUiState(repoRoot)), ...parsed.data };
|
|
319
|
+
try {
|
|
320
|
+
await mkdir(dataDir, { recursive: true });
|
|
321
|
+
await writeFile(uiStatePath(repoRoot), `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
325
|
+
}
|
|
326
|
+
return c.json(merged);
|
|
327
|
+
});
|
|
328
|
+
// Refresh team skills (spec 005): clone/fetch the configured skills repos,
|
|
329
|
+
// then return the merged catalog. Degrades quietly — offline just means the
|
|
330
|
+
// team entries stay as they were (or absent).
|
|
331
|
+
app.post('/api/skills/refresh', async (c) => {
|
|
332
|
+
await refreshTeamSkills(repoRoot);
|
|
333
|
+
return c.json(await discoverSkills(repoRoot));
|
|
334
|
+
});
|
|
335
|
+
app.get('/api/workflows', async (c) => c.json(await loadWorkflows(repoRoot)));
|
|
336
|
+
// Save an approved plan as a reusable chain (spec 008): YAML in
|
|
337
|
+
// `.ai/cezar/workflows/<slug>.yaml` — from then on it's in the dropdown
|
|
338
|
+
// like any other workflow.
|
|
339
|
+
app.post('/api/workflows', async (c) => {
|
|
340
|
+
const parsed = saveWorkflowSchema.safeParse(await c.req.json().catch(() => null));
|
|
341
|
+
if (!parsed.success) {
|
|
342
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
343
|
+
}
|
|
344
|
+
const steps = parsed.data.steps ?? skillsToSteps(parsed.data.skills ?? []);
|
|
345
|
+
const issue = stepsIssue(steps);
|
|
346
|
+
if (issue)
|
|
347
|
+
return c.json({ error: issue }, 400);
|
|
348
|
+
const slug = slugify(parsed.data.name) || 'chain';
|
|
349
|
+
const dir = join(repoRoot, WORKFLOWS_DIR);
|
|
350
|
+
const path = join(dir, `${slug}.yaml`);
|
|
351
|
+
// Pure skill stacks are written in the portable compact form (spec 012) —
|
|
352
|
+
// `name` + `skills:` — so the file imports cleanly in any repo.
|
|
353
|
+
const stack = skillStackOf(steps);
|
|
354
|
+
const doc = {
|
|
355
|
+
name: parsed.data.name,
|
|
356
|
+
...(parsed.data.description ? { description: parsed.data.description } : {}),
|
|
357
|
+
...(stack ? { skills: stack } : { steps }),
|
|
358
|
+
};
|
|
359
|
+
try {
|
|
360
|
+
await mkdir(dir, { recursive: true });
|
|
361
|
+
// `wx` = fail if the file exists — no silent overwrite of a chain.
|
|
362
|
+
await writeFile(path, stringifyYaml(doc), {
|
|
363
|
+
encoding: 'utf8',
|
|
364
|
+
flag: parsed.data.overwrite ? 'w' : 'wx',
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
if (err instanceof Error && 'code' in err && err.code === 'EEXIST') {
|
|
369
|
+
return c.json({ error: `workflow file already exists: ${path}`, exists: true }, 409);
|
|
370
|
+
}
|
|
371
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
372
|
+
return c.json({ error: message }, 500);
|
|
373
|
+
}
|
|
374
|
+
return c.json({ path, name: parsed.data.name }, 201);
|
|
375
|
+
});
|
|
376
|
+
// Delete a saved workflow (spec 012 follow-up): file workflows only —
|
|
377
|
+
// built-ins have no file and always come back.
|
|
378
|
+
app.delete('/api/workflows/:name', async (c) => {
|
|
379
|
+
const name = c.req.param('name');
|
|
380
|
+
const { workflows } = await loadWorkflows(repoRoot);
|
|
381
|
+
const wf = workflows.find((w) => w.name === name);
|
|
382
|
+
if (!wf)
|
|
383
|
+
return c.json({ error: `unknown workflow: ${name}` }, 404);
|
|
384
|
+
if (wf.source !== 'file' || !wf.path) {
|
|
385
|
+
return c.json({ error: 'built-in workflows cannot be deleted' }, 400);
|
|
386
|
+
}
|
|
387
|
+
const dir = resolve(repoRoot, WORKFLOWS_DIR);
|
|
388
|
+
const target = resolve(wf.path);
|
|
389
|
+
if (!target.startsWith(dir + sep)) {
|
|
390
|
+
return c.json({ error: 'refusing to delete a file outside the workflows dir' }, 400);
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
await unlink(target);
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
397
|
+
}
|
|
398
|
+
return c.json({ ok: true, path: target });
|
|
399
|
+
});
|
|
400
|
+
// Import support for the builder (spec 012): parse + validate a pasted
|
|
401
|
+
// workflow YAML (either form) and hand back the normalized definition. The
|
|
402
|
+
// server owns YAML parsing — the GUI stays dependency-free.
|
|
403
|
+
app.post('/api/workflows/parse', async (c) => {
|
|
404
|
+
const parsed = parseWorkflowSchema.safeParse(await c.req.json().catch(() => null));
|
|
405
|
+
if (!parsed.success) {
|
|
406
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
407
|
+
}
|
|
408
|
+
let raw;
|
|
409
|
+
try {
|
|
410
|
+
raw = parseYaml(parsed.data.yaml);
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
414
|
+
return c.json({ error: `not valid YAML: ${message}` }, 400);
|
|
415
|
+
}
|
|
416
|
+
const doc = workflowFileSchema.safeParse(raw);
|
|
417
|
+
if (!doc.success) {
|
|
418
|
+
return c.json({ error: doc.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
419
|
+
}
|
|
420
|
+
const normalized = normalizeWorkflowDoc(doc.data);
|
|
421
|
+
const issue = stepsIssue(normalized.steps);
|
|
422
|
+
if (issue)
|
|
423
|
+
return c.json({ error: issue }, 400);
|
|
424
|
+
return c.json(normalized);
|
|
425
|
+
});
|
|
426
|
+
// Chain-from-prompt (spec 008): one cheap claude call proposes a chain of
|
|
427
|
+
// steps for the task. Never blocks — degraded answers come back as a
|
|
428
|
+
// one-step quick-task plan with `fallback: true`.
|
|
429
|
+
app.post('/api/plan', async (c) => {
|
|
430
|
+
const parsed = planSchema.safeParse(await c.req.json().catch(() => null));
|
|
431
|
+
if (!parsed.success) {
|
|
432
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
433
|
+
}
|
|
434
|
+
return c.json(await planChain(repoRoot, parsed.data.task));
|
|
435
|
+
});
|
|
436
|
+
// ---- runs ----------------------------------------------------------------
|
|
437
|
+
// Additive `usage` field (#348): the latest CPU/RSS/proc-count sample of the
|
|
438
|
+
// run's live process tree — absent for finished runs and when `ps` yields
|
|
439
|
+
// nothing. The stored record itself is never touched.
|
|
440
|
+
const withUsage = (run) => {
|
|
441
|
+
const usage = currentUsage(run.id);
|
|
442
|
+
return usage ? { ...run, usage } : run;
|
|
443
|
+
};
|
|
444
|
+
app.get('/api/runs', (c) => c.json(store.listRuns().map(withUsage)));
|
|
445
|
+
// Registered before the `/:id/...` routes so "archive-finished" never
|
|
446
|
+
// matches as a run id.
|
|
447
|
+
app.post('/api/runs/archive-finished', (c) => c.json({ archived: store.archiveFinished() }));
|
|
448
|
+
app.post('/api/runs/:id/archive', async (c) => {
|
|
449
|
+
const id = c.req.param('id');
|
|
450
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
451
|
+
const run = store.setArchived(id, body.archived !== false);
|
|
452
|
+
return run ? c.json(run) : c.json({ error: 'not found' }, 404);
|
|
453
|
+
});
|
|
454
|
+
app.post('/api/runs', async (c) => {
|
|
455
|
+
const parsed = startRunSchema.safeParse(await c.req.json().catch(() => null));
|
|
456
|
+
if (!parsed.success) {
|
|
457
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
458
|
+
}
|
|
459
|
+
let workflow;
|
|
460
|
+
if (parsed.data.steps) {
|
|
461
|
+
// Inline chain (spec 008): an approved plan runs as an ad-hoc workflow.
|
|
462
|
+
const issue = stepsIssue(parsed.data.steps);
|
|
463
|
+
if (issue)
|
|
464
|
+
return c.json({ error: issue }, 400);
|
|
465
|
+
workflow = { name: '(planned)', source: 'built-in', steps: parsed.data.steps };
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
const { workflows } = await loadWorkflows(repoRoot);
|
|
469
|
+
workflow = workflows.find((w) => w.name === parsed.data.workflow);
|
|
470
|
+
if (!workflow)
|
|
471
|
+
return c.json({ error: `unknown workflow: ${parsed.data.workflow}` }, 404);
|
|
472
|
+
}
|
|
473
|
+
const images = parsed.data.images?.map((img) => ({
|
|
474
|
+
type: 'image',
|
|
475
|
+
source: { type: 'base64', media_type: img.mediaType, data: img.data },
|
|
476
|
+
}));
|
|
477
|
+
const input = {
|
|
478
|
+
task: parsed.data.task,
|
|
479
|
+
model: parsed.data.model,
|
|
480
|
+
runner: parsed.data.runner,
|
|
481
|
+
images,
|
|
482
|
+
systemPrompt: parsed.data.systemPrompt,
|
|
483
|
+
worktree: parsed.data.worktree,
|
|
484
|
+
autonomous: parsed.data.autonomous,
|
|
485
|
+
// Opt-in inbox (#471): the capability is the ceiling, so a client asking
|
|
486
|
+
// for follow-ups on a server that has them off gets a plain `false`
|
|
487
|
+
// rather than an error — the run is still perfectly valid without them.
|
|
488
|
+
// One decision here feeds the run record, the system prompt and
|
|
489
|
+
// CEZ_TODOS_FILE alike (RunManager.agentEnv).
|
|
490
|
+
generateFollowups: capabilities().followups ? parsed.data.generateFollowups : false,
|
|
491
|
+
};
|
|
492
|
+
const variants = parsed.data.variants ?? 1;
|
|
493
|
+
if (variants > 1) {
|
|
494
|
+
// Variants live in worktrees — without git there's nothing to isolate
|
|
495
|
+
// them with, so this degrades to a clear 400 instead of stepping on
|
|
496
|
+
// one shared working tree.
|
|
497
|
+
const repo = await getRepoInfo(repoRoot);
|
|
498
|
+
if (!repo) {
|
|
499
|
+
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);
|
|
500
|
+
}
|
|
501
|
+
return c.json({ runs: manager.startVariants(workflow, input, variants) }, 201);
|
|
502
|
+
}
|
|
503
|
+
const run = manager.startRun(workflow, input);
|
|
504
|
+
return c.json(run, 201);
|
|
505
|
+
});
|
|
506
|
+
// ---- parallel variants (spec 010) -----------------------------------------
|
|
507
|
+
const groupRuns = (groupId) => store
|
|
508
|
+
.listRuns()
|
|
509
|
+
.filter((r) => r.groupId === groupId)
|
|
510
|
+
.sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''));
|
|
511
|
+
// Comparison data: per variant the status, cost, `git diff --stat` and the
|
|
512
|
+
// first Progress-log lines from the handoff. The full diff is fetched per
|
|
513
|
+
// variant via the existing GET /api/runs/:id/diff.
|
|
514
|
+
app.get('/api/groups/:groupId', async (c) => {
|
|
515
|
+
const runs = groupRuns(c.req.param('groupId'));
|
|
516
|
+
if (runs.length === 0)
|
|
517
|
+
return c.json({ error: 'not found' }, 404);
|
|
518
|
+
const detailed = await Promise.all(runs.map(async (r) => ({
|
|
519
|
+
id: r.id,
|
|
520
|
+
variant: r.variant ?? '?',
|
|
521
|
+
title: r.title,
|
|
522
|
+
status: r.status,
|
|
523
|
+
archived: r.archived,
|
|
524
|
+
tokensUsed: r.tokensUsed,
|
|
525
|
+
costUsd: r.costUsd,
|
|
526
|
+
diffStat: r.worktreePath && existsSync(r.worktreePath)
|
|
527
|
+
? await worktreeDiffStat(r.worktreePath, r.baseBranch ?? 'HEAD')
|
|
528
|
+
: '',
|
|
529
|
+
handoffExcerpt: handoffProgressExcerpt(readHandoff(dataDir, r.id)),
|
|
530
|
+
})));
|
|
531
|
+
return c.json({ groupId: c.req.param('groupId'), runs: detailed });
|
|
532
|
+
});
|
|
533
|
+
// "Pick this one": the winner rests at `review` (spec 009 takes it from
|
|
534
|
+
// there — send back / draft PR / finish); the losers are cancelled if
|
|
535
|
+
// alive, archived, and their worktrees + branches removed.
|
|
536
|
+
app.post('/api/groups/:groupId/pick', async (c) => {
|
|
537
|
+
const runs = groupRuns(c.req.param('groupId'));
|
|
538
|
+
if (runs.length === 0)
|
|
539
|
+
return c.json({ error: 'not found' }, 404);
|
|
540
|
+
const parsed = pickSchema.safeParse(await c.req.json().catch(() => null));
|
|
541
|
+
if (!parsed.success) {
|
|
542
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
543
|
+
}
|
|
544
|
+
const winner = runs.find((r) => r.id === parsed.data.runId);
|
|
545
|
+
if (!winner)
|
|
546
|
+
return c.json({ error: 'runId is not part of this group' }, 404);
|
|
547
|
+
if (manager.isActive(winner.id)) {
|
|
548
|
+
return c.json({ error: 'this variant is still active — wait for it to finish first' }, 409);
|
|
549
|
+
}
|
|
550
|
+
// Winner: a non-review terminal state with a non-empty diff flips to
|
|
551
|
+
// `review` (the settleSuccess rule) — but only when the review gate applies
|
|
552
|
+
// (#489): it is enabled (`reviewGateEnabled`, default off) AND the winner is
|
|
553
|
+
// not autonomous. An autonomous / gate-off winner keeps its `done` state with
|
|
554
|
+
// the diff left in the worktree; an empty diff (or no worktree) stays too.
|
|
555
|
+
if (winner.status !== 'review' &&
|
|
556
|
+
winner.worktreePath &&
|
|
557
|
+
existsSync(winner.worktreePath) &&
|
|
558
|
+
winner.autonomous !== true &&
|
|
559
|
+
reviewGateEnabled(await loadConfig(repoRoot))) {
|
|
560
|
+
const diff = await worktreeDiff(winner.worktreePath, winner.baseBranch ?? 'HEAD');
|
|
561
|
+
if (diff.trim().length > 0 && !diff.startsWith('(diff failed')) {
|
|
562
|
+
store.updateRun(winner.id, { status: 'review' });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const losers = runs.filter((r) => r.id !== winner.id);
|
|
566
|
+
store.appendEvent(winner.id, {
|
|
567
|
+
type: 'lifecycle',
|
|
568
|
+
message: `picked from ${runs.length} variants — ${losers.length} other variant(s) archived`,
|
|
569
|
+
});
|
|
570
|
+
appendHandoffHeartbeat(dataDir, winner.id, `picked from ${runs.length} variants`);
|
|
571
|
+
for (const loser of losers) {
|
|
572
|
+
if (manager.isActive(loser.id))
|
|
573
|
+
manager.cancel(loser.id);
|
|
574
|
+
if (loser.worktreePath)
|
|
575
|
+
await removeWorktree(repoRoot, loser.worktreePath, loser.branch);
|
|
576
|
+
store.updateRun(loser.id, { worktreePath: undefined, branch: undefined });
|
|
577
|
+
store.setArchived(loser.id, true);
|
|
578
|
+
store.appendEvent(loser.id, {
|
|
579
|
+
type: 'lifecycle',
|
|
580
|
+
message: `variant ${winner.variant ?? '?'} was picked — this variant is archived, its worktree removed`,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
return c.json({ winner: store.getRun(winner.id) });
|
|
584
|
+
});
|
|
585
|
+
app.get('/api/runs/:id', (c) => {
|
|
586
|
+
const run = store.getRun(c.req.param('id'));
|
|
587
|
+
return run ? c.json(withUsage(run)) : c.json({ error: 'not found' }, 404);
|
|
588
|
+
});
|
|
589
|
+
// Editable titles (#389). The UI displays `titleSummary ?? title`, so a
|
|
590
|
+
// user edit sets BOTH: `title` (the record's own name — the raw task stops
|
|
591
|
+
// being it the moment the user renames the run) and `titleSummary` (what
|
|
592
|
+
// actually displays). The auto-summarizer only ever fills an *unset*
|
|
593
|
+
// titleSummary (RunManager.recordTurnEnd), so an edit wins over any past or
|
|
594
|
+
// future auto-summary. Answers the updated record.
|
|
595
|
+
app.patch('/api/runs/:id', async (c) => {
|
|
596
|
+
const id = c.req.param('id');
|
|
597
|
+
if (!store.getRun(id))
|
|
598
|
+
return c.json({ error: 'not found' }, 404);
|
|
599
|
+
const parsed = patchRunSchema.safeParse(await c.req.json().catch(() => null));
|
|
600
|
+
if (!parsed.success) {
|
|
601
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
602
|
+
}
|
|
603
|
+
if (parsed.data.title !== undefined) {
|
|
604
|
+
// titleOrigin 'user' permanently stops the namer's live updates for this run
|
|
605
|
+
// (spec 2026-07-17-task-auto-naming).
|
|
606
|
+
store.updateRun(id, { title: parsed.data.title, titleSummary: parsed.data.title, titleOrigin: 'user' });
|
|
607
|
+
}
|
|
608
|
+
return c.json(store.getRun(id));
|
|
609
|
+
});
|
|
610
|
+
app.post('/api/runs/:id/cancel', (c) => {
|
|
611
|
+
const id = c.req.param('id');
|
|
612
|
+
if (!store.getRun(id))
|
|
613
|
+
return c.json({ error: 'not found' }, 404);
|
|
614
|
+
const cancelled = manager.cancel(id);
|
|
615
|
+
return c.json({ cancelled });
|
|
616
|
+
});
|
|
617
|
+
// Live-session participation (spec 002): deliver a user message (text +
|
|
618
|
+
// pasted screenshots) into the run's open claude session.
|
|
619
|
+
app.post('/api/runs/:id/messages', async (c) => {
|
|
620
|
+
const id = c.req.param('id');
|
|
621
|
+
if (!store.getRun(id))
|
|
622
|
+
return c.json({ error: 'not found' }, 404);
|
|
623
|
+
const parsed = messageSchema.safeParse(await c.req.json().catch(() => null));
|
|
624
|
+
if (!parsed.success) {
|
|
625
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
626
|
+
}
|
|
627
|
+
const content = [
|
|
628
|
+
...parsed.data.images.map((img) => ({
|
|
629
|
+
type: 'image',
|
|
630
|
+
source: { type: 'base64', media_type: img.mediaType, data: img.data },
|
|
631
|
+
})),
|
|
632
|
+
...(parsed.data.text.trim()
|
|
633
|
+
? [{ type: 'text', text: parsed.data.text }]
|
|
634
|
+
: []),
|
|
635
|
+
];
|
|
636
|
+
const delivered = manager.sendMessage(id, content);
|
|
637
|
+
if (!delivered)
|
|
638
|
+
return c.json({ error: 'session closed' }, 409);
|
|
639
|
+
return c.json({ delivered: true });
|
|
640
|
+
});
|
|
641
|
+
// "Finish": gracefully close a waiting session — the run completes as done.
|
|
642
|
+
app.post('/api/runs/:id/finish', (c) => {
|
|
643
|
+
const id = c.req.param('id');
|
|
644
|
+
if (!store.getRun(id))
|
|
645
|
+
return c.json({ error: 'not found' }, 404);
|
|
646
|
+
const finished = manager.finish(id);
|
|
647
|
+
if (!finished)
|
|
648
|
+
return c.json({ error: 'no open session' }, 409);
|
|
649
|
+
return c.json({ finished: true });
|
|
650
|
+
});
|
|
651
|
+
// "Continue" (spec 003): reopen a finished run's session in-process.
|
|
652
|
+
app.post('/api/runs/:id/continue', async (c) => {
|
|
653
|
+
const id = c.req.param('id');
|
|
654
|
+
if (!store.getRun(id))
|
|
655
|
+
return c.json({ error: 'not found' }, 404);
|
|
656
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
657
|
+
const result = manager.continueRun(id, typeof body.text === 'string' ? body.text : undefined);
|
|
658
|
+
if (!result.ok)
|
|
659
|
+
return c.json({ error: result.error }, 409);
|
|
660
|
+
return c.json({ continued: true });
|
|
661
|
+
});
|
|
662
|
+
// "Open in terminal" (spec 003): hand the session off to a real terminal —
|
|
663
|
+
// in the task's worktree when it still exists (spec 006).
|
|
664
|
+
app.post('/api/runs/:id/open-in-cli', async (c) => {
|
|
665
|
+
const id = c.req.param('id');
|
|
666
|
+
const run = store.getRun(id);
|
|
667
|
+
if (!run)
|
|
668
|
+
return c.json({ error: 'not found' }, 404);
|
|
669
|
+
// Hosted mode: there is no "my machine" to open a terminal on. The UI
|
|
670
|
+
// hides the button when localHandoff is false — this is defense in depth.
|
|
671
|
+
if (!capabilities().localHandoff) {
|
|
672
|
+
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);
|
|
673
|
+
}
|
|
674
|
+
const sessionId = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
|
|
675
|
+
if (!sessionId)
|
|
676
|
+
return c.json({ error: 'no agent session to resume' }, 409);
|
|
677
|
+
const cwd = run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : repoRoot;
|
|
678
|
+
const command = resumeCommand(run.runner, sessionId);
|
|
679
|
+
const opened = await openInTerminal(cwd, command);
|
|
680
|
+
if (!opened) {
|
|
681
|
+
return c.json({ error: 'no terminal emulator found', command: `cd '${cwd}' && ${command}` }, 409);
|
|
682
|
+
}
|
|
683
|
+
return c.json({ opened: true, command });
|
|
684
|
+
});
|
|
685
|
+
// "Open in…" session takeover (#open-in): the editors/file-manager/terminal
|
|
686
|
+
// detected on THIS machine. Empty in hosted mode (no local desktop to open).
|
|
687
|
+
app.get('/api/open-targets', (c) => c.json({ targets: capabilities().localHandoff ? detectOpenTargets() : [] }));
|
|
688
|
+
// Open a run's worktree (or the repo root) in the chosen local app.
|
|
689
|
+
app.post('/api/runs/:id/open-in', async (c) => {
|
|
690
|
+
const id = c.req.param('id');
|
|
691
|
+
const run = store.getRun(id);
|
|
692
|
+
if (!run)
|
|
693
|
+
return c.json({ error: 'not found' }, 404);
|
|
694
|
+
if (!capabilities().localHandoff) {
|
|
695
|
+
return c.json({ error: 'local handoff is disabled — this cockpit runs in hosted mode (CEZ_REMOTE)' }, 409);
|
|
696
|
+
}
|
|
697
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
698
|
+
const target = typeof body.target === 'string' ? body.target : '';
|
|
699
|
+
if (!target)
|
|
700
|
+
return c.json({ error: 'target required' }, 400);
|
|
701
|
+
const dir = run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : repoRoot;
|
|
702
|
+
// Coding-agent CLI handoff (#cli-handoff): open a terminal in the worktree that resumes THIS
|
|
703
|
+
// run's session when the chosen CLI is the run's own runner (and a session exists), or starts
|
|
704
|
+
// a fresh CLI there otherwise. Same terminal launcher the Terminal button uses.
|
|
705
|
+
const cliRunner = agentCliRunner(target);
|
|
706
|
+
if (cliRunner) {
|
|
707
|
+
const sessionId = [...run.steps].reverse().find((s) => s.sessionId)?.sessionId;
|
|
708
|
+
const command = sessionId && cliRunner === run.runner ? resumeCommand(cliRunner, sessionId) : cliRunner;
|
|
709
|
+
const opened = await openInTerminal(dir, command);
|
|
710
|
+
if (!opened) {
|
|
711
|
+
return c.json({ error: 'no terminal emulator found', command: `cd '${dir}' && ${command}` }, 409);
|
|
712
|
+
}
|
|
713
|
+
return c.json({ opened: true, path: dir, command });
|
|
714
|
+
}
|
|
715
|
+
const opened = await openInApp(target, dir);
|
|
716
|
+
if (!opened)
|
|
717
|
+
return c.json({ error: `could not open ${target}`, path: dir }, 409);
|
|
718
|
+
return c.json({ opened: true, path: dir });
|
|
719
|
+
});
|
|
720
|
+
// Handoff journal (spec 007): the per-task handoff.md as markdown. 404 only
|
|
721
|
+
// when the task is unknown; a task without a (yet) seeded file returns ''.
|
|
722
|
+
app.get('/api/runs/:id/handoff', (c) => {
|
|
723
|
+
const run = store.getRun(c.req.param('id'));
|
|
724
|
+
if (!run)
|
|
725
|
+
return c.json({ error: 'not found' }, 404);
|
|
726
|
+
return c.text(readHandoff(dataDir, run.id), 200, {
|
|
727
|
+
'content-type': 'text/markdown; charset=utf-8',
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
// Agent screenshots — image blocks the run manager persisted out of tool
|
|
731
|
+
// results (persistImage). `basename` pins reads inside the run's own dir.
|
|
732
|
+
const IMAGE_TYPES = {
|
|
733
|
+
png: 'image/png',
|
|
734
|
+
jpg: 'image/jpeg',
|
|
735
|
+
webp: 'image/webp',
|
|
736
|
+
gif: 'image/gif',
|
|
737
|
+
};
|
|
738
|
+
app.get('/api/runs/:id/images/:file', (c) => {
|
|
739
|
+
const run = store.getRun(c.req.param('id'));
|
|
740
|
+
if (!run)
|
|
741
|
+
return c.json({ error: 'not found' }, 404);
|
|
742
|
+
const file = basename(c.req.param('file'));
|
|
743
|
+
const path = join(dataDir, 'runs', `${run.id}-images`, file);
|
|
744
|
+
if (!existsSync(path))
|
|
745
|
+
return c.json({ error: 'not found' }, 404);
|
|
746
|
+
const type = IMAGE_TYPES[file.split('.').pop() ?? ''] ?? 'application/octet-stream';
|
|
747
|
+
return new Response(readFileSync(path), {
|
|
748
|
+
headers: { 'content-type': type, 'cache-control': 'private, max-age=31536000, immutable' },
|
|
749
|
+
});
|
|
750
|
+
});
|
|
751
|
+
// Task diff (spec 006): what this run changed — its worktree vs its base.
|
|
752
|
+
app.get('/api/runs/:id/diff', async (c) => {
|
|
753
|
+
const run = store.getRun(c.req.param('id'));
|
|
754
|
+
if (!run)
|
|
755
|
+
return c.json({ error: 'not found' }, 404);
|
|
756
|
+
if (!run.worktreePath || !existsSync(run.worktreePath)) {
|
|
757
|
+
return c.text('(no worktree — this task ran directly in the repo working tree)');
|
|
758
|
+
}
|
|
759
|
+
return c.text(await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD'));
|
|
760
|
+
});
|
|
761
|
+
// ---- session git view (redesign R5 Step 1.2 — §"Git/session API additions").
|
|
762
|
+
// Structured sibling of the text-blob /diff above (which stays untouched —
|
|
763
|
+
// protected surface). Same worktree/base resolution; every predictable git
|
|
764
|
+
// failure degrades to 409 + human-readable reason, 404 only for unknown ids.
|
|
765
|
+
const worktreeOf = (run) => run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : null;
|
|
766
|
+
const NO_WORKTREE = 'no worktree — this task ran directly in the repo working tree';
|
|
767
|
+
app.get('/api/runs/:id/changes', async (c) => {
|
|
768
|
+
const run = store.getRun(c.req.param('id'));
|
|
769
|
+
if (!run)
|
|
770
|
+
return c.json({ error: 'not found' }, 404);
|
|
771
|
+
const worktree = worktreeOf(run);
|
|
772
|
+
if (!worktree)
|
|
773
|
+
return c.json({ error: NO_WORKTREE }, 409);
|
|
774
|
+
const result = await collectChanges(worktree, run.baseBranch ?? 'HEAD');
|
|
775
|
+
if (!result.ok)
|
|
776
|
+
return c.json({ error: result.error }, 409);
|
|
777
|
+
return c.json(result.changes);
|
|
778
|
+
});
|
|
779
|
+
// The run's own commits (<base>..HEAD on the worktree branch) — the Commits tab.
|
|
780
|
+
app.get('/api/runs/:id/commits', async (c) => {
|
|
781
|
+
const run = store.getRun(c.req.param('id'));
|
|
782
|
+
if (!run)
|
|
783
|
+
return c.json({ error: 'not found' }, 404);
|
|
784
|
+
const worktree = worktreeOf(run);
|
|
785
|
+
if (!worktree)
|
|
786
|
+
return c.json({ error: NO_WORKTREE }, 409);
|
|
787
|
+
const result = await collectRunCommits(worktree, run.baseBranch ?? 'HEAD');
|
|
788
|
+
if (!result.ok)
|
|
789
|
+
return c.json({ error: result.error }, 409);
|
|
790
|
+
return c.json({ commits: result.commits });
|
|
791
|
+
});
|
|
792
|
+
// One of the run's commits, structured like the Changes tab (reuses collectCommitChanges).
|
|
793
|
+
app.get('/api/runs/:id/commit/:sha', async (c) => {
|
|
794
|
+
const run = store.getRun(c.req.param('id'));
|
|
795
|
+
if (!run)
|
|
796
|
+
return c.json({ error: 'not found' }, 404);
|
|
797
|
+
const worktree = worktreeOf(run);
|
|
798
|
+
if (!worktree)
|
|
799
|
+
return c.json({ error: NO_WORKTREE }, 409);
|
|
800
|
+
const result = await collectCommitChanges(worktree, c.req.param('sha'));
|
|
801
|
+
if (!result.ok)
|
|
802
|
+
return c.json({ error: result.error }, 409);
|
|
803
|
+
return c.json(result.commit);
|
|
804
|
+
});
|
|
805
|
+
// Files tab: directory listing (path omitted or a dir) or file content
|
|
806
|
+
// (size-capped, binary flagged). Traversal-safe — readWorktreePath rejects
|
|
807
|
+
// anything escaping the worktree. `raw=1` (R5 Step 1.6) serves the BYTES of
|
|
808
|
+
// image files only, for the preview's inline <img> — never HTML/JS/etc., so
|
|
809
|
+
// no worktree file can become a same-origin document, and never past the
|
|
810
|
+
// size cap. The no-script CSP neutralizes SVG opened as a top-level URL.
|
|
811
|
+
app.get('/api/runs/:id/files', async (c) => {
|
|
812
|
+
const run = store.getRun(c.req.param('id'));
|
|
813
|
+
if (!run)
|
|
814
|
+
return c.json({ error: 'not found' }, 404);
|
|
815
|
+
const worktree = worktreeOf(run);
|
|
816
|
+
if (!worktree)
|
|
817
|
+
return c.json({ error: NO_WORKTREE }, 409);
|
|
818
|
+
const result = await readWorktreePath(worktree, c.req.query('path') ?? '');
|
|
819
|
+
if (result.kind === 'invalid' || result.kind === 'missing') {
|
|
820
|
+
return c.json({ error: result.error }, 409);
|
|
821
|
+
}
|
|
822
|
+
if (result.kind === 'dir') {
|
|
823
|
+
return c.json({ type: 'dir', path: result.path, entries: result.entries });
|
|
824
|
+
}
|
|
825
|
+
if (c.req.query('raw') === '1') {
|
|
826
|
+
const mime = imageMimeType(result.path);
|
|
827
|
+
if (!mime)
|
|
828
|
+
return c.json({ error: `raw serving is limited to images: ${result.path}` }, 409);
|
|
829
|
+
if (result.tooLarge) {
|
|
830
|
+
return c.json({ error: `file too large to serve raw (${result.size} bytes): ${result.path}` }, 409);
|
|
831
|
+
}
|
|
832
|
+
const bytes = await readFile(join(worktree, result.path));
|
|
833
|
+
return c.body(new Uint8Array(bytes).buffer, 200, {
|
|
834
|
+
'content-type': mime,
|
|
835
|
+
'x-content-type-options': 'nosniff',
|
|
836
|
+
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
return c.json({
|
|
840
|
+
type: 'file',
|
|
841
|
+
path: result.path,
|
|
842
|
+
size: result.size,
|
|
843
|
+
binary: result.binary,
|
|
844
|
+
tooLarge: result.tooLarge,
|
|
845
|
+
...(result.content !== undefined ? { content: result.content } : {}),
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
app.post('/api/runs/:id/git/commit', async (c) => {
|
|
849
|
+
const run = store.getRun(c.req.param('id'));
|
|
850
|
+
if (!run)
|
|
851
|
+
return c.json({ error: 'not found' }, 404);
|
|
852
|
+
const worktree = worktreeOf(run);
|
|
853
|
+
if (!worktree)
|
|
854
|
+
return c.json({ error: NO_WORKTREE }, 409);
|
|
855
|
+
const parsed = gitCommitSchema.safeParse(await c.req.json().catch(() => null));
|
|
856
|
+
if (!parsed.success) {
|
|
857
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
858
|
+
}
|
|
859
|
+
const result = await commitAll(worktree, parsed.data.message);
|
|
860
|
+
if (!result.ok)
|
|
861
|
+
return c.json({ error: result.error }, 409);
|
|
862
|
+
return c.json({ committed: true, sha: result.sha });
|
|
863
|
+
});
|
|
864
|
+
app.post('/api/runs/:id/git/push', async (c) => {
|
|
865
|
+
const run = store.getRun(c.req.param('id'));
|
|
866
|
+
if (!run)
|
|
867
|
+
return c.json({ error: 'not found' }, 404);
|
|
868
|
+
const worktree = worktreeOf(run);
|
|
869
|
+
if (!worktree)
|
|
870
|
+
return c.json({ error: NO_WORKTREE }, 409);
|
|
871
|
+
const result = await pushCurrentBranch(worktree);
|
|
872
|
+
if (!result.ok)
|
|
873
|
+
return c.json({ error: result.error }, 409);
|
|
874
|
+
return c.json({ pushed: true, branch: result.branch, remote: result.remote, upstreamSet: result.upstreamSet });
|
|
875
|
+
});
|
|
876
|
+
// Draft PR from the review gate (spec 009): final autosave → push →
|
|
877
|
+
// `gh pr create --draft`; on success the run completes as done with the PR
|
|
878
|
+
// badge. Failures come back as 409 with a `manual` merge command the GUI
|
|
879
|
+
// shows next to the toast. CEZ_DRY_RUN=1 fakes the URL (no push, no gh).
|
|
880
|
+
app.post('/api/runs/:id/pr', async (c) => {
|
|
881
|
+
const id = c.req.param('id');
|
|
882
|
+
const run = store.getRun(id);
|
|
883
|
+
if (!run)
|
|
884
|
+
return c.json({ error: 'not found' }, 404);
|
|
885
|
+
if (manager.isActive(id))
|
|
886
|
+
return c.json({ error: 'run is still active — wait for the review gate' }, 409);
|
|
887
|
+
if (!run.worktreePath || !existsSync(run.worktreePath) || !run.branch) {
|
|
888
|
+
return c.json({ error: 'no worktree/branch to publish — this task ran in the repo working tree' }, 400);
|
|
889
|
+
}
|
|
890
|
+
const outcome = await createDraftPr({ repoRoot, run, handoffText: readHandoff(dataDir, id) });
|
|
891
|
+
if (!outcome.ok) {
|
|
892
|
+
return c.json({ error: outcome.error, manual: `git merge ${run.branch}` }, 409);
|
|
893
|
+
}
|
|
894
|
+
store.updateRun(id, {
|
|
895
|
+
pullRequestUrl: outcome.url,
|
|
896
|
+
status: 'done',
|
|
897
|
+
finishedAt: run.finishedAt ?? new Date().toISOString(),
|
|
898
|
+
});
|
|
899
|
+
store.appendEvent(id, {
|
|
900
|
+
type: 'note',
|
|
901
|
+
message: `draft PR created: ${outcome.url}${outcome.dryRun ? ' (dry run — no real PR)' : ''}`,
|
|
902
|
+
});
|
|
903
|
+
return c.json({ url: outcome.url, dryRun: outcome.dryRun }, 201);
|
|
904
|
+
});
|
|
905
|
+
// Archived tasks keep their worktree for inspection; this is the explicit
|
|
906
|
+
// "🧹 Remove worktree" cleanup (spec 006).
|
|
907
|
+
app.post('/api/runs/:id/remove-worktree', async (c) => {
|
|
908
|
+
const id = c.req.param('id');
|
|
909
|
+
const run = store.getRun(id);
|
|
910
|
+
if (!run)
|
|
911
|
+
return c.json({ error: 'not found' }, 404);
|
|
912
|
+
if (manager.isActive(id))
|
|
913
|
+
return c.json({ error: 'run is active — cancel it first' }, 409);
|
|
914
|
+
if (run.worktreePath)
|
|
915
|
+
await removeWorktree(repoRoot, run.worktreePath, run.branch);
|
|
916
|
+
store.updateRun(id, { worktreePath: undefined, branch: undefined });
|
|
917
|
+
return c.json({ removed: true });
|
|
918
|
+
});
|
|
919
|
+
app.delete('/api/runs/:id', async (c) => {
|
|
920
|
+
const id = c.req.param('id');
|
|
921
|
+
if (manager.isActive(id))
|
|
922
|
+
return c.json({ error: 'run is active — cancel it first' }, 409);
|
|
923
|
+
const run = store.getRun(id);
|
|
924
|
+
if (!run)
|
|
925
|
+
return c.json({ error: 'not found' }, 404);
|
|
926
|
+
// Delete cleans up after itself: worktree + branch go with the run (spec 006).
|
|
927
|
+
if (run.worktreePath)
|
|
928
|
+
await removeWorktree(repoRoot, run.worktreePath, run.branch);
|
|
929
|
+
return store.deleteRun(id) ? c.json({ deleted: true }) : c.json({ error: 'not found' }, 404);
|
|
930
|
+
});
|
|
931
|
+
// ---- worktree management panel (#483) --------------------------------------
|
|
932
|
+
// List materialized task worktrees with disk usage + retention state, and a
|
|
933
|
+
// "Reclaim now" action. Both additive; the per-row delete reuses the existing
|
|
934
|
+
// /api/runs/:id/remove-worktree route above.
|
|
935
|
+
app.get('/api/worktrees', async (c) => {
|
|
936
|
+
const config = await loadConfig(repoRoot);
|
|
937
|
+
const runs = store.listRuns().filter((r) => r.worktreePath && existsSync(r.worktreePath));
|
|
938
|
+
const worktrees = await Promise.all(runs.map(async (r) => ({
|
|
939
|
+
runId: r.id,
|
|
940
|
+
title: r.title ?? r.id,
|
|
941
|
+
status: r.status,
|
|
942
|
+
branch: r.branch ?? null,
|
|
943
|
+
// POSIX `du` — degrades to null (Windows / du missing / error); never blocks.
|
|
944
|
+
sizeBytes: await worktreeSizeBytes(r.worktreePath),
|
|
945
|
+
finishedAt: r.finishedAt ?? null,
|
|
946
|
+
reclaimable: isReclaimable(r),
|
|
947
|
+
})));
|
|
948
|
+
// Total is null when any size degraded, so the panel never shows a wrong sum.
|
|
949
|
+
const totalBytes = worktrees.some((w) => w.sizeBytes === null)
|
|
950
|
+
? null
|
|
951
|
+
: worktrees.reduce((sum, w) => sum + (w.sizeBytes ?? 0), 0);
|
|
952
|
+
return c.json({ worktrees, totalBytes, keep: config.worktreeRetention });
|
|
953
|
+
});
|
|
954
|
+
const reclaimBodySchema = z.object({}).passthrough();
|
|
955
|
+
app.post('/api/worktrees/reclaim', async (c) => {
|
|
956
|
+
// Accept an empty or `{}` body; retention is best-effort, so 200 always.
|
|
957
|
+
const parsed = reclaimBodySchema.safeParse(await c.req.json().catch(() => ({})));
|
|
958
|
+
if (!parsed.success)
|
|
959
|
+
return c.json({ error: 'invalid body' }, 400);
|
|
960
|
+
const { worktreeRetention } = await loadConfig(repoRoot);
|
|
961
|
+
const reclaimed = await reclaimWorktrees(repoRoot, store, worktreeRetention);
|
|
962
|
+
return c.json({ reclaimed });
|
|
963
|
+
});
|
|
964
|
+
// ---- inbox (spec 007) ------------------------------------------------------
|
|
965
|
+
// Opt-in via CEZ_FOLLOWUPS=1 (#471). Off, the reader degrades to an empty
|
|
966
|
+
// inbox (a 404 would make old clients surface an error for a feature that is
|
|
967
|
+
// merely switched off) and the mutators 409 as defense in depth — the shape
|
|
968
|
+
// the hosted-mode open-in-* handlers already use. Existing todos.json entries
|
|
969
|
+
// are never touched, so flipping the env back on restores them.
|
|
970
|
+
app.get('/api/todos', async (c) => c.json(capabilities().followups ? await readTodos(dataDir) : []));
|
|
971
|
+
// Check off = delete the entry.
|
|
972
|
+
app.delete('/api/todos/:id', async (c) => {
|
|
973
|
+
if (!capabilities().followups)
|
|
974
|
+
return c.json({ error: FOLLOWUPS_OFF }, 409);
|
|
975
|
+
const removed = await removeTodo(dataDir, c.req.param('id'));
|
|
976
|
+
return removed ? c.json({ removed: true }) : c.json({ error: 'not found' }, 404);
|
|
977
|
+
});
|
|
978
|
+
// "▶ Run": turn an inbox entry into a task — a one-off single-step workflow
|
|
979
|
+
// around the suggested skill when it exists, plain quick-task otherwise.
|
|
980
|
+
app.post('/api/todos/:id/start', async (c) => {
|
|
981
|
+
if (!capabilities().followups)
|
|
982
|
+
return c.json({ error: FOLLOWUPS_OFF }, 409);
|
|
983
|
+
const id = c.req.param('id');
|
|
984
|
+
const todo = (await readTodos(dataDir)).find((t) => t.id === id);
|
|
985
|
+
if (!todo)
|
|
986
|
+
return c.json({ error: 'not found' }, 404);
|
|
987
|
+
if (todo.startedTaskId)
|
|
988
|
+
return c.json({ error: 'already started' }, 409);
|
|
989
|
+
// Body is optional — a request with none at all (the pre-#413 client) stays `undefined`
|
|
990
|
+
// here, same as an empty `{}`. A body that IS present but is not valid JSON becomes `null`,
|
|
991
|
+
// which the schema rejects → 400 (the `.catch(() => null)` pattern every other mutating
|
|
992
|
+
// route uses); mapping it to `undefined` too would let a broken payload pass as "no body"
|
|
993
|
+
// and silently 201.
|
|
994
|
+
const rawBody = await c.req.text().catch(() => '');
|
|
995
|
+
let body;
|
|
996
|
+
if (rawBody.trim().length > 0) {
|
|
997
|
+
try {
|
|
998
|
+
body = JSON.parse(rawBody);
|
|
999
|
+
}
|
|
1000
|
+
catch {
|
|
1001
|
+
body = null;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
const parsedBody = startTodoBodySchema.safeParse(body);
|
|
1005
|
+
if (!parsedBody.success) {
|
|
1006
|
+
return c.json({ error: parsedBody.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
1007
|
+
}
|
|
1008
|
+
let task = (todo.suggestedPrompt ?? todo.summary).trim() || todo.summary;
|
|
1009
|
+
if (todo.suggestedArgs)
|
|
1010
|
+
task += `\n\nArguments: ${todo.suggestedArgs}`;
|
|
1011
|
+
if (parsedBody.data?.prompt)
|
|
1012
|
+
task += `\n\n${parsedBody.data.prompt}`;
|
|
1013
|
+
let workflow;
|
|
1014
|
+
if (todo.suggestedSkill) {
|
|
1015
|
+
const skills = await discoverSkills(repoRoot);
|
|
1016
|
+
if (skills.some((s) => s.name === todo.suggestedSkill)) {
|
|
1017
|
+
workflow = {
|
|
1018
|
+
name: '(inbox)',
|
|
1019
|
+
description: `Follow-up from the inbox — skill "${todo.suggestedSkill}"`,
|
|
1020
|
+
source: 'built-in',
|
|
1021
|
+
steps: [{ id: 'task', name: 'Do the task', skill: todo.suggestedSkill, prompt: '{{task}}' }],
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
if (!workflow) {
|
|
1026
|
+
const { workflows } = await loadWorkflows(repoRoot);
|
|
1027
|
+
workflow = workflows.find((w) => w.name === 'quick-task') ?? QUICK_TASK_WORKFLOW;
|
|
1028
|
+
}
|
|
1029
|
+
const run = manager.startRun(workflow, { task });
|
|
1030
|
+
await markStarted(dataDir, id, run.id);
|
|
1031
|
+
return c.json({ run }, 201);
|
|
1032
|
+
});
|
|
1033
|
+
// Per-run SSE: full replay from the NDJSON file, then live events. The
|
|
1034
|
+
// listener attaches before the replay and buffers, so nothing emitted
|
|
1035
|
+
// during the replay is lost or duplicated (dedup by seq).
|
|
1036
|
+
app.get('/api/runs/:id/events', (c) => {
|
|
1037
|
+
const id = c.req.param('id');
|
|
1038
|
+
if (!store.getRun(id))
|
|
1039
|
+
return c.json({ error: 'not found' }, 404);
|
|
1040
|
+
return streamSSENoBuffer(c, async (stream) => {
|
|
1041
|
+
let replaying = true;
|
|
1042
|
+
let maxSeq = 0;
|
|
1043
|
+
const buffered = [];
|
|
1044
|
+
// One endpoint, two SSE event names: v1 lines stay `run-event` (the name
|
|
1045
|
+
// the legacy UI listened to — its default branch JSON-dumped unknown
|
|
1046
|
+
// types into the transcript, which is why v2 never rode that name; the
|
|
1047
|
+
// split outlives the R7 retirement as wire shape); protocol-v2 lines
|
|
1048
|
+
// (dotted types, persisted snapshots AND ephemeral coalesced deltas)
|
|
1049
|
+
// ride `ui-event`, which only v2-aware clients subscribe to.
|
|
1050
|
+
// EventSource ignores names it has no listener for.
|
|
1051
|
+
const writeEvent = (event) => stream.writeSSE({
|
|
1052
|
+
event: isV2WireEventType(event.type) ? 'ui-event' : 'run-event',
|
|
1053
|
+
data: JSON.stringify(event),
|
|
1054
|
+
});
|
|
1055
|
+
const onEvent = (payload) => {
|
|
1056
|
+
if (payload.runId !== id)
|
|
1057
|
+
return;
|
|
1058
|
+
if (replaying)
|
|
1059
|
+
buffered.push(payload.event);
|
|
1060
|
+
else
|
|
1061
|
+
void writeEvent(payload.event);
|
|
1062
|
+
};
|
|
1063
|
+
const onRun = (run) => {
|
|
1064
|
+
if (run.id !== id)
|
|
1065
|
+
return;
|
|
1066
|
+
void stream.writeSSE({ event: 'run', data: JSON.stringify(run) });
|
|
1067
|
+
};
|
|
1068
|
+
store.on('event', onEvent);
|
|
1069
|
+
store.on('run', onRun);
|
|
1070
|
+
stream.onAbort(() => {
|
|
1071
|
+
store.off('event', onEvent);
|
|
1072
|
+
store.off('run', onRun);
|
|
1073
|
+
});
|
|
1074
|
+
for (const event of store.readEvents(id)) {
|
|
1075
|
+
maxSeq = Math.max(maxSeq, event.seq);
|
|
1076
|
+
await writeEvent(event);
|
|
1077
|
+
}
|
|
1078
|
+
replaying = false;
|
|
1079
|
+
for (const event of buffered) {
|
|
1080
|
+
if (event.seq > maxSeq)
|
|
1081
|
+
await writeEvent(event);
|
|
1082
|
+
}
|
|
1083
|
+
const run = store.getRun(id);
|
|
1084
|
+
if (run)
|
|
1085
|
+
await stream.writeSSE({ event: 'run', data: JSON.stringify(run) });
|
|
1086
|
+
while (!stream.aborted) {
|
|
1087
|
+
await stream.writeSSE({ event: 'ping', data: '' });
|
|
1088
|
+
await stream.sleep(15_000);
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
});
|
|
1092
|
+
// Global SSE: run-summary updates for the list view + inbox changes.
|
|
1093
|
+
app.get('/api/events', (c) => streamSSENoBuffer(c, async (stream) => {
|
|
1094
|
+
const onRun = (run) => void stream.writeSSE({ event: 'run', data: JSON.stringify(run) });
|
|
1095
|
+
const onDeleted = (id) => void stream.writeSSE({ event: 'run-deleted', data: JSON.stringify({ id }) });
|
|
1096
|
+
const sendTodos = async () => {
|
|
1097
|
+
const items = await readTodos(dataDir).catch(() => []);
|
|
1098
|
+
await stream.writeSSE({ event: 'todos', data: JSON.stringify(items) });
|
|
1099
|
+
};
|
|
1100
|
+
// Opt-in inbox (#471): with the capability off the watcher never starts,
|
|
1101
|
+
// so this would never fire anyway — but the emitter is module-global, so
|
|
1102
|
+
// subscribe only when the inbox actually exists rather than lean on that.
|
|
1103
|
+
const offTodos = capabilities().followups
|
|
1104
|
+
? onTodosChanged(() => void sendTodos())
|
|
1105
|
+
: () => undefined;
|
|
1106
|
+
// Live resource telemetry (#348): the sampler ticks ~every 2 s only
|
|
1107
|
+
// while some run has a registered process; each tick is relayed as one
|
|
1108
|
+
// `usage` message (runId → {cpuPct, rssBytes, procCount}). Never
|
|
1109
|
+
// persisted — the NDJSON transcripts stay usage-free.
|
|
1110
|
+
const offUsage = onUsage((usage) => void stream.writeSSE({ event: 'usage', data: JSON.stringify(usage) }));
|
|
1111
|
+
store.on('run', onRun);
|
|
1112
|
+
store.on('deleted', onDeleted);
|
|
1113
|
+
stream.onAbort(() => {
|
|
1114
|
+
store.off('run', onRun);
|
|
1115
|
+
store.off('deleted', onDeleted);
|
|
1116
|
+
offTodos();
|
|
1117
|
+
offUsage();
|
|
1118
|
+
});
|
|
1119
|
+
while (!stream.aborted) {
|
|
1120
|
+
await stream.writeSSE({ event: 'ping', data: '' });
|
|
1121
|
+
await stream.sleep(15_000);
|
|
1122
|
+
}
|
|
1123
|
+
}));
|
|
1124
|
+
// ---- GitHub tab ------------------------------------------------------------
|
|
1125
|
+
// Issues + PRs of the repo's origin, read through the logged-in `gh` CLI.
|
|
1126
|
+
// Degrades to `{available:false, reason}` — no gh / no remote / offline all
|
|
1127
|
+
// just render as a hint in the tab, never an error.
|
|
1128
|
+
app.get('/api/github', async (c) => {
|
|
1129
|
+
const limit = Number.parseInt(c.req.query('limit') ?? '', 10);
|
|
1130
|
+
return c.json(await fetchGithub(repoRoot, c.req.query('refresh') === '1', Number.isFinite(limit) ? limit : 30));
|
|
1131
|
+
});
|
|
1132
|
+
// ---- repo view -----------------------------------------------------------
|
|
1133
|
+
app.get('/api/repo', async (c) => {
|
|
1134
|
+
const info = await getRepoInfo(repoRoot);
|
|
1135
|
+
if (!info)
|
|
1136
|
+
return c.json({ info: null, status: [], log: [], branches: [], baseBranch: null });
|
|
1137
|
+
const [status, log, branches, config] = await Promise.all([
|
|
1138
|
+
getStatus(info.root),
|
|
1139
|
+
getLog(info.root),
|
|
1140
|
+
getBranches(info.root),
|
|
1141
|
+
loadConfig(repoRoot),
|
|
1142
|
+
]);
|
|
1143
|
+
return c.json({ info, status, log, branches, baseBranch: config.baseBranch ?? null });
|
|
1144
|
+
});
|
|
1145
|
+
// The Settings → Agents knobs in one read (R6 Step 1.5) — an ADDITIVE
|
|
1146
|
+
// sibling of PUT /api/config below; /api/health keeps its protected shape.
|
|
1147
|
+
const configAnswer = (config) => ({
|
|
1148
|
+
baseBranch: config.baseBranch ?? null,
|
|
1149
|
+
defaultRunner: config.defaultRunner,
|
|
1150
|
+
systemPrompt: config.systemPrompt ?? null,
|
|
1151
|
+
defaultModels: config.defaultModels ?? {},
|
|
1152
|
+
maxParallel: config.maxParallel,
|
|
1153
|
+
memoryLimitMb: config.memoryLimitMb ?? null,
|
|
1154
|
+
// Count-based worktree retention (#483): keep the last N finished worktrees
|
|
1155
|
+
// on disk. 0 = unlimited. Always materialized (schema default 10).
|
|
1156
|
+
worktreeRetention: config.worktreeRetention,
|
|
1157
|
+
// Live title updates (task auto-naming spec): tri-state — null means "no
|
|
1158
|
+
// config key, the CEZ_TITLE_UPDATES env default (ON) decides".
|
|
1159
|
+
liveTitleUpdates: config.liveTitleUpdates ?? null,
|
|
1160
|
+
// Optional review gate (#489): tri-state — null means "no config key, the
|
|
1161
|
+
// CEZ_REVIEW_GATE env default (OFF) decides".
|
|
1162
|
+
reviewGate: config.reviewGate ?? null,
|
|
1163
|
+
});
|
|
1164
|
+
app.get('/api/config', async (c) => c.json(configAnswer(await loadConfig(repoRoot))));
|
|
1165
|
+
// Set/clear the agents' config knobs (Settings → Agents; the Repo tab's
|
|
1166
|
+
// base-branch picker). Merges into the RAW config.json so user keys
|
|
1167
|
+
// (skillsRepos…) survive and schema defaults are never materialized into
|
|
1168
|
+
// the file. All fields optional + additive: `null` (and `''` for the
|
|
1169
|
+
// R6 keys) clears a knob back to its default.
|
|
1170
|
+
const modelPresetSchema = z.string().trim().max(200).nullable().optional();
|
|
1171
|
+
const setConfigSchema = z.object({
|
|
1172
|
+
baseBranch: z.string().trim().min(1).max(200).nullable().optional(),
|
|
1173
|
+
defaultRunner: z.enum(['claude', 'codex', 'opencode']).optional(),
|
|
1174
|
+
systemPrompt: z
|
|
1175
|
+
.string()
|
|
1176
|
+
.trim()
|
|
1177
|
+
.max(20_000, 'systemPrompt must be at most 20000 characters')
|
|
1178
|
+
.nullable()
|
|
1179
|
+
.optional(),
|
|
1180
|
+
defaultModels: z
|
|
1181
|
+
.object({ claude: modelPresetSchema, codex: modelPresetSchema, opencode: modelPresetSchema })
|
|
1182
|
+
.optional(),
|
|
1183
|
+
// Concurrency + memory guard (Settings → Resources). maxParallel clamps to
|
|
1184
|
+
// the schema's 1–16; memoryLimitMb null/0 clears the ceiling.
|
|
1185
|
+
maxParallel: z.number().int().min(1).max(16).optional(),
|
|
1186
|
+
memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
|
|
1187
|
+
// Worktree retention count (Settings → Resources, #483). 0 = unlimited;
|
|
1188
|
+
// null clears the key back to the schema default (10). Unlike memoryLimitMb,
|
|
1189
|
+
// 0 is a meaningful value (unlimited), so it is stored, not treated as clear.
|
|
1190
|
+
worktreeRetention: z.number().int().min(0).max(1000).nullable().optional(),
|
|
1191
|
+
// Live title updates toggle (Settings → Agents): null clears the key back
|
|
1192
|
+
// to the env-default behavior.
|
|
1193
|
+
liveTitleUpdates: z.boolean().nullable().optional(),
|
|
1194
|
+
// Optional review gate toggle (Settings → Agents, #489): null clears the key
|
|
1195
|
+
// back to the env-default behavior (OFF).
|
|
1196
|
+
reviewGate: z.boolean().nullable().optional(),
|
|
1197
|
+
});
|
|
1198
|
+
app.put('/api/config', async (c) => {
|
|
1199
|
+
const parsed = setConfigSchema.safeParse(await c.req.json().catch(() => null));
|
|
1200
|
+
if (!parsed.success) {
|
|
1201
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
1202
|
+
}
|
|
1203
|
+
const configPath = join(dataDir, 'config.json');
|
|
1204
|
+
let raw = {};
|
|
1205
|
+
try {
|
|
1206
|
+
const existing = JSON.parse(await readFile(configPath, 'utf8'));
|
|
1207
|
+
if (existing && typeof existing === 'object')
|
|
1208
|
+
raw = existing;
|
|
1209
|
+
}
|
|
1210
|
+
catch {
|
|
1211
|
+
// missing or malformed — start fresh
|
|
1212
|
+
}
|
|
1213
|
+
if (parsed.data.baseBranch !== undefined) {
|
|
1214
|
+
if (parsed.data.baseBranch === null)
|
|
1215
|
+
delete raw.baseBranch;
|
|
1216
|
+
else
|
|
1217
|
+
raw.baseBranch = parsed.data.baseBranch;
|
|
1218
|
+
}
|
|
1219
|
+
if (parsed.data.defaultRunner !== undefined)
|
|
1220
|
+
raw.defaultRunner = parsed.data.defaultRunner;
|
|
1221
|
+
if (parsed.data.systemPrompt !== undefined) {
|
|
1222
|
+
// '' and null both clear: an emptied textarea means "no extra prompt".
|
|
1223
|
+
if (parsed.data.systemPrompt === null || parsed.data.systemPrompt === '') {
|
|
1224
|
+
delete raw.systemPrompt;
|
|
1225
|
+
}
|
|
1226
|
+
else {
|
|
1227
|
+
raw.systemPrompt = parsed.data.systemPrompt;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
if (parsed.data.maxParallel !== undefined)
|
|
1231
|
+
raw.maxParallel = parsed.data.maxParallel;
|
|
1232
|
+
if (parsed.data.worktreeRetention !== undefined) {
|
|
1233
|
+
// null clears back to the default (10); a number (including 0 = unlimited)
|
|
1234
|
+
// is stored as-is.
|
|
1235
|
+
if (parsed.data.worktreeRetention === null)
|
|
1236
|
+
delete raw.worktreeRetention;
|
|
1237
|
+
else
|
|
1238
|
+
raw.worktreeRetention = parsed.data.worktreeRetention;
|
|
1239
|
+
}
|
|
1240
|
+
if (parsed.data.liveTitleUpdates !== undefined) {
|
|
1241
|
+
if (parsed.data.liveTitleUpdates === null)
|
|
1242
|
+
delete raw.liveTitleUpdates;
|
|
1243
|
+
else
|
|
1244
|
+
raw.liveTitleUpdates = parsed.data.liveTitleUpdates;
|
|
1245
|
+
}
|
|
1246
|
+
if (parsed.data.reviewGate !== undefined) {
|
|
1247
|
+
if (parsed.data.reviewGate === null)
|
|
1248
|
+
delete raw.reviewGate;
|
|
1249
|
+
else
|
|
1250
|
+
raw.reviewGate = parsed.data.reviewGate;
|
|
1251
|
+
}
|
|
1252
|
+
if (parsed.data.memoryLimitMb !== undefined) {
|
|
1253
|
+
// null or 0 both mean "no ceiling" — drop the key back to the default.
|
|
1254
|
+
if (parsed.data.memoryLimitMb === null || parsed.data.memoryLimitMb === 0) {
|
|
1255
|
+
delete raw.memoryLimitMb;
|
|
1256
|
+
}
|
|
1257
|
+
else {
|
|
1258
|
+
raw.memoryLimitMb = parsed.data.memoryLimitMb;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
if (parsed.data.defaultModels !== undefined) {
|
|
1262
|
+
// Per-runner merge, so setting codex's preset never clobbers claude's.
|
|
1263
|
+
const current = raw.defaultModels && typeof raw.defaultModels === 'object'
|
|
1264
|
+
? { ...raw.defaultModels }
|
|
1265
|
+
: {};
|
|
1266
|
+
for (const [runner, model] of Object.entries(parsed.data.defaultModels)) {
|
|
1267
|
+
if (model === undefined)
|
|
1268
|
+
continue;
|
|
1269
|
+
if (model === null || model === '')
|
|
1270
|
+
delete current[runner];
|
|
1271
|
+
else
|
|
1272
|
+
current[runner] = model;
|
|
1273
|
+
}
|
|
1274
|
+
if (Object.keys(current).length === 0)
|
|
1275
|
+
delete raw.defaultModels;
|
|
1276
|
+
else
|
|
1277
|
+
raw.defaultModels = current;
|
|
1278
|
+
}
|
|
1279
|
+
try {
|
|
1280
|
+
await mkdir(dataDir, { recursive: true });
|
|
1281
|
+
await writeFile(configPath, `${JSON.stringify(raw, null, 2)}\n`, 'utf8');
|
|
1282
|
+
}
|
|
1283
|
+
catch (err) {
|
|
1284
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
1285
|
+
}
|
|
1286
|
+
// Pre-R6 answer shape ({baseBranch, defaultRunner}) + additive R6 fields.
|
|
1287
|
+
return c.json(configAnswer(await loadConfig(repoRoot)));
|
|
1288
|
+
});
|
|
1289
|
+
app.get('/api/repo/diff', async (c) => {
|
|
1290
|
+
const info = await getRepoInfo(repoRoot);
|
|
1291
|
+
if (!info)
|
|
1292
|
+
return c.text('not a git repository');
|
|
1293
|
+
return c.text(await getDiff(info.root));
|
|
1294
|
+
});
|
|
1295
|
+
// One commit's message + stat + patch — the Repo view expands it inline.
|
|
1296
|
+
// `?structured=1` is the ADDITIVE sibling (R5 Step 1.7): the new repo view's commit-diff
|
|
1297
|
+
// shape `{sha, subject, author, when, files, stat}` with 409 + reason on failure. The
|
|
1298
|
+
// legacy text answer below is a protected surface (BACKWARD_COMPATIBILITY.md §2) — its
|
|
1299
|
+
// shape, including the in-band failure sentences, stays exactly as it was.
|
|
1300
|
+
app.get('/api/repo/commit/:sha', async (c) => {
|
|
1301
|
+
const info = await getRepoInfo(repoRoot);
|
|
1302
|
+
if (c.req.query('structured') === '1') {
|
|
1303
|
+
if (!info)
|
|
1304
|
+
return c.json({ error: 'not a git repository' }, 409);
|
|
1305
|
+
const result = await collectCommitChanges(info.root, c.req.param('sha'));
|
|
1306
|
+
if (!result.ok)
|
|
1307
|
+
return c.json({ error: result.error }, 409);
|
|
1308
|
+
return c.json(result.commit);
|
|
1309
|
+
}
|
|
1310
|
+
if (!info)
|
|
1311
|
+
return c.text('not a git repository');
|
|
1312
|
+
try {
|
|
1313
|
+
return c.text(await getCommit(info.root, c.req.param('sha')));
|
|
1314
|
+
}
|
|
1315
|
+
catch (err) {
|
|
1316
|
+
return c.text(`(git show failed: ${err instanceof Error ? err.message : String(err)})`);
|
|
1317
|
+
}
|
|
1318
|
+
});
|
|
1319
|
+
// Structured sibling of the text-blob /api/repo/diff above (protected
|
|
1320
|
+
// surface, untouched): the same {files, stat} shape the session /changes
|
|
1321
|
+
// route serves, here for the MAIN working tree's uncommitted changes vs
|
|
1322
|
+
// HEAD (redesign R5 Step 1.3 — §"Git/session API additions").
|
|
1323
|
+
app.get('/api/repo/changes', async (c) => {
|
|
1324
|
+
const info = await getRepoInfo(repoRoot);
|
|
1325
|
+
if (!info)
|
|
1326
|
+
return c.json({ error: 'not a git repository' }, 409);
|
|
1327
|
+
// The user's REAL working tree — never stage into their index (a GET must not write).
|
|
1328
|
+
const result = await collectChanges(info.root, 'HEAD', { intentToAdd: false });
|
|
1329
|
+
if (!result.ok)
|
|
1330
|
+
return c.json({ error: result.error }, 409);
|
|
1331
|
+
return c.json(result.changes);
|
|
1332
|
+
});
|
|
1333
|
+
// Repo view branch actions: switch to an existing branch, or create one
|
|
1334
|
+
// (from `from` or HEAD) and switch. Predictable git failures — invalid
|
|
1335
|
+
// name, unknown `from`, dirty-tree checkout conflict — are 409 + reason.
|
|
1336
|
+
const repoBranchSchema = z.object({
|
|
1337
|
+
name: z.string().trim().min(1).max(200),
|
|
1338
|
+
from: z.string().trim().min(1).max(200).optional(),
|
|
1339
|
+
});
|
|
1340
|
+
app.post('/api/repo/branch', async (c) => {
|
|
1341
|
+
const info = await getRepoInfo(repoRoot);
|
|
1342
|
+
if (!info)
|
|
1343
|
+
return c.json({ error: 'not a git repository' }, 409);
|
|
1344
|
+
const parsed = repoBranchSchema.safeParse(await c.req.json().catch(() => null));
|
|
1345
|
+
if (!parsed.success) {
|
|
1346
|
+
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
1347
|
+
}
|
|
1348
|
+
const result = await createOrSwitchBranch(info.root, parsed.data.name, parsed.data.from);
|
|
1349
|
+
if (!result.ok)
|
|
1350
|
+
return c.json({ error: result.error }, 409);
|
|
1351
|
+
return c.json({ branch: result.branch, created: result.created });
|
|
1352
|
+
});
|
|
1353
|
+
// ---- SPA catch-all -------------------------------------------------------
|
|
1354
|
+
// Last, so every route above still wins. Any other GET gets the cockpit shell:
|
|
1355
|
+
// react-router owns the route map, including the 404, so `/tasks/:id/changes`
|
|
1356
|
+
// cold-loads and survives a refresh instead of 404ing. `/api/*` and the static
|
|
1357
|
+
// files above resolve to `passthrough` and fall through to Hono's own 404 —
|
|
1358
|
+
// an unknown API path must never answer with HTML.
|
|
1359
|
+
// Without a web/dist build this serves the built-in build-hint page (dev-only
|
|
1360
|
+
// state — the published tarball ships web/dist), never a 404.
|
|
1361
|
+
app.get('*', (c) => serveShell(c) ?? c.notFound());
|
|
1362
|
+
return app;
|
|
1363
|
+
}
|
|
1364
|
+
export function startServer(deps, port) {
|
|
1365
|
+
const app = createApp(deps);
|
|
1366
|
+
// SECURITY: default to loopback. This server executes agents locally and its endpoints are
|
|
1367
|
+
// same-origin-trusted (only /api/health is CORS-open); binding to a non-loopback host would
|
|
1368
|
+
// expose an agent-executing box to the network. `bindHost` exists only for a deliberate
|
|
1369
|
+
// hosted/VPS deployment (which also flips CEZ_REMOTE to gate the local-handoff endpoints) —
|
|
1370
|
+
// src/index.ts never passes it, so the loopback guarantee holds for the normal CLI.
|
|
1371
|
+
return serve({ fetch: app.fetch, port, hostname: deps.bindHost ?? '127.0.0.1' });
|
|
1372
|
+
}
|
|
1373
|
+
function resolveWebDir() {
|
|
1374
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
1375
|
+
// here = <pkg>/dist/server (built) or <pkg>/src/server (tsx dev).
|
|
1376
|
+
return join(here, '..', '..', 'web');
|
|
1377
|
+
}
|
|
1378
|
+
/** The CLI command that reopens a run's session for interactive take-over,
|
|
1379
|
+
* per backend. Legacy/undefined records default to Claude. */
|
|
1380
|
+
function resumeCommand(runner, sessionId) {
|
|
1381
|
+
switch (runner) {
|
|
1382
|
+
case 'codex':
|
|
1383
|
+
return `codex resume ${sessionId}`;
|
|
1384
|
+
case 'opencode':
|
|
1385
|
+
return `opencode --session ${sessionId}`;
|
|
1386
|
+
default:
|
|
1387
|
+
return `claude --resume ${sessionId}`;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
//# sourceMappingURL=server.js.map
|