@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.
- package/README.md +124 -37
- package/dist/config.d.ts +46 -0
- package/dist/config.js +31 -0
- package/dist/config.js.map +1 -1
- package/dist/core/agent-runner.d.ts +16 -0
- package/dist/core/agent-runner.js +9 -0
- package/dist/core/agent-runner.js.map +1 -1
- package/dist/core/claude-cli-runner.d.ts +6 -0
- package/dist/core/claude-cli-runner.js +27 -43
- package/dist/core/claude-cli-runner.js.map +1 -1
- package/dist/core/codex-app-server-runner.js +42 -5
- package/dist/core/codex-app-server-runner.js.map +1 -1
- package/dist/core/opencode-server-runner.js +42 -5
- package/dist/core/opencode-server-runner.js.map +1 -1
- package/dist/git-worktree.d.ts +21 -0
- package/dist/git-worktree.js +48 -4
- package/dist/git-worktree.js.map +1 -1
- package/dist/handoff.d.ts +1 -1
- package/dist/handoff.js +2 -0
- package/dist/handoff.js.map +1 -1
- package/dist/index.js +34 -4
- package/dist/index.js.map +1 -1
- package/dist/runs/store.d.ts +82 -1
- package/dist/runs/store.js +57 -5
- package/dist/runs/store.js.map +1 -1
- package/dist/server/git.js +12 -1
- package/dist/server/git.js.map +1 -1
- package/dist/server/github.d.ts +8 -29
- package/dist/server/github.js +7 -139
- package/dist/server/github.js.map +1 -1
- package/dist/server/pr.d.ts +5 -20
- package/dist/server/pr.js +4 -95
- package/dist/server/pr.js.map +1 -1
- package/dist/server/server.d.ts +30 -1
- package/dist/server/server.js +448 -19
- package/dist/server/server.js.map +1 -1
- package/dist/workflows/run.d.ts +90 -0
- package/dist/workflows/run.js +412 -29
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +16 -16
- package/package.json +44 -6
- package/scripts/check-pack.mjs +46 -0
- package/scripts/dev.mjs +74 -0
- package/scripts/mock-claude.mjs +54 -2
- package/scripts/test-process-usage.mjs +47 -0
- package/web/dist/assets/{toaster-BS2E1bhz.js → centered-state-zgEVNWsj.js} +1 -1
- package/web/dist/assets/compare-variants-ChHCcSQX.js +1 -0
- package/web/dist/assets/dialog-DDRjj-uK.js +1 -0
- package/web/dist/assets/{diff-view-CctnJHWe.js → diff-view-CWRO0GMe.js} +1 -1
- package/web/dist/assets/dist-id2CeyV-.js +1 -0
- package/web/dist/assets/git-toolbar-C4jo602L.js +1 -0
- package/web/dist/assets/github-BOIzQFwE.js +1 -0
- package/web/dist/assets/index-Cv1pQs9U.js +11 -0
- package/web/dist/assets/repo-git-BuyJiqna.js +1 -0
- package/web/dist/assets/{run-diff-C7mfxj7f.js → run-diff-BocG6LVd.js} +1 -1
- package/web/dist/assets/run-header-CDKw22ek.js +1 -0
- package/web/dist/assets/skill-detail-CcFFKmGO.js +1 -0
- package/web/dist/assets/skills-Bc_4U3yq.js +1 -0
- package/web/dist/assets/tab-link-C77EP3i6.js +1 -0
- package/web/dist/assets/task-changes-DsDgN8-x.js +1 -0
- package/web/dist/assets/task-commits-Dve6Fozc.js +1 -0
- package/web/dist/assets/{task-files-FOAAz2FB.js → task-files-BDMc_z0B.js} +1 -1
- package/web/dist/assets/{task-thread-Bncxe8Os.js → task-thread-CmRuPj9H.js} +2 -2
- package/web/dist/assets/use-desktop-CyEWsxSU.js +3 -0
- package/web/dist/assets/{workflows-Dg2wQg6i.js → workflows-J3z3kYic.js} +1 -1
- package/web/dist/index.html +5 -5
- package/web/app/node_modules/.vite/vitest/ca84d1343b96baa8137c943ed1860e522cacb238/results.json +0 -1
- package/web/app/src/components/app-shell-container.tsx +0 -76
- package/web/app.js +0 -3428
- package/web/dist/assets/compare-variants-D6fPL9cS.js +0 -1
- package/web/dist/assets/dialog-BEgEWFDS.js +0 -1
- package/web/dist/assets/dist-CW7M_Jhs.js +0 -1
- package/web/dist/assets/git-toolbar-D8OPPqBP.js +0 -1
- package/web/dist/assets/github-BYIHwIJB.js +0 -1
- package/web/dist/assets/index-9qG98Orw.js +0 -11
- package/web/dist/assets/repo-git-XTqlVOl2.js +0 -1
- package/web/dist/assets/run-header-CfVeY3uM.js +0 -1
- package/web/dist/assets/skill-detail-C9cniyId.js +0 -1
- package/web/dist/assets/skills-DXgY1uXJ.js +0 -1
- package/web/dist/assets/tab-link-phWEqSBy.js +0 -1
- package/web/dist/assets/task-changes-DYwnPnTN.js +0 -1
- package/web/dist/assets/task-commits-DZzxEzbn.js +0 -1
- package/web/dist/assets/use-desktop-eHm2iTXX.js +0 -3
- package/web/index.html +0 -108
- package/web/style.css +0 -1767
package/dist/server/server.js
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
//
|
|
705
|
-
//
|
|
706
|
-
|
|
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
|
-
|
|
742
|
-
return c.json(
|
|
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
|
-
|
|
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));
|