@terminus-ai/cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1055 -0
  3. package/bin/agent-discovery.mjs +71 -0
  4. package/bin/agent-icon.mjs +77 -0
  5. package/bin/agent-models.mjs +77 -0
  6. package/bin/agent-type.mjs +51 -0
  7. package/bin/agentdev.mjs +657 -0
  8. package/bin/app-route-script.mjs +59 -0
  9. package/bin/app-runtime-contract.mjs +2 -0
  10. package/bin/appdev-remote.mjs +346 -0
  11. package/bin/appdev.mjs +4446 -0
  12. package/bin/apps.mjs +5512 -0
  13. package/bin/capability-calls.mjs +437 -0
  14. package/bin/capsule-data.mjs +260 -0
  15. package/bin/client.mjs +189 -0
  16. package/bin/commands.mjs +1194 -0
  17. package/bin/dev-capsules.mjs +1599 -0
  18. package/bin/dev-contract.mjs +262 -0
  19. package/bin/dev-data.mjs +287 -0
  20. package/bin/dev-members.mjs +18 -0
  21. package/bin/dev-net.mjs +316 -0
  22. package/bin/dev-notification-popup.mjs +628 -0
  23. package/bin/dev-ports.mjs +567 -0
  24. package/bin/dev-server-binding.mjs +35 -0
  25. package/bin/dev-server-ops.mjs +1086 -0
  26. package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
  27. package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
  28. package/bin/dev-ui/OFL.txt +92 -0
  29. package/bin/dev-ui/agent-robot.webp +0 -0
  30. package/bin/dev-ui/app.js +5217 -0
  31. package/bin/dev-ui/highlight.js +195 -0
  32. package/bin/dev-ui/index.html +34 -0
  33. package/bin/dev-ui/style.css +3640 -0
  34. package/bin/devlint.mjs +112 -0
  35. package/bin/devserver.mjs +2127 -0
  36. package/bin/devtriggers.mjs +367 -0
  37. package/bin/endpoints.mjs +156 -0
  38. package/bin/errors.mjs +61 -0
  39. package/bin/files.mjs +169 -0
  40. package/bin/horizontal-capabilities/v1/contract.json +280 -0
  41. package/bin/http.mjs +500 -0
  42. package/bin/lint-manifests/justbash-commands.json +88 -0
  43. package/bin/lint-manifests/python-stdlib.json +295 -0
  44. package/bin/login-page.mjs +488 -0
  45. package/bin/schedules.mjs +664 -0
  46. package/bin/server-sandbox.mjs +204 -0
  47. package/bin/servicedev.mjs +425 -0
  48. package/bin/sync.mjs +357 -0
  49. package/bin/terminus.js +3666 -0
  50. package/bin/toolchain.mjs +125 -0
  51. package/bin/vendor/app-runtime-v1/app-host.json +124 -0
  52. package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
  53. package/bin/vendor/app-runtime-v1/doors.json +2867 -0
  54. package/bin/vendor/appd/node-harness.mjs +209 -0
  55. package/bin/vendor/appd/python-harness.py +12 -0
  56. package/bin/vendor/appd/server-protocol.json +84 -0
  57. package/bin/vendor/where.mjs +541 -0
  58. package/bin/versioning.mjs +72 -0
  59. package/bin/write-rules.mjs +398 -0
  60. package/package.json +41 -0
@@ -0,0 +1,2127 @@
1
+ /**
2
+ * The `terminus dev` web server for a kind:"agent" package.
3
+ *
4
+ * One local HTTP server on 127.0.0.1 between the browser and the dev
5
+ * machinery. It serves the static chat UI (`bin/dev-ui/`), bridges the
6
+ * engine's frames to SSE, owns the SQLite session mirror
7
+ * (`.terminus/dev/agent/dev.db`), watches the package for hot
8
+ * reloads, and is the only place credentials live — the user JWT never
9
+ * reaches the page. Because every /api door proxies user-authed cloud
10
+ * doors, each request must carry the per-run token embedded in the served
11
+ * page (plus an Origin check) — otherwise any tab on localhost could spend
12
+ * the developer's authority.
13
+ *
14
+ * Substrates (interface constant, substrate swapped):
15
+ * - local (default): `LocalDev` — agentd on this machine, native exec,
16
+ * brokered web/skills/connectors.
17
+ * - `--remote`: `AgentDevSession` — the tree pushed as the Studio draft,
18
+ * turns on the production engine. Same UI.
19
+ *
20
+ * An agent has ONE conversation here, start to finish: the engine's session
21
+ * is resumed on every start, and there is no door to list, switch or begin
22
+ * another. The SQLite row survives so a reload keeps the transcript.
23
+ *
24
+ * terminus.json is ground truth: the inspector's edits land in the manifest
25
+ * (indent-preserving re-render of the strict-JSON file), then the engine
26
+ * recompiles. The one thing that is NOT written there is a reader's model
27
+ * pick, because it is not the developer's configuration — it is what one
28
+ * visitor chose, and only an agent that offers a choice has one.
29
+ */
30
+
31
+ import { execFile } from "node:child_process";
32
+ import { randomBytes } from "node:crypto";
33
+ import { watch } from "node:fs";
34
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
35
+ import { createServer } from "node:http";
36
+ import path from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+
39
+ import { DEV_DIRECTORY, ensureDevDirectory, TERMINUS_DIRECTORY } from "./files.mjs";
40
+
41
+ import {
42
+ AgentDevSession,
43
+ LocalDev,
44
+ compatWarningsForToolCall,
45
+ } from "./agentdev.mjs";
46
+ import {
47
+ MAX_ICON_BYTES,
48
+ STAGED_ICON_NAMES,
49
+ readStagedIcon,
50
+ sniffImage,
51
+ } from "./agent-icon.mjs";
52
+ import { MODEL_MODES, readModelSection, writeModelSection } from "./agent-models.mjs";
53
+ import { readDiscovery, writeDiscovery } from "./agent-discovery.mjs";
54
+ import { readAgentType, writeAgentType } from "./agent-type.mjs";
55
+ import {
56
+ buildTriggerInput,
57
+ effectiveWorkloads,
58
+ listDevTriggers,
59
+ loadWatchState,
60
+ runTriggerFiring,
61
+ } from "./devtriggers.mjs";
62
+ import { readAppPackage, resolveCreation } from "./apps.mjs";
63
+ import { parseAgentTriggers, validUtcCron } from "./schedules.mjs";
64
+ import { CliError } from "./client.mjs";
65
+ import { normalizeBase } from "./http.mjs";
66
+ import {
67
+ answerDevAbout,
68
+ assertDevPortRangeAvailable,
69
+ DEV_ABOUT_PATH,
70
+ devPortUnavailableError,
71
+ isLoopbackHost,
72
+ isLoopbackOrigin,
73
+ listenDevServer,
74
+ refuseForeignHost,
75
+ resolveDevPortRange,
76
+ } from "./dev-ports.mjs";
77
+
78
+ const BODY_LIMIT_BYTES = 40 * 1024 * 1024;
79
+ const MAX_IMAGE_ATTACHMENTS = 4;
80
+ const MAX_IMAGE_ATTACHMENT_BYTES = 5 * 1024 * 1024;
81
+ const MAX_FILE_ATTACHMENTS = 4;
82
+ const MAX_FILE_ATTACHMENT_BYTES = 8 * 1024 * 1024;
83
+ const IMAGE_MEDIA_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
84
+ const PORT_ATTEMPTS = 50;
85
+ const WATCH_DEBOUNCE_MS = 500;
86
+ const TOOL_INPUT_MIRROR_CAP = 4_096;
87
+
88
+ const uiDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "dev-ui");
89
+
90
+ const CONTENT_TYPES = {
91
+ ".css": "text/css; charset=utf-8",
92
+ ".csv": "text/csv; charset=utf-8",
93
+ ".gif": "image/gif",
94
+ ".html": "text/html; charset=utf-8",
95
+ ".jpeg": "image/jpeg",
96
+ ".jpg": "image/jpeg",
97
+ ".js": "text/javascript; charset=utf-8",
98
+ ".json": "application/json; charset=utf-8",
99
+ ".md": "text/markdown; charset=utf-8",
100
+ ".mjs": "text/javascript; charset=utf-8",
101
+ ".pdf": "application/pdf",
102
+ ".png": "image/png",
103
+ ".svg": "image/svg+xml",
104
+ ".txt": "text/plain; charset=utf-8",
105
+ ".webp": "image/webp",
106
+ ".woff2": "font/woff2",
107
+ };
108
+
109
+ export function contentTypeFor(filePath) {
110
+ return CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // SQLite session mirror
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /** Open (creating on first use) the local SQLite mirror. Prod parity
118
+ * decides storage: sessions are Postgres rows in prod, so locally they are
119
+ * SQLite rows; outputs stay real files in the workspace lanes. */
120
+ export async function openDevDb(dbPath) {
121
+ let DatabaseSync;
122
+ try {
123
+ ({ DatabaseSync } = await import("node:sqlite"));
124
+ } catch {
125
+ throw new CliError(
126
+ "the dev needs node:sqlite (Node ≥ 22.16) — update Node to run the web dev",
127
+ );
128
+ }
129
+ await mkdir(path.dirname(dbPath), { recursive: true });
130
+ const db = new DatabaseSync(dbPath);
131
+ db.exec(`
132
+ CREATE TABLE IF NOT EXISTS sessions (
133
+ id TEXT PRIMARY KEY,
134
+ title TEXT NOT NULL DEFAULT '',
135
+ model TEXT NOT NULL DEFAULT '',
136
+ substrate TEXT NOT NULL DEFAULT 'local',
137
+ created_at INTEGER NOT NULL,
138
+ updated_at INTEGER NOT NULL,
139
+ tokens INTEGER NOT NULL DEFAULT 0,
140
+ cost_usd REAL NOT NULL DEFAULT 0
141
+ );
142
+ CREATE TABLE IF NOT EXISTS messages (
143
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
144
+ session_id TEXT NOT NULL,
145
+ role TEXT NOT NULL,
146
+ content TEXT NOT NULL,
147
+ extra TEXT,
148
+ created_at INTEGER NOT NULL
149
+ );
150
+ CREATE INDEX IF NOT EXISTS messages_session ON messages (session_id, id);
151
+ CREATE TABLE IF NOT EXISTS prefs (
152
+ key TEXT PRIMARY KEY,
153
+ value TEXT NOT NULL
154
+ );
155
+ `);
156
+ return {
157
+ ensureSession(id, model, substrate) {
158
+ db.prepare(
159
+ `INSERT INTO sessions (id, model, substrate, created_at, updated_at)
160
+ VALUES (?, ?, ?, ?, ?)
161
+ ON CONFLICT(id) DO UPDATE SET model = excluded.model`,
162
+ ).run(id, model ?? "", substrate, Date.now(), Date.now());
163
+ },
164
+ touchSession(id, { title, tokens = 0, costUsd = 0 } = {}) {
165
+ db.prepare(
166
+ `UPDATE sessions SET
167
+ updated_at = ?,
168
+ tokens = tokens + ?,
169
+ cost_usd = cost_usd + ?,
170
+ title = CASE WHEN title = '' THEN ? ELSE title END
171
+ WHERE id = ?`,
172
+ ).run(Date.now(), tokens, costUsd, (title ?? "").slice(0, 80), id);
173
+ },
174
+ insertMessage(sessionId, role, content, extra) {
175
+ db.prepare(
176
+ "INSERT INTO messages (session_id, role, content, extra, created_at) VALUES (?, ?, ?, ?, ?)",
177
+ ).run(sessionId, role, content, extra ? JSON.stringify(extra) : null, Date.now());
178
+ },
179
+ listSessions() {
180
+ return db
181
+ .prepare(
182
+ `SELECT s.id, s.title, s.model, s.substrate, s.created_at, s.updated_at,
183
+ s.tokens, s.cost_usd,
184
+ (SELECT COUNT(*) FROM messages m
185
+ WHERE m.session_id = s.id AND m.role = 'user') AS turns
186
+ FROM sessions s ORDER BY s.updated_at DESC`,
187
+ )
188
+ .all();
189
+ },
190
+ listMessages(sessionId) {
191
+ return db
192
+ .prepare(
193
+ "SELECT id, role, content, extra, created_at FROM messages WHERE session_id = ? ORDER BY id ASC",
194
+ )
195
+ .all(sessionId)
196
+ .map((row) => ({
197
+ ...row,
198
+ extra: row.extra ? JSON.parse(row.extra) : null,
199
+ }));
200
+ },
201
+ close() {
202
+ try {
203
+ db.close();
204
+ } catch {
205
+ // Best effort; process exit reclaims it anyway.
206
+ }
207
+ },
208
+ };
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // terminus.json is ground truth
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /** Apply one mutation to the package manifest, re-rendering the strict-JSON
216
+ * file with its own indentation (detected, defaulting to two spaces) and a
217
+ * trailing newline. terminus.json is scaffolded strict JSON — there are no
218
+ * comments to preserve. */
219
+ export async function editManifestFile(dir, mutate) {
220
+ const manifestPath = path.join(dir, "terminus.json");
221
+ const raw = await readFile(manifestPath, "utf8");
222
+ const indent = /\n([ \t]+)"/.exec(raw)?.[1] ?? " ";
223
+ let manifest;
224
+ try {
225
+ manifest = JSON.parse(raw);
226
+ } catch {
227
+ throw new CliError("terminus.json is not valid JSON — fix it before editing from the dev");
228
+ }
229
+ mutate(manifest);
230
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, indent)}\n`);
231
+ return manifest;
232
+ }
233
+
234
+ function toolIdentity(entry) {
235
+ return typeof entry === "string" ? entry : entry?.id;
236
+ }
237
+
238
+ /** A sanity bound mirroring the backend's, not a product limit. The old cap
239
+ * was 16 — below the size of the live catalog, so "any model" could not be
240
+ * published at all. */
241
+ export const MAX_AGENT_MODELS = 512;
242
+
243
+ /** The declarable platform lanes that are not connector profiles — the core
244
+ * half of GET /api/tool-catalog. Connector tools join at request time from
245
+ * the live `/connectors` catalog, so this list never has to chase them. */
246
+ export const CORE_TOOL_CATALOG = [
247
+ { id: "internet", name: "Internet", desc: "Search the web and read public pages through the gateway", kind: "core" },
248
+ { id: "compute", name: "Compute", desc: "Run shell, Python, and Node programs in the workspace", kind: "core" },
249
+ { id: "automation", name: "Automation", desc: "Manage its own schedules and page watches", kind: "core" },
250
+ { id: "bash", name: "Bash", desc: "Run shell commands in the workspace", kind: "core" },
251
+ { id: "python", name: "Python", desc: "Compute with Python", kind: "core" },
252
+ { id: "node", name: "Node.js", desc: "Run Node.js", kind: "core" },
253
+ { id: "web.search", name: "Web search", desc: "Search the public web through the gateway", kind: "core" },
254
+ { id: "web.fetch", name: "Web fetch", desc: "Fetch a public page through the gateway", kind: "core" },
255
+ { id: "schedules.manage", name: "Schedules", desc: "Manage its own scheduled runs in conversation", kind: "core" },
256
+ { id: "watches.manage", name: "Watches", desc: "Track pages and sources in conversation", kind: "core" },
257
+ ];
258
+
259
+ /**
260
+ * The lanes above, said the way a developer says them: three bundles, each
261
+ * standing for the tools it expands to. This is the registry's own grouping
262
+ * (`bin/horizontal-capabilities/v1/contract.json` → `bundles`) carried into
263
+ * the manifest's vocabulary, where a member is `web.search` rather than the
264
+ * registry's `web-search`; `registryToolId` and its test hold the two
265
+ * together, so a member added upstream cannot go missing here in silence.
266
+ *
267
+ * Granting every member of a bundle IS the bundle — that is what the
268
+ * compiler's sugar means — which is why the picker offers the bundle first
269
+ * and its members as the thing you narrow it to.
270
+ */
271
+ export const TOOL_BUNDLES = [
272
+ { id: "internet", members: ["web.search", "web.fetch"] },
273
+ { id: "compute", members: ["bash", "python", "node"] },
274
+ { id: "automation", members: ["schedules.manage", "watches.manage"] },
275
+ ].map((bundle) => {
276
+ const core = CORE_TOOL_CATALOG.find((tool) => tool.id === bundle.id);
277
+ return {
278
+ ...bundle,
279
+ name: core?.name ?? bundle.id,
280
+ desc: core?.desc ?? "",
281
+ tools: bundle.members.map((id) => {
282
+ const member = CORE_TOOL_CATALOG.find((tool) => tool.id === id);
283
+ return { id, name: member?.name ?? id, desc: member?.desc ?? "" };
284
+ }),
285
+ };
286
+ });
287
+
288
+ /** A horizontal-registry capability id as the manifest spells it. The two
289
+ * vocabularies differ in exactly this handful of names. */
290
+ export function registryToolId(id) {
291
+ return { "web-search": "web.search", "web-fetch": "web.fetch", schedules: "schedules.manage", watches: "watches.manage" }[id]
292
+ ?? id;
293
+ }
294
+
295
+ /** The manifest operations the Settings panel performs. */
296
+ export function applyManifestOps(manifest, ops) {
297
+ // Who reads the agent's conversations: the default, or its maintainers.
298
+ if (ops.type !== undefined) {
299
+ writeAgentType(manifest, String(ops.type ?? "").trim());
300
+ }
301
+ // Model configuration is ONE section, read and written whole: a default, a
302
+ // mode, and a list that only exists under `selective`. Editing the parts
303
+ // separately is how the flat shape ended up able to hold two answers.
304
+ if (ops.model !== undefined || ops.model_mode !== undefined || ops.models !== undefined) {
305
+ const section = readModelSection(manifest);
306
+
307
+ if (typeof ops.model === "string" && ops.model.trim()) {
308
+ section.default = ops.model.trim();
309
+ }
310
+
311
+ if (ops.model_mode !== undefined) {
312
+ const mode = String(ops.model_mode ?? "default").trim();
313
+ if (!MODEL_MODES.includes(mode)) {
314
+ throw new CliError(`models.mode is one of ${MODEL_MODES.join(", ")}`);
315
+ }
316
+ section.mode = mode;
317
+ // A list only means something under `selective`; leaving one behind
318
+ // under another mode would be a second, contradicting answer.
319
+ if (mode !== "selective") section.available = [];
320
+ }
321
+
322
+ if (ops.models !== undefined) {
323
+ const list = Array.isArray(ops.models)
324
+ ? ops.models.map((entry) => String(entry ?? "").trim()).filter(Boolean)
325
+ : [];
326
+ // The platform offers the default PLUS this list (agent_host.rs
327
+ // allowed_models), so naming the default again buys nothing and makes a
328
+ // default-only agent look like it offers a choice of one.
329
+ const unique = [...new Set(list)].filter((id) => id !== section.default);
330
+ if (unique.length > MAX_AGENT_MODELS) {
331
+ throw new CliError(`at most ${MAX_AGENT_MODELS} selectable models`);
332
+ }
333
+ section.available = unique;
334
+ section.mode = unique.length ? "selective" : "default";
335
+ }
336
+
337
+ writeModelSection(manifest, section);
338
+ }
339
+ // Whether the agent may look in the Store for a kind of thing, or keep to
340
+ // what its tools name.
341
+ if (ops.discovery !== undefined) {
342
+ writeDiscovery(manifest, ops.discovery?.kind, ops.discovery?.mode);
343
+ }
344
+ // One tool or several: a service is one token per operation, so adding or
345
+ // dropping it is a set of tokens that has to land in ONE write.
346
+ const adding = ops.add_tools ?? (ops.add_tool !== undefined ? [ops.add_tool] : []);
347
+ if (adding.length) {
348
+ const tools = Array.isArray(manifest.tools) ? manifest.tools : [];
349
+ for (const token of adding) {
350
+ const id = toolIdentity(token);
351
+ if (!id) throw new CliError("add_tool needs a tool token (string or {id, when})");
352
+ if (!tools.some((entry) => toolIdentity(entry) === id)) tools.push(token);
353
+ }
354
+ manifest.tools = tools;
355
+ }
356
+ const dropping = new Set(
357
+ (ops.remove_tools ?? (ops.remove_tool ? [ops.remove_tool] : []))
358
+ .filter((id) => typeof id === "string" && id),
359
+ );
360
+ if (dropping.size) {
361
+ manifest.tools = (Array.isArray(manifest.tools) ? manifest.tools : []).filter(
362
+ (entry) => !dropping.has(toolIdentity(entry)),
363
+ );
364
+ }
365
+ const agentTriggerOps = manifest.kind === "agent"
366
+ && ["schedule", "schedule_create", "schedule_delete", "watch", "watch_create", "watch_delete"]
367
+ .some((op) => ops[op] !== undefined);
368
+ if (agentTriggerOps) {
369
+ applyAgentTriggerOps(manifest, ops, AGENT_TRIGGER_KINDS.schedule);
370
+ applyAgentTriggerOps(manifest, ops, AGENT_TRIGGER_KINDS.watch);
371
+ } else if (ops.schedule !== undefined) {
372
+ const { name, cron, timezone, enabled } = ops.schedule ?? {};
373
+ if (!name) throw new CliError("schedule op needs the schedule's name");
374
+ const schedules = manifest.workloads?.schedules;
375
+ const target = Array.isArray(schedules)
376
+ ? schedules.find((entry) => entry?.name === name)
377
+ : null;
378
+ if (!target) throw new CliError(`no schedule named '${name}' in workloads.schedules`);
379
+ if (cron !== undefined) {
380
+ if (!validUtcCron(cron)) {
381
+ throw new CliError(
382
+ "cron fields are *, */N, or one number (minute hour day month weekday)",
383
+ );
384
+ }
385
+ target.cron = String(cron).trim();
386
+ }
387
+ if (timezone !== undefined) {
388
+ try {
389
+ new Intl.DateTimeFormat("en", { timeZone: timezone });
390
+ } catch {
391
+ throw new CliError(`unknown timezone '${timezone}'`);
392
+ }
393
+ target.timezone = timezone;
394
+ }
395
+ // `enabled: true` is the default, so writing it out would only add noise
396
+ // to the manifest; delete the key instead of stating the default.
397
+ if (enabled !== undefined) {
398
+ if (enabled) delete target.enabled;
399
+ else target.enabled = false;
400
+ }
401
+ }
402
+ return manifest;
403
+ }
404
+
405
+ const CADENCE_FIELDS = ["every", "at", "on_day", "cron"];
406
+
407
+ /** The two conversationally-editable trigger kinds, one grammar each. */
408
+ const AGENT_TRIGGER_KINDS = {
409
+ schedule: {
410
+ op: "schedule",
411
+ section: "schedules",
412
+ fields: ["name", "every", "at", "on_day", "cron", "timezone", "prompt", "enabled"],
413
+ // A cadence update replaces the whole cadence atomically.
414
+ exclusive: CADENCE_FIELDS,
415
+ defaults: (draft) => {
416
+ if (draft.cron === undefined && draft.every === undefined) {
417
+ draft.every = "day";
418
+ draft.at = draft.at ?? "09:00";
419
+ }
420
+ },
421
+ generatedName: (entry) => (entry.cron !== undefined
422
+ ? "custom"
423
+ : entry.every === "day"
424
+ ? "daily"
425
+ : entry.every === "month"
426
+ ? "monthly"
427
+ : /^\d+[mh]$/.test(entry.every ?? "")
428
+ ? `every-${entry.every}`
429
+ : String(entry.every ?? "schedule")),
430
+ },
431
+ watch: {
432
+ op: "watch",
433
+ section: "watches",
434
+ fields: ["name", "url", "pattern", "interval_minutes", "prompt", "condition", "enabled"],
435
+ exclusive: [],
436
+ defaults: (draft) => {
437
+ draft.url = draft.url ?? "https://example.com";
438
+ },
439
+ generatedName: () => "watch",
440
+ },
441
+ };
442
+
443
+ /** Strip defaults so the authored file never states them: enabled true,
444
+ * nulls, and undefined fields stay out of terminus.json. */
445
+ function cleanAgentTrigger(entry, fields) {
446
+ const cleaned = {};
447
+ for (const field of fields) {
448
+ if (entry[field] !== undefined && entry[field] !== null) cleaned[field] = entry[field];
449
+ }
450
+ if (cleaned.enabled === true) delete cleaned.enabled;
451
+ return cleaned;
452
+ }
453
+
454
+ /**
455
+ * Agent triggers live at the manifest top level in prompt form. Per kind:
456
+ * `<kind>_create` appends (auto-naming once there is more than one),
457
+ * `<kind>_delete` removes by name ("default" addresses a single unnamed
458
+ * entry), `<kind>` updates fields — `null` clears a field back to its
459
+ * default. The result is validated whole before it is written, so a refused
460
+ * edit leaves the manifest untouched.
461
+ */
462
+ function applyAgentTriggerOps(manifest, ops, kind) {
463
+ const create = ops[`${kind.op}_create`];
464
+ const remove = ops[`${kind.op}_delete`];
465
+ const update = ops[kind.op];
466
+ if (create === undefined && remove === undefined && update === undefined) return;
467
+ let entries = Array.isArray(manifest[kind.section])
468
+ ? manifest[kind.section].map((entry) => ({ ...entry }))
469
+ : [];
470
+ const keyOf = (entry) => entry.name ?? "default";
471
+ if (create !== undefined) {
472
+ const draft = { ...(create ?? {}) };
473
+ kind.defaults(draft);
474
+ if (entries.length === 1 && entries[0].name === undefined) {
475
+ // The single entry's key was implicitly "default"; naming it keeps the
476
+ // key (and its platform state) stable as the list grows.
477
+ entries[0].name = "default";
478
+ }
479
+ if (draft.name === undefined && entries.length) {
480
+ const taken = new Set(entries.map(keyOf));
481
+ const base = kind.generatedName(draft);
482
+ let name = base;
483
+ for (let counter = 2; taken.has(name); counter += 1) name = `${base}-${counter}`;
484
+ draft.name = name;
485
+ }
486
+ entries.push(cleanAgentTrigger(draft, kind.fields));
487
+ }
488
+ if (remove !== undefined) {
489
+ const name = remove?.name;
490
+ if (!name) throw new CliError(`${kind.op}_delete needs the ${kind.op}'s name`);
491
+ const remaining = entries.filter((entry) => keyOf(entry) !== name);
492
+ if (remaining.length === entries.length) {
493
+ throw new CliError(`no ${kind.op} named '${name}'`);
494
+ }
495
+ entries = remaining;
496
+ if (entries.length === 1 && entries[0].name === "default") {
497
+ delete entries[0].name;
498
+ }
499
+ }
500
+ if (update !== undefined) {
501
+ const updates = update ?? {};
502
+ if (!updates.name) throw new CliError(`${kind.op} op needs the ${kind.op}'s name`);
503
+ const index = entries.findIndex((entry) => keyOf(entry) === updates.name);
504
+ if (index < 0) throw new CliError(`no ${kind.op} named '${updates.name}'`);
505
+ const target = { ...entries[index] };
506
+ if (kind.exclusive.some((field) => updates[field] !== undefined)) {
507
+ for (const field of kind.exclusive) delete target[field];
508
+ }
509
+ for (const field of kind.fields) {
510
+ if (field === "name" || updates[field] === undefined) continue;
511
+ target[field] = updates[field];
512
+ }
513
+ entries[index] = cleanAgentTrigger(target, kind.fields);
514
+ }
515
+ // One atomic gate for whatever the ops produced — the same validation
516
+ // publish runs, so the file can never be edited into an unpublishable
517
+ // state.
518
+ const candidate = { ...manifest };
519
+ if (entries.length) candidate[kind.section] = entries;
520
+ else delete candidate[kind.section];
521
+ parseAgentTriggers(candidate, { agentName: "agent" });
522
+ if (entries.length) manifest[kind.section] = entries;
523
+ else delete manifest[kind.section];
524
+ }
525
+
526
+ // ---------------------------------------------------------------------------
527
+ // Attachments (the ratified contract, local half)
528
+ // ---------------------------------------------------------------------------
529
+
530
+ function sanitizeAttachmentName(name) {
531
+ const base = String(name ?? "")
532
+ .split(/[\\/]/)
533
+ .pop();
534
+ const cleaned = base
535
+ .replace(/[^A-Za-z0-9._-]/g, "-")
536
+ .replace(/^[.-]+|[.-]+$/g, "")
537
+ .slice(0, 80);
538
+ return cleaned || "file";
539
+ }
540
+
541
+ /** Validate and split a message's attachments: images become engine content
542
+ * parts, files materialize into the workspace's attachments/ folder with
543
+ * reference notes appended to the text — mirroring the hosted door exactly.
544
+ *
545
+ * `mediaDir` is the harness's own store, OUTSIDE the agent's lanes: image bytes
546
+ * are kept there only so the transcript can still show the photo after a
547
+ * reload. The agent never sees them as files, because the hosted agent does
548
+ * not either — images reach a model as content parts and nothing else. */
549
+ const IMAGE_EXTENSIONS = {
550
+ "image/png": "png",
551
+ "image/jpeg": "jpg",
552
+ "image/webp": "webp",
553
+ "image/gif": "gif",
554
+ };
555
+
556
+ export async function prepareAttachments(attachments, workspaceDir, mediaDir = null) {
557
+ const images = [];
558
+ const notes = [];
559
+ const fileMeta = [];
560
+ const imageMeta = [];
561
+ const list = Array.isArray(attachments) ? attachments : [];
562
+ const imageCount = list.filter((entry) => (entry?.kind ?? "image") === "image").length;
563
+ const fileCount = list.filter((entry) => entry?.kind === "file").length;
564
+ if (imageCount > MAX_IMAGE_ATTACHMENTS) {
565
+ throw new CliError(`at most ${MAX_IMAGE_ATTACHMENTS} image attachments per message`);
566
+ }
567
+ if (fileCount > MAX_FILE_ATTACHMENTS) {
568
+ throw new CliError(`at most ${MAX_FILE_ATTACHMENTS} file attachments per message`);
569
+ }
570
+ for (const entry of list) {
571
+ const kind = entry?.kind ?? "image";
572
+ const data = typeof entry?.data === "string" ? entry.data : "";
573
+ let bytes;
574
+ try {
575
+ bytes = Buffer.from(data, "base64");
576
+ } catch {
577
+ bytes = Buffer.alloc(0);
578
+ }
579
+ if (!bytes.length) throw new CliError("attachment data must be non-empty base64");
580
+ if (kind === "image") {
581
+ const mediaType = entry?.media_type ?? "";
582
+ if (!IMAGE_MEDIA_TYPES.has(mediaType)) {
583
+ throw new CliError(
584
+ `unsupported image media_type '${mediaType}' (png, jpeg, webp, or gif)`,
585
+ );
586
+ }
587
+ if (bytes.length > MAX_IMAGE_ATTACHMENT_BYTES) {
588
+ throw new CliError(
589
+ `image attachments are limited to ${MAX_IMAGE_ATTACHMENT_BYTES / (1024 * 1024)} MB each`,
590
+ );
591
+ }
592
+ images.push({ media_type: mediaType, data });
593
+ const meta = { media_type: mediaType };
594
+ if (mediaDir) {
595
+ meta.id = `${randomBytes(8).toString("hex")}.${IMAGE_EXTENSIONS[mediaType]}`;
596
+ await mkdir(mediaDir, { recursive: true });
597
+ await writeFile(path.join(mediaDir, meta.id), bytes);
598
+ }
599
+ imageMeta.push(meta);
600
+ } else if (kind === "file") {
601
+ if (!workspaceDir) {
602
+ throw new CliError(
603
+ "file attachments need a local workspace — the remote dev draft has none; send images instead",
604
+ );
605
+ }
606
+ if (bytes.length > MAX_FILE_ATTACHMENT_BYTES) {
607
+ throw new CliError(
608
+ `file attachments are limited to ${MAX_FILE_ATTACHMENT_BYTES / (1024 * 1024)} MB each`,
609
+ );
610
+ }
611
+ const storedName = `${randomBytes(4).toString("hex")}-${sanitizeAttachmentName(entry?.name)}`;
612
+ const target = path.join(workspaceDir, "attachments", storedName);
613
+ await mkdir(path.dirname(target), { recursive: true });
614
+ await writeFile(target, bytes);
615
+ notes.push(`[attached file: attachments/${storedName}]`);
616
+ fileMeta.push({ name: entry?.name ?? storedName, path: `attachments/${storedName}` });
617
+ } else {
618
+ throw new CliError("attachment kind must be 'image' or 'file'");
619
+ }
620
+ }
621
+ return { images, notes, fileMeta, imageMeta };
622
+ }
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // Workspace lanes (files stay files)
626
+ // ---------------------------------------------------------------------------
627
+
628
+ /**
629
+ * Two lanes, two roots.
630
+ *
631
+ * `source` is the PACKAGE — the tree the developer authors, terminus.json and
632
+ * AGENT.md included, and the only lane they may write. It is deliberately not
633
+ * the staged copy: editing a copy that is rebuilt on every reload would throw
634
+ * the edit away.
635
+ *
636
+ * `workspace` is the AGENT's own state: its files at the root, output/
637
+ * deliverables, attachments/ from chat, and memory/ notes. Read-only here,
638
+ * because it is the result of a run, not its input. (Session history is
639
+ * not a workspace concern — it lives in dev.db, behind the History
640
+ * button.) The package stages read-only IN PLACE at the same root, but
641
+ * those staged paths are deliberately NOT listed — they are runtime
642
+ * plumbing, byte-identical to the Source lane (still readable file-by-file
643
+ * so a path the agent mentions resolves when clicked).
644
+ */
645
+ const LANES = ["source", "workspace"];
646
+ const WRITABLE_LANES = new Set(["source"]);
647
+
648
+ /**
649
+ * Never listed under source: the harness's own staging, VCS internals,
650
+ * dependency trees, OS litter. Everything the developer authored is theirs.
651
+ *
652
+ * The same segments `apps.mjs` refuses to upload (IGNORED_SEGMENTS) — the
653
+ * lane and the package have to agree about what is the developer's work,
654
+ * or the explorer shows folders that can never be pushed. `.terminus-dev`
655
+ * and `.workspace` are where a dev's state lived before `.terminus/dev`:
656
+ * nothing writes them any more, but a folder that ran an older build still
657
+ * has one sitting there, and it is no more the package than `.terminus` is.
658
+ */
659
+ const SOURCE_SKIP = new Set([
660
+ TERMINUS_DIRECTORY, ".terminus-dev", ".workspace", ".git", "node_modules", ".DS_Store",
661
+ ]);
662
+
663
+ /** The staged package paths (the reserved names), from the live local dev;
664
+ * empty when the dev has not started or runs remote. */
665
+ function stagedPackagePaths(hub) {
666
+ return hub.local?.reservedPaths ?? [];
667
+ }
668
+
669
+ function laneRoot(hub, lane) {
670
+ if (lane === "source") return hub.dir;
671
+ if (lane === "workspace") return hub.workspace;
672
+ throw new CliError(`unknown lane '${lane}'`);
673
+ }
674
+
675
+ function laneFilePath(hub, lane, raw) {
676
+ const root = laneRoot(hub, lane);
677
+ const virtual = String(raw ?? "").replace(/^\/+/, "");
678
+ const parts = virtual.split("/");
679
+ if (
680
+ !virtual
681
+ || virtual.includes("\\")
682
+ || parts.some((part) => part === "" || part === "." || part === "..")
683
+ ) {
684
+ throw new CliError("path must be a safe file inside the lane");
685
+ }
686
+ const target = path.join(root, ...parts);
687
+ // Belt and braces over the segment check: whatever the parts were, the
688
+ // resolved path has to land under the lane it claims.
689
+ const relative = path.relative(root, target);
690
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
691
+ throw new CliError("path escapes its lane");
692
+ }
693
+ return target;
694
+ }
695
+
696
+ async function listLane(root, skip) {
697
+ const entries = [];
698
+ const walk = async (dir, prefix) => {
699
+ let children;
700
+ try {
701
+ children = await readdir(dir, { withFileTypes: true });
702
+ } catch {
703
+ return;
704
+ }
705
+ for (const child of children) {
706
+ if (skip?.has(child.name)) continue;
707
+ const virtual = prefix ? `${prefix}/${child.name}` : child.name;
708
+ const full = path.join(dir, child.name);
709
+ if (child.isDirectory()) {
710
+ await walk(full, virtual);
711
+ } else if (entries.length < 2_000) {
712
+ const info = await stat(full).catch(() => null);
713
+ entries.push({ path: virtual, size: info?.size ?? 0, mtime: info?.mtimeMs ?? 0 });
714
+ }
715
+ }
716
+ };
717
+ await walk(root, "");
718
+ entries.sort((a, b) => a.path.localeCompare(b.path));
719
+ return entries;
720
+ }
721
+
722
+ export async function openWithPlatform(target) {
723
+ const command =
724
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
725
+ const args = process.platform === "win32" ? ["/c", "start", "", target] : [target];
726
+ return new Promise((resolve) => {
727
+ try {
728
+ const child = execFile(command, args, { detached: true, stdio: "ignore" }, (error) => {
729
+ resolve(!error);
730
+ });
731
+ child.unref();
732
+ } catch {
733
+ resolve(false);
734
+ }
735
+ });
736
+ }
737
+
738
+ // ---------------------------------------------------------------------------
739
+ // The server
740
+ // ---------------------------------------------------------------------------
741
+
742
+ function sseStart(response) {
743
+ response.writeHead(200, {
744
+ "content-type": "text/event-stream",
745
+ "cache-control": "no-cache, no-transform",
746
+ // SSE dies behind buffering; nothing here compresses, and this header
747
+ // keeps any local proxy honest too.
748
+ "x-accel-buffering": "no",
749
+ connection: "keep-alive",
750
+ });
751
+ response.write(":ok\n\n");
752
+ return {
753
+ send(frame) {
754
+ response.write(`data: ${JSON.stringify(frame)}\n\n`);
755
+ },
756
+ end() {
757
+ response.end();
758
+ },
759
+ };
760
+ }
761
+
762
+ async function readJsonBody(request) {
763
+ const chunks = [];
764
+ let size = 0;
765
+ for await (const chunk of request) {
766
+ size += chunk.length;
767
+ if (size > BODY_LIMIT_BYTES) {
768
+ throw new CliError("request body is too large");
769
+ }
770
+ chunks.push(chunk);
771
+ }
772
+ if (!size) return {};
773
+ try {
774
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
775
+ } catch {
776
+ throw new CliError("the request body is not valid JSON");
777
+ }
778
+ }
779
+
780
+ export function timingSafeTokenMatch(expected, presented) {
781
+ if (typeof presented !== "string" || presented.length !== expected.length) return false;
782
+ let mismatch = 0;
783
+ for (let index = 0; index < expected.length; index += 1) {
784
+ mismatch |= expected.charCodeAt(index) ^ presented.charCodeAt(index);
785
+ }
786
+ return mismatch === 0;
787
+ }
788
+
789
+ let webhookTokenWrites = Promise.resolve();
790
+
791
+ /** Each declared webhook's local delivery token, made on first sight.
792
+ *
793
+ * The local ingress mirrors the platform's `POST /v1/hooks/{token}`: 32
794
+ * random bytes that are the whole capability, so a tunnel can deliver under
795
+ * its own Host, while a web page, which cannot read the token, cannot fire
796
+ * the hook. The tokens live beside the dev state, per folder, so a provider
797
+ * pointed at one keeps working across restarts; deleting the file rotates
798
+ * them. Writes are queued so two first sights cannot mint two tokens. */
799
+ export function webhookTokens(devRoot, names) {
800
+ const pending = webhookTokenWrites.then(async () => {
801
+ const file = path.join(devRoot, "webhook-tokens.json");
802
+ let tokens = {};
803
+ try {
804
+ const saved = JSON.parse(await readFile(file, "utf8"));
805
+ if (saved && typeof saved === "object" && !Array.isArray(saved)) tokens = saved;
806
+ } catch {
807
+ // Not made yet, or unreadable: start again, which rotates every token.
808
+ }
809
+ let minted = false;
810
+ for (const name of names) {
811
+ if (typeof tokens[name] === "string" && /^[0-9a-f]{64}$/.test(tokens[name])) continue;
812
+ tokens[name] = randomBytes(32).toString("hex");
813
+ minted = true;
814
+ }
815
+ if (minted) await writeFile(file, `${JSON.stringify(tokens, null, 2)}\n`, { mode: 0o600 });
816
+ return tokens;
817
+ });
818
+ webhookTokenWrites = pending.catch(() => {});
819
+ return pending;
820
+ }
821
+
822
+ class DevHub {
823
+ constructor({ dir, flags, api }) {
824
+ this.dir = dir;
825
+ this.flags = flags;
826
+ this.api = api;
827
+ this.remote = Boolean(flags.remote);
828
+ this.devRoot = path.join(dir, DEV_DIRECTORY, "agent");
829
+ this.workspace = path.join(this.devRoot, "workspace");
830
+ this.mediaDir = path.join(this.devRoot, "media");
831
+ this.db = null;
832
+ this.local = null;
833
+ this.remoteDev = null;
834
+ this.remoteInfo = null;
835
+ this.compiled = null;
836
+ this.manifest = null;
837
+ this.model = null;
838
+ /** The reader's model pick for this dev run, distinct from the
839
+ * developer's `--model` flag. Never written to terminus.json. */
840
+ this.readerModel = null;
841
+ /** The reload in flight, so the next one waits rather than racing it. */
842
+ this.reloading = null;
843
+ /** Set by stop(); a stopped hub never starts another engine. */
844
+ this.stopped = false;
845
+ /** The live model catalog, fetched once when a manifest says "*". */
846
+ this.catalog = null;
847
+ /** Where the agent's mark comes from: "remote" (the linked artifact's own
848
+ * icon), "staged" (icon.png beside terminus.json, pre-first-push), or
849
+ * "default" (the house robot). Resolved on every reload. */
850
+ this.icon = { source: "default", appId: null };
851
+ /** The PUBLISHED version, or null while the agent has never shipped. The
852
+ * dev always runs the local package; this says what its users have. */
853
+ this.publishedVersion = null;
854
+ this.busy = false;
855
+ this.pendingReload = false;
856
+ this.eventSubscribers = new Set();
857
+ this.turn = null;
858
+ /** Re-mints the local engine's runtime token before it expires. */
859
+ this.credentialTimer = null;
860
+ }
861
+
862
+ broadcast(frame) {
863
+ for (const subscriber of this.eventSubscribers) {
864
+ try {
865
+ subscriber.send(frame);
866
+ } catch {
867
+ this.eventSubscribers.delete(subscriber);
868
+ }
869
+ }
870
+ }
871
+
872
+ activeSessionId() {
873
+ return this.remote ? this.remoteDev?.sessionId ?? null : this.local?.sessionId ?? null;
874
+ }
875
+
876
+ /** The local engine announces its session id on its first stdout frame,
877
+ * just after spawn — wait briefly rather than racing it. */
878
+ async waitForEngineSession(timeoutMs = 5_000) {
879
+ const startedAt = Date.now();
880
+ while (!this.activeSessionId() && Date.now() - startedAt < timeoutMs) {
881
+ await new Promise((resolve) => setTimeout(resolve, 20));
882
+ }
883
+ return this.activeSessionId();
884
+ }
885
+
886
+ spend() {
887
+ const spend = this.remote ? this.remoteDev?.spend : this.local?.spend;
888
+ return spend ?? { tokens: 0, costUsd: 0 };
889
+ }
890
+
891
+ /** Compile info is served by the compile door on both substrates — the
892
+ * remote gear still shows the same toolset panels. */
893
+ async compileInfo() {
894
+ const pkg = await readAppPackage(this.dir, { includeSource: false });
895
+ if (pkg.manifest.kind !== "agent") {
896
+ throw new CliError(
897
+ `terminus dev chat is for kind 'agent' packages (found '${pkg.manifest.kind}')`,
898
+ );
899
+ }
900
+ this.manifest = pkg.manifest;
901
+ this.compiled = await this.api.json("POST /v1/agent-dev/compile", { body: pkg.manifest });
902
+ // A pick only stands while the agent still offers a choice that
903
+ // includes it — pinning the agent, or dropping the model from the
904
+ // allowlist, returns every reader to the default.
905
+ if (this.readerModel) {
906
+ const offered = await this.selectableModels();
907
+ if (offered.length < 2 || !offered.includes(this.readerModel)) this.readerModel = null;
908
+ }
909
+ this.model = this.readerModel ?? this.flags.model ?? this.compiled.model;
910
+ return this.compiled;
911
+ }
912
+
913
+ /** What the linked artifact says about itself: its mark and its published
914
+ * version. One listing answers both, so they are resolved together.
915
+ *
916
+ * The mark: the linked artifact wins, because Studio and the dev then look
917
+ * at ONE object and a change in either shows in both; a staged file only
918
+ * speaks for a package with no remote yet. Never fatal — an agent with
919
+ * neither still runs, wearing the robot.
920
+ */
921
+ /** The `id` the developer wrote in terminus.json — the link to a published
922
+ * artifact — read from the file itself. */
923
+ async linkedAddress() {
924
+ try {
925
+ const raw = await readFile(path.join(this.dir, "terminus.json"), "utf8");
926
+ return String(JSON.parse(raw).id ?? "").trim();
927
+ } catch {
928
+ return "";
929
+ }
930
+ }
931
+
932
+ async resolveIdentity() {
933
+ const next = { source: "default", appId: null };
934
+ let published = null;
935
+ // From the RAW manifest, not this.manifest: readAppPackage normalizes, and
936
+ // normalization moves the address off the manifest onto pkg.id — so the
937
+ // link is invisible to anything reading the compiled shape.
938
+ const address = (await this.linkedAddress()).toLowerCase();
939
+ if (address) {
940
+ try {
941
+ // The platform's one address door: the creation, yours or shared
942
+ // with you, drafts included — null when there is none.
943
+ const app = await resolveCreation(this.api, address);
944
+ if (app?.id) {
945
+ next.appId = app.id;
946
+ if (app.icon_url) next.source = "remote";
947
+ if (app.status === "published") {
948
+ // The platform's own display rule (routes/apps/releases.rs
949
+ // version_display): the manifest's semver label when a release
950
+ // carries one, else the integer release counter.
951
+ const label = String(app.release_label ?? "").trim();
952
+ published = label
953
+ || (app.release_version == null ? null : String(app.release_version));
954
+ }
955
+ }
956
+ } catch {
957
+ // Offline, or not logged in: a package we cannot ask about is a draft
958
+ // wearing whatever it carries locally.
959
+ }
960
+ }
961
+ if (next.source === "default" && await readStagedIcon(this.dir)) next.source = "staged";
962
+ this.icon = next;
963
+ this.publishedVersion = published;
964
+ }
965
+
966
+ /** The bytes to paint, or null for "draw the robot". */
967
+ async iconBytes() {
968
+ if (this.icon.source === "remote" && this.icon.appId) {
969
+ try {
970
+ const bytes = await this.api.bytes("GET /v1/apps/{app_id}/icon", { params: { app_id: this.icon.appId } });
971
+ // The platform stores only PNG, JPEG, and WebP marks, sniffed on upload.
972
+ const sniffed = sniffImage(bytes);
973
+ if (sniffed) return { bytes, mediaType: sniffed.mediaType };
974
+ } catch {
975
+ // Fall through: a mark we cannot fetch is a mark we do not have.
976
+ }
977
+ }
978
+ const staged = await readStagedIcon(this.dir);
979
+ return staged ? { bytes: staged.bytes, mediaType: staged.mediaType } : null;
980
+ }
981
+
982
+ /** A mark chosen in the dev. Linked packages write STRAIGHT to the
983
+ * artifact — icons are never release-pinned, so it is live at once and needs
984
+ * no republish. An unlinked package stages the file instead, and the first
985
+ * push carries it up. */
986
+ async setIcon(bytes) {
987
+ if (bytes.length > MAX_ICON_BYTES) {
988
+ throw new CliError("the icon must be 512KB or smaller");
989
+ }
990
+ const sniffed = sniffImage(bytes);
991
+ if (!sniffed) throw new CliError("the icon must be a PNG, JPEG, or WebP image");
992
+ if (this.icon.appId) {
993
+ try {
994
+ await this.api.request("PUT /v1/apps/{app_id}/icon", {
995
+ params: { app_id: this.icon.appId },
996
+ raw: { data: bytes, type: sniffed.mediaType },
997
+ as: "none",
998
+ });
999
+ } catch (error) {
1000
+ throw new CliError(
1001
+ `the platform refused the icon (${error.status ?? error.message}) — it stays as it was`,
1002
+ );
1003
+ }
1004
+ this.icon = { ...this.icon, source: "remote" };
1005
+ return { source: "remote" };
1006
+ }
1007
+ // No remote to hold it yet: stage beside terminus.json, replacing whatever
1008
+ // extension was there so a package never carries two marks.
1009
+ for (const name of STAGED_ICON_NAMES) {
1010
+ if (name === `icon.${sniffed.extension}`) continue;
1011
+ await rm(path.join(this.dir, name), { force: true }).catch(() => {});
1012
+ }
1013
+ await writeFile(path.join(this.dir, `icon.${sniffed.extension}`), bytes);
1014
+ this.icon = { ...this.icon, source: "staged" };
1015
+ return { source: "staged" };
1016
+ }
1017
+
1018
+ async start() {
1019
+ // The dev folder ignores itself before anything is written into it.
1020
+ await ensureDevDirectory(this.dir);
1021
+ this.db = await openDevDb(path.join(this.devRoot, "dev.db"));
1022
+ if (this.remote) {
1023
+ await this.compileInfo();
1024
+ this.remoteDev = new AgentDevSession(this.api);
1025
+ this.remoteInfo = await this.remoteDev.start(this.dir, this.flags.model);
1026
+ this.model = this.remoteInfo.model || this.model;
1027
+ this.db.ensureSession(this.remoteDev.sessionId, this.model, "remote");
1028
+ } else {
1029
+ this.local = new LocalDev(this.dir, this.flags, this.api, {
1030
+ onFrame: (frame) => this.onEngineFrame(frame),
1031
+ });
1032
+ const started = await this.local.start();
1033
+ this.compiled = started.compiled;
1034
+ this.manifest = this.local.manifest;
1035
+ this.model = this.local.model;
1036
+ if (this.local.sessionId) this.db.ensureSession(this.local.sessionId, this.model, "local");
1037
+ }
1038
+ // AFTER the substrate branch, for the same reason reload() does it here:
1039
+ // compileInfo() is a REMOTE-only path, so anything hung off it never runs
1040
+ // for the default substrate.
1041
+ await this.resolveIdentity();
1042
+ this.armCredentialRefresh();
1043
+ }
1044
+
1045
+ /**
1046
+ * The local engine's runtime token is minted per start with a ~2h life,
1047
+ * and agentd cannot refresh it by design — the host owns reauthentication.
1048
+ * A dev left open past the expiry therefore watched every brokered tool
1049
+ * fail with "the backend rejected the runtime token". So the hub reloads
1050
+ * the engine (which re-mints, and resumes the conversation by session id)
1051
+ * comfortably before the deadline; the busy guard inside reload() defers
1052
+ * it past a running turn.
1053
+ */
1054
+ armCredentialRefresh() {
1055
+ clearTimeout(this.credentialTimer);
1056
+ if (this.remote) return;
1057
+ const expiresAt = this.local?.runtimeExpiresAt;
1058
+ if (!expiresAt) return;
1059
+ const lead = 10 * 60 * 1000;
1060
+ // Clamp above Node's 2^31-1ms setTimeout ceiling: a longer delay would
1061
+ // overflow and fire IMMEDIATELY, turning a distant expiry into a reload
1062
+ // loop. A token further out than the ceiling simply never needs the timer.
1063
+ const wait = Math.min(Math.max(expiresAt - Date.now() - lead, 30_000), 2_147_483_647);
1064
+ this.credentialTimer = setTimeout(() => {
1065
+ this.reload().catch((error) => {
1066
+ this.broadcast({ type: "engine_log", text: `credential refresh failed: ${error.message}` });
1067
+ });
1068
+ }, wait);
1069
+ this.credentialTimer.unref?.();
1070
+ }
1071
+
1072
+ /** Recompile + restart the engine. Local keeps the conversation (resume by
1073
+ * session id); remote re-pushes the draft and starts a fresh one — the
1074
+ * draft session's semantics, unchanged from v1. */
1075
+ /**
1076
+ * Reloads run ONE at a time. Settings write per change, so two edits a
1077
+ * moment apart would otherwise assemble the dev workspace twice at once —
1078
+ * one run deleting the files the other is still chmod-ing (ENOENT).
1079
+ */
1080
+ async reload() {
1081
+ const queued = (this.reloading ?? Promise.resolve()).catch(() => {}).then(() => this.reloadOnce());
1082
+ this.reloading = queued.catch(() => {});
1083
+ return queued;
1084
+ }
1085
+
1086
+ async reloadOnce() {
1087
+ if (this.stopped) return { stopped: true };
1088
+ if (this.busy) {
1089
+ this.pendingReload = true;
1090
+ return { deferred: true };
1091
+ }
1092
+ if (this.remote) {
1093
+ await this.remoteDev?.stop();
1094
+ await this.compileInfo();
1095
+ this.remoteDev = new AgentDevSession(this.api);
1096
+ this.remoteInfo = await this.remoteDev.start(this.dir, this.flags.model);
1097
+ this.model = this.remoteInfo.model || this.model;
1098
+ this.db.ensureSession(this.remoteDev.sessionId, this.model, "remote");
1099
+ } else {
1100
+ await this.local.stop();
1101
+ // A pick only stands while the agent still offers a choice that
1102
+ // includes it, and the engine restarts ON it — the same rule
1103
+ // compileInfo() applies for the remote substrate.
1104
+ if (this.readerModel) {
1105
+ const offered = await this.selectableModels();
1106
+ if (offered.length < 2 || !offered.includes(this.readerModel)) this.readerModel = null;
1107
+ }
1108
+ this.local.modelOverride = this.readerModel;
1109
+ const started = await this.local.start();
1110
+ this.compiled = started.compiled;
1111
+ this.manifest = this.local.manifest;
1112
+ this.model = this.local.model;
1113
+ }
1114
+ // The mark can change between reloads — a staged file added or deleted, or
1115
+ // an icon set in Studio — so it is re-resolved every time, not cached from
1116
+ // the first compile.
1117
+ await this.resolveIdentity();
1118
+ this.armCredentialRefresh();
1119
+ this.broadcast({ type: "reload", model: this.model, session_id: this.activeSessionId() });
1120
+ this.broadcast({ type: "icon", source: this.icon.source });
1121
+ return { deferred: false };
1122
+ }
1123
+
1124
+ onEngineFrame(frame) {
1125
+ if (frame.type === "session_started") {
1126
+ if (typeof frame.session_id === "string") {
1127
+ this.db?.ensureSession(frame.session_id, this.model, "local");
1128
+ }
1129
+ return;
1130
+ }
1131
+ // The reactive half of the credential contract: if a rejection slipped
1132
+ // through anyway (clock skew, a laptop asleep past the timer), re-mint
1133
+ // via reload so the NEXT call works without a manual restart. While a
1134
+ // turn runs this defers through pendingReload, so it fires once the
1135
+ // failing turn settles.
1136
+ if (
1137
+ frame.type === "tool_result"
1138
+ && String(frame.output ?? frame.content ?? "").includes("rejected the runtime token")
1139
+ ) {
1140
+ this.broadcast({
1141
+ type: "engine_log",
1142
+ text: "runtime credential expired — re-minting and reloading the engine",
1143
+ });
1144
+ this.reload().catch(() => {});
1145
+ }
1146
+ this.turn?.onFrame(frame);
1147
+ }
1148
+
1149
+ /** Run one turn: mirror the user message, stream frames to the response,
1150
+ * aggregate for the mirror, settle the session row. */
1151
+ async sendMessage({ text, attachments }, stream) {
1152
+ if (this.busy) throw new CliError("a turn is already running — interrupt it first");
1153
+ this.busy = true;
1154
+ const substrate = this.remote ? "remote" : "local";
1155
+ try {
1156
+ const prepared = await prepareAttachments(
1157
+ attachments,
1158
+ this.remote ? null : this.workspace,
1159
+ this.mediaDir,
1160
+ );
1161
+ let fullText = String(text ?? "").trim();
1162
+ for (const note of prepared.notes) {
1163
+ fullText = fullText ? `${fullText}\n\n${note}` : note;
1164
+ }
1165
+ if (!fullText && !prepared.images.length) {
1166
+ throw new CliError("say something (or attach something)");
1167
+ }
1168
+ const sessionId = await this.waitForEngineSession();
1169
+ if (!sessionId) throw new CliError("the dev engine is not running");
1170
+ this.db.ensureSession(sessionId, this.model, substrate);
1171
+ this.db.insertMessage(sessionId, "user", fullText, {
1172
+ images: prepared.imageMeta,
1173
+ files: prepared.fileMeta,
1174
+ });
1175
+ this.db.touchSession(sessionId, { title: fullText || "(attachment)" });
1176
+
1177
+ const aggregate = {
1178
+ text: "", tools: [], plan: null, cards: [], error: null, tokens: 0, costUsd: 0,
1179
+ };
1180
+ this.turn = {
1181
+ onFrame: (frame) => {
1182
+ let followUp = null;
1183
+ switch (frame.type) {
1184
+ case "delta":
1185
+ if (typeof frame.text === "string") aggregate.text += frame.text;
1186
+ break;
1187
+ case "tool_call": {
1188
+ const warnings = this.remote ? [] : compatWarningsForToolCall(frame);
1189
+ let input = "";
1190
+ try {
1191
+ input = JSON.stringify(frame.input ?? {});
1192
+ } catch {
1193
+ input = "";
1194
+ }
1195
+ aggregate.tools.push({
1196
+ name: frame.name ?? "?",
1197
+ // ToolCall frames carry `id`; ToolResult echoes it back as
1198
+ // `tool_use_id`.
1199
+ tool_use_id: frame.id,
1200
+ input: input.slice(0, TOOL_INPUT_MIRROR_CAP),
1201
+ startedAt: Date.now(),
1202
+ warnings,
1203
+ });
1204
+ if (warnings.length) {
1205
+ // After the tool_call frame itself, so the UI can attach the
1206
+ // warnings to the row it just rendered.
1207
+ followUp = { type: "compat_warning", tool: frame.name, warnings };
1208
+ }
1209
+ break;
1210
+ }
1211
+ case "tool_result": {
1212
+ const target =
1213
+ aggregate.tools.find((tool) => tool.tool_use_id === frame.tool_use_id)
1214
+ ?? aggregate.tools.findLast((tool) => tool.name === frame.name);
1215
+ if (target) {
1216
+ // The engine reports duration_ms itself; the wall-clock diff
1217
+ // only covers older agentd builds that don't.
1218
+ const ms = Number(frame.duration_ms);
1219
+ if (Number.isFinite(ms) && ms > 0) target.ms = ms;
1220
+ else if (target.startedAt) target.ms = Date.now() - target.startedAt;
1221
+ delete target.startedAt;
1222
+ if (frame.is_error === true) {
1223
+ target.is_error = true;
1224
+ const detail = String(frame.output ?? frame.content ?? "");
1225
+ target.error = detail.split("\n")[0].slice(0, 300);
1226
+ }
1227
+ }
1228
+ break;
1229
+ }
1230
+ case "plan_update":
1231
+ aggregate.plan = Array.isArray(frame.steps) ? frame.steps : null;
1232
+ break;
1233
+ case "host_event":
1234
+ // send_file's card: keep it in the durable transcript so a
1235
+ // reload still shows what was delivered.
1236
+ if (frame.event?.type === "file_card") aggregate.cards.push(frame.event);
1237
+ break;
1238
+ case "usage": {
1239
+ aggregate.tokens += (frame.input_tokens ?? 0) + (frame.output_tokens ?? 0);
1240
+ const billed = Number.parseFloat(frame.billed_amount_usd);
1241
+ const cost = Number.parseFloat(
1242
+ Number.isFinite(billed) && billed > 0
1243
+ ? frame.billed_amount_usd
1244
+ : frame.est_provider_cost_usd,
1245
+ );
1246
+ if (Number.isFinite(cost)) aggregate.costUsd += cost;
1247
+ break;
1248
+ }
1249
+ case "error":
1250
+ aggregate.error = frame.message ?? "the turn failed";
1251
+ break;
1252
+ default:
1253
+ break;
1254
+ }
1255
+ stream.send(frame);
1256
+ if (followUp) stream.send(followUp);
1257
+ },
1258
+ };
1259
+ try {
1260
+ if (this.remote) {
1261
+ await this.remoteDev.send(fullText, prepared.images, (frame) =>
1262
+ this.turn.onFrame(frame),
1263
+ );
1264
+ } else {
1265
+ await this.local.send(fullText, prepared.images);
1266
+ }
1267
+ } catch (error) {
1268
+ if (!aggregate.error) aggregate.error = error?.message ?? "the turn failed";
1269
+ stream.send({ type: "error", message: aggregate.error });
1270
+ }
1271
+ if (aggregate.text || aggregate.tools.length) {
1272
+ this.db.insertMessage(sessionId, "assistant", aggregate.text, {
1273
+ tools: aggregate.tools,
1274
+ plan: aggregate.plan ?? undefined,
1275
+ cards: aggregate.cards.length ? aggregate.cards : undefined,
1276
+ });
1277
+ }
1278
+ if (aggregate.error) {
1279
+ this.db.insertMessage(sessionId, "error", aggregate.error, null);
1280
+ }
1281
+ this.db.touchSession(sessionId, {
1282
+ tokens: aggregate.tokens,
1283
+ costUsd: aggregate.costUsd,
1284
+ });
1285
+ stream.send({
1286
+ type: "done",
1287
+ session_id: sessionId,
1288
+ tokens: this.spend().tokens,
1289
+ cost_usd: this.spend().costUsd,
1290
+ });
1291
+ // Trigger simulation chains turns on the step results
1292
+ // (`steps.N.result.text`), so the aggregate text is the return value.
1293
+ return aggregate.text;
1294
+ } finally {
1295
+ this.turn = null;
1296
+ this.busy = false;
1297
+ if (this.pendingReload) {
1298
+ this.pendingReload = false;
1299
+ this.reload().catch((error) => {
1300
+ this.broadcast({ type: "engine_log", text: `reload failed: ${error.message}` });
1301
+ });
1302
+ }
1303
+ }
1304
+ }
1305
+
1306
+ interrupt() {
1307
+ if (this.remote) this.remoteDev?.interrupt();
1308
+ else this.local?.interrupt();
1309
+ }
1310
+
1311
+ async connectorStatuses() {
1312
+ const slugs = this.compiled?.grants?.connectors ?? [];
1313
+ if (!slugs.length) return [];
1314
+ try {
1315
+ const status = await this.api.json("POST /v1/connectors/status", { body: { slugs } });
1316
+ return status.connectors ?? [];
1317
+ } catch {
1318
+ return slugs.map((slug) => ({ slug, status: "unknown" }));
1319
+ }
1320
+ }
1321
+
1322
+ /** What a reader of this agent may pick between: the pinned default plus
1323
+ * the manifest's allowlist. A pinned agent offers exactly one. Mirrors the
1324
+ * platform's `allowed_models()`. */
1325
+ /**
1326
+ * The mode a `models` list declares: "all" | "selective" | "default".
1327
+ *
1328
+ * Takes the list rather than reading `this.manifest`, which is only
1329
+ * refreshed on reload — so right after a manifest write it still holds the
1330
+ * PREVIOUS value, and the panel would report the mode one edit behind.
1331
+ */
1332
+ /** The raw manifest as AUTHORED. readAppPackage normalizes, which moves and
1333
+ * renames things; the model policy has to be read from the file. */
1334
+ async authoredManifest() {
1335
+ try {
1336
+ return JSON.parse(await readFile(path.join(this.dir, "terminus.json"), "utf8"));
1337
+ } catch {
1338
+ return {};
1339
+ }
1340
+ }
1341
+
1342
+ async selectableModels() {
1343
+ const section = readModelSection(await this.authoredManifest());
1344
+ const seen = [];
1345
+ const add = (model) => {
1346
+ const trimmed = String(model ?? "").trim();
1347
+ if (trimmed && !seen.includes(trimmed)) seen.push(trimmed);
1348
+ };
1349
+ add(this.compiled?.model);
1350
+ if (section.mode === "all") {
1351
+ // The same resolution the platform performs, so the reader's picker here
1352
+ // offers exactly what a visitor would be offered.
1353
+ for (const model of await this.platformModels()) add(model);
1354
+ return seen;
1355
+ }
1356
+ for (const model of section.available) add(model);
1357
+ return seen;
1358
+ }
1359
+
1360
+ /** The live catalog, cached for the run: resolving "all" should not cost a
1361
+ * round trip on every turn. */
1362
+ async platformModels() {
1363
+ if (this.catalog) return this.catalog;
1364
+ try {
1365
+ const data = await this.api.json("GET /v1/terminus/models");
1366
+ const list = Array.isArray(data) ? data : data.models ?? data.data ?? [];
1367
+ this.catalog = list
1368
+ .map((model) => String(model.slug ?? model.id ?? model.model ?? "").trim())
1369
+ .filter(Boolean);
1370
+ } catch {
1371
+ this.catalog = [];
1372
+ }
1373
+ return this.catalog;
1374
+ }
1375
+
1376
+ async devInfo() {
1377
+ const manifest = this.manifest ?? {};
1378
+ // The Setup panel edits the AUTHORED tools list. readAppPackage
1379
+ // normalizes the sugar into `capabilities`, so read the raw ground
1380
+ // truth back from terminus.json.
1381
+ const authored = await this.authoredManifest();
1382
+ const manifestTools = Array.isArray(authored.tools) ? authored.tools : [];
1383
+ const modelSection = readModelSection(authored);
1384
+ const manifestModels = modelSection.available;
1385
+ return {
1386
+ substrate: this.remote ? "remote" : "local",
1387
+ agent: {
1388
+ name: manifest.name ?? path.basename(this.dir),
1389
+ slug: manifest.slug ?? "",
1390
+ description: manifest.description ?? "",
1391
+ // "default" means the page draws the robot; anything else means it can
1392
+ // GET /api/agent-icon. icon_linked decides whether a new mark goes live
1393
+ // at once or waits for the first push.
1394
+ icon: this.icon.source,
1395
+ icon_linked: Boolean(this.icon.appId),
1396
+ // null until the agent has shipped; the dev always runs the LOCAL
1397
+ // package, which is why both versions are reported.
1398
+ published_version: this.publishedVersion,
1399
+ local_version: manifest.version ?? null,
1400
+ // "default" | "observed", as the file says it — the Type switch.
1401
+ type: readAgentType(authored),
1402
+ },
1403
+ model: this.model,
1404
+ // What the FILE says, which a reader's pick above does not change — the
1405
+ // Settings panel configures terminus.json, not this run.
1406
+ model_default: modelSection.default || this.compiled?.model || null,
1407
+ session_id: this.activeSessionId(),
1408
+ tools: (this.compiled?.tools ?? []).map((tool) => ({
1409
+ name: tool.name,
1410
+ description: (tool.description ?? "").slice(0, 400),
1411
+ })),
1412
+ manifest_tools: manifestTools,
1413
+ // The list only matters under `selective`; the mode is the setting.
1414
+ manifest_models: manifestModels,
1415
+ model_policy: modelSection.mode,
1416
+ discovery: readDiscovery(authored),
1417
+ grants: this.compiled?.grants ?? {},
1418
+ budgets: this.compiled?.budgets ?? {},
1419
+ connectors: await this.connectorStatuses(),
1420
+ spend: this.spend(),
1421
+ remote: this.remote
1422
+ ? { address: this.remoteInfo?.address, revision: this.remoteInfo?.revision }
1423
+ : null,
1424
+ };
1425
+ }
1426
+
1427
+ /** One simulated trigger firing (standing-agents P4): input built the way
1428
+ * the platform builds it, templates resolved the same way, agent.run steps
1429
+ * as real turns on this dev with the platform's untrusted-event fence.
1430
+ * A manual fire is a test, so an unchanged watch still fires — with a
1431
+ * notice saying so. */
1432
+ async fireTrigger({ name, payload }, stream) {
1433
+ if (this.remote) {
1434
+ throw new CliError("trigger simulation runs on the local dev — restart without --remote");
1435
+ }
1436
+ const raw = JSON.parse(await readFile(path.join(this.dir, "terminus.json"), "utf8"));
1437
+ const trigger = listDevTriggers(raw).find((candidate) => candidate.name === name);
1438
+ if (!trigger) throw new CliError(`no declared trigger named '${name}'`);
1439
+ const built = await buildTriggerInput(trigger, { payload, devRoot: this.devRoot });
1440
+ stream.send({
1441
+ type: "trigger_fired",
1442
+ trigger: trigger.name,
1443
+ kind: trigger.kind,
1444
+ ...(built.baseline ? { baseline: true } : {}),
1445
+ ...(built.unchanged ? { unchanged: true } : {}),
1446
+ });
1447
+ if (built.baseline) {
1448
+ stream.send({
1449
+ type: "notice",
1450
+ message: `watch '${trigger.name}': first poll recorded the baseline — this firing tests the loop with the current content`,
1451
+ });
1452
+ } else if (built.unchanged) {
1453
+ stream.send({
1454
+ type: "notice",
1455
+ message: `watch '${trigger.name}': the source is unchanged — on the platform this would not fire`,
1456
+ });
1457
+ }
1458
+ await runTriggerFiring({
1459
+ manifest: raw,
1460
+ trigger,
1461
+ input: built.input,
1462
+ runTurn: (prompt) => {
1463
+ // Turn boundary for the transcript: the composed prompt (fence and
1464
+ // all) IS what the agent receives — seeing it is the point of a
1465
+ // dev.
1466
+ stream.send({ type: "trigger_turn", text: prompt });
1467
+ return this.sendMessage({ text: prompt, attachments: [] }, stream);
1468
+ },
1469
+ emit: (frame) => stream.send(frame),
1470
+ });
1471
+ }
1472
+
1473
+ async stop() {
1474
+ // A reload in flight has stopped the old engine and is starting a new
1475
+ // one; stopping now would find nothing to stop, and the new engine would
1476
+ // outlive the dev, keeping the process alive. So it lands first, and its
1477
+ // engine and credential timer are the ones stopped here.
1478
+ this.stopped = true;
1479
+ await this.reloading;
1480
+ clearTimeout(this.credentialTimer);
1481
+ if (this.remote) await this.remoteDev?.stop();
1482
+ else await this.local?.stop();
1483
+ // Null after close so a second stop (shutdown races, tests) is a no-op
1484
+ // instead of a double-close throw.
1485
+ this.db?.close();
1486
+ this.db = null;
1487
+ }
1488
+ }
1489
+
1490
+ /** Start the dev web server; resolves when it is listening. Exported for
1491
+ * tests (which drive it against a fake agentd and a mock API). */
1492
+ export async function startDevServer({ commandArgs, dir, flags, api }) {
1493
+ // A busy --port is reported before the engine starts, so it costs no
1494
+ // compile and no agentd, and says who holds the port.
1495
+ const basePort = resolveDevPortRange(flags.port, 1, 4720);
1496
+ if (flags.port !== undefined) {
1497
+ await assertDevPortRangeAvailable({ basePort, commandArgs, count: 1, directory: dir });
1498
+ }
1499
+ const hub = new DevHub({ dir, flags, api });
1500
+ await hub.start();
1501
+ const token = randomBytes(16).toString("hex");
1502
+ const webBase = normalizeBase(
1503
+ flags.web_base ?? process.env.TERMINUS_WEB_BASE ?? "https://www.terminus.build",
1504
+ );
1505
+
1506
+ const authorized = (request, url) => {
1507
+ const presented = request.headers["x-dev-token"] ?? url.searchParams.get("token");
1508
+ return timingSafeTokenMatch(token, presented) && isLoopbackOrigin(request);
1509
+ };
1510
+
1511
+ const server = createServer(async (request, response) => {
1512
+ const url = new URL(request.url, "http://127.0.0.1");
1513
+ const route = `${request.method} ${url.pathname}`;
1514
+ try {
1515
+ // Local webhook ingress (`POST /hooks/{token}`), the platform's shape:
1516
+ // outside the dev-token gate and the Host check, so curl or a
1517
+ // provider's redeliver button through a tunnel can exercise the whole
1518
+ // loop. The token (see webhookTokens) is the capability. The firing
1519
+ // runs in the background and its frames land on the dev event stream.
1520
+ const hook = request.method === "POST" && /^\/hooks\/([0-9a-f]{64})$/.exec(url.pathname);
1521
+ if (hook) {
1522
+ const raw = JSON.parse(await readFile(path.join(dir, "terminus.json"), "utf8"));
1523
+ const webhooks = listDevTriggers(raw).filter((candidate) => candidate.kind === "webhook");
1524
+ const tokens = await webhookTokens(hub.devRoot, webhooks.map(({ name }) => name));
1525
+ const trigger = webhooks.find((candidate) => timingSafeTokenMatch(tokens[candidate.name], hook[1]));
1526
+ if (!trigger) {
1527
+ response.writeHead(404, { "content-type": "application/json" });
1528
+ response.end(JSON.stringify({ error: "no declared webhook has this token" }));
1529
+ return;
1530
+ }
1531
+ const body = await readJsonBody(request).catch(() => ({}));
1532
+ if (hub.busy) {
1533
+ response.writeHead(429, { "content-type": "application/json" });
1534
+ response.end(JSON.stringify({ error: "a turn is already running" }));
1535
+ return;
1536
+ }
1537
+ const broadcastStream = { send: (frame) => hub.broadcast(frame), end: () => {} };
1538
+ void hub
1539
+ .fireTrigger({ name: trigger.name, payload: body }, broadcastStream)
1540
+ .catch((error) => hub.broadcast({ type: "error", message: error.message }));
1541
+ response.writeHead(202, { "content-type": "application/json" });
1542
+ response.end(JSON.stringify({ ok: true }));
1543
+ return;
1544
+ }
1545
+ // Everything else, the page with the dev token first, answers only
1546
+ // requests addressed to localhost (see isLoopbackHost).
1547
+ if (!isLoopbackHost(request)) return refuseForeignHost(request, response);
1548
+ if (url.pathname === DEV_ABOUT_PATH) {
1549
+ answerDevAbout(request, response, {
1550
+ kind: "agent",
1551
+ app: (await hub.linkedAddress()) || path.basename(path.resolve(dir)),
1552
+ directory: path.resolve(dir),
1553
+ remote: hub.remote,
1554
+ });
1555
+ return;
1556
+ }
1557
+ if (route === "GET /" || route === "GET /index.html") {
1558
+ const html = await readFile(path.join(uiDir, "index.html"), "utf8");
1559
+ response.writeHead(200, {
1560
+ "content-type": "text/html; charset=utf-8",
1561
+ "cache-control": "no-store",
1562
+ });
1563
+ response.end(
1564
+ html.replace(
1565
+ "__DEV_BOOT__",
1566
+ JSON.stringify({ token, webBase }).replaceAll("</", "<\\/"),
1567
+ ),
1568
+ );
1569
+ return;
1570
+ }
1571
+ if (request.method === "GET" && url.pathname.startsWith("/assets/")) {
1572
+ const name = url.pathname.slice("/assets/".length);
1573
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) {
1574
+ response.writeHead(404).end();
1575
+ return;
1576
+ }
1577
+ try {
1578
+ const bytes = await readFile(path.join(uiDir, name));
1579
+ // The page's own code must never be served stale. Without a header
1580
+ // the browser caches heuristically, and a dev running an old
1581
+ // bundle silently disagrees with the server it is talking to — the
1582
+ // hardest kind of bug to see, because everything LOOKS current.
1583
+ // Fonts and artwork are content-addressed by name and can cache.
1584
+ const code = /\.(js|mjs|css|html)$/i.test(name);
1585
+ response.writeHead(200, {
1586
+ "content-type": contentTypeFor(name),
1587
+ "cache-control": code ? "no-store" : "public, max-age=86400",
1588
+ });
1589
+ response.end(bytes);
1590
+ } catch {
1591
+ response.writeHead(404).end();
1592
+ }
1593
+ return;
1594
+ }
1595
+ if (!url.pathname.startsWith("/api/")) {
1596
+ response.writeHead(404).end();
1597
+ return;
1598
+ }
1599
+ if (!authorized(request, url)) {
1600
+ response.writeHead(401, { "content-type": "application/json" });
1601
+ response.end(JSON.stringify({ error: "dev token required" }));
1602
+ return;
1603
+ }
1604
+ const json = (status, body) => {
1605
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
1606
+ response.end(JSON.stringify(body));
1607
+ };
1608
+ switch (route) {
1609
+ case "GET /api/dev":
1610
+ return json(200, await hub.devInfo());
1611
+ // The reader's pick. `model` policy decides whether the page may
1612
+ // offer this at all; the server still refuses anything outside the
1613
+ // agent's own allowlist, because the door is the authority.
1614
+ case "POST /api/model": {
1615
+ const body = await readJsonBody(request);
1616
+ const wanted = String(body.model ?? "").trim();
1617
+ const allowed = await hub.selectableModels();
1618
+ if (!wanted) {
1619
+ hub.readerModel = null;
1620
+ } else if (allowed.includes(wanted)) {
1621
+ hub.readerModel = wanted;
1622
+ } else {
1623
+ throw new CliError("that model is not one this agent offers");
1624
+ }
1625
+ const result = await hub.reload();
1626
+ return json(200, { ok: true, ...result, dev: await hub.devInfo() });
1627
+ }
1628
+ case "POST /api/reload": {
1629
+ const result = await hub.reload();
1630
+ return json(200, { ok: true, ...result, model: hub.model });
1631
+ }
1632
+ case "POST /api/interrupt":
1633
+ hub.interrupt();
1634
+ return json(200, { ok: true });
1635
+ case "GET /api/events": {
1636
+ const stream = sseStart(response);
1637
+ hub.eventSubscribers.add(stream);
1638
+ request.on("close", () => hub.eventSubscribers.delete(stream));
1639
+ return undefined;
1640
+ }
1641
+ case "POST /api/messages": {
1642
+ const body = await readJsonBody(request);
1643
+ const stream = sseStart(response);
1644
+ try {
1645
+ await hub.sendMessage(body, stream);
1646
+ } catch (error) {
1647
+ stream.send({ type: "error", message: error.message });
1648
+ stream.send({ type: "done" });
1649
+ }
1650
+ stream.end();
1651
+ return undefined;
1652
+ }
1653
+ case "GET /api/triggers": {
1654
+ const raw = JSON.parse(await readFile(path.join(dir, "terminus.json"), "utf8"));
1655
+ const state = await loadWatchState(hub.devRoot);
1656
+ const port = server.address()?.port;
1657
+ const triggers = listDevTriggers(raw);
1658
+ const hookTokens = await webhookTokens(
1659
+ hub.devRoot,
1660
+ triggers.filter(({ kind }) => kind === "webhook").map(({ name }) => name),
1661
+ );
1662
+ // The Schedules tab shows what a firing DOES, so each trigger
1663
+ // carries a step summary from the automation it names.
1664
+ const automations = new Map(
1665
+ (effectiveWorkloads(raw).automations ?? []).map((automation) => [automation.name, automation]),
1666
+ );
1667
+ const stepSummary = (step) => ({
1668
+ action: step?.action ?? "?",
1669
+ summary: String(
1670
+ step?.action === "agent.run"
1671
+ ? step?.params?.prompt ?? ""
1672
+ : step?.params?.title ?? step?.params?.connector ?? "",
1673
+ ).slice(0, 200),
1674
+ });
1675
+ return json(200, {
1676
+ triggers: triggers.map((trigger) => ({
1677
+ ...trigger,
1678
+ steps: (automations.get(trigger.automation)?.steps ?? []).map(stepSummary),
1679
+ ...(trigger.kind === "webhook" && port
1680
+ ? { local_url: `http://127.0.0.1:${port}/hooks/${hookTokens[trigger.name]}` }
1681
+ : {}),
1682
+ ...(trigger.kind === "watch"
1683
+ ? { baseline: Boolean(state[trigger.name]) }
1684
+ : {}),
1685
+ })),
1686
+ });
1687
+ }
1688
+ case "POST /api/triggers/fire": {
1689
+ const body = await readJsonBody(request);
1690
+ const stream = sseStart(response);
1691
+ try {
1692
+ await hub.fireTrigger(body, stream);
1693
+ } catch (error) {
1694
+ stream.send({ type: "error", message: error.message });
1695
+ }
1696
+ stream.send({ type: "done" });
1697
+ stream.end();
1698
+ return undefined;
1699
+ }
1700
+ // Images the developer attached, served back to the transcript from
1701
+ // the harness's own store. Ids are minted here, so an id that is not
1702
+ // one of ours can never address a path.
1703
+ case "GET /api/media": {
1704
+ const id = url.searchParams.get("id") ?? "";
1705
+ if (!/^[0-9a-f]{16}\.(png|jpg|webp|gif)$/.test(id)) {
1706
+ return json(400, { error: "bad media id" });
1707
+ }
1708
+ let bytes;
1709
+ try {
1710
+ bytes = await readFile(path.join(hub.mediaDir, id));
1711
+ } catch {
1712
+ return json(404, { error: "no such attachment" });
1713
+ }
1714
+ response.writeHead(200, {
1715
+ "content-type": contentTypeFor(id),
1716
+ "cache-control": "private, max-age=86400",
1717
+ });
1718
+ response.end(bytes);
1719
+ return undefined;
1720
+ }
1721
+ case "GET /api/agent-icon": {
1722
+ const art = await hub.iconBytes();
1723
+ if (!art) return json(404, { error: "this agent has no icon of its own" });
1724
+ response.writeHead(200, {
1725
+ "content-type": art.mediaType,
1726
+ // The mark can change under us (Studio, or an upload here), so
1727
+ // never let a stale one stick.
1728
+ "cache-control": "no-store",
1729
+ });
1730
+ response.end(art.bytes);
1731
+ return undefined;
1732
+ }
1733
+ case "PUT /api/agent-icon": {
1734
+ const chunks = [];
1735
+ let size = 0;
1736
+ for await (const chunk of request) {
1737
+ size += chunk.length;
1738
+ if (size > MAX_ICON_BYTES) return json(400, { error: "the icon must be 512KB or smaller" });
1739
+ chunks.push(chunk);
1740
+ }
1741
+ const placed = await hub.setIcon(Buffer.concat(chunks));
1742
+ hub.broadcast({ type: "icon", source: placed.source });
1743
+ return json(200, { ok: true, ...placed, dev: await hub.devInfo() });
1744
+ }
1745
+ case "GET /api/workspace": {
1746
+ const lane = url.searchParams.get("lane") ?? "source";
1747
+ if (!LANES.includes(lane)) return json(400, { error: `unknown lane '${lane}'` });
1748
+ let files = await listLane(
1749
+ laneRoot(hub, lane),
1750
+ lane === "source" ? SOURCE_SKIP : null,
1751
+ );
1752
+ if (lane === "workspace") {
1753
+ // The staged package is plumbing, not agent state (see LANES
1754
+ // doc) — the Source lane is where humans read those files.
1755
+ const staged = new Set(stagedPackagePaths(hub));
1756
+ files = files.filter((file) => !staged.has(file.path));
1757
+ }
1758
+ return json(200, { lane, files, writable: WRITABLE_LANES.has(lane) });
1759
+ }
1760
+ case "PUT /api/workspace/file": {
1761
+ const lane = url.searchParams.get("lane") ?? "source";
1762
+ if (!WRITABLE_LANES.has(lane)) {
1763
+ return json(403, { error: `${lane}/ belongs to the agent — it is read-only here` });
1764
+ }
1765
+ const target = laneFilePath(hub, lane, url.searchParams.get("path"));
1766
+ await stat(target).catch(() => {
1767
+ throw new CliError("no such file — the dev edits files that exist");
1768
+ });
1769
+ const chunks = [];
1770
+ let size = 0;
1771
+ for await (const chunk of request) {
1772
+ size += chunk.length;
1773
+ if (size > BODY_LIMIT_BYTES) throw new CliError("that file is too large to save");
1774
+ chunks.push(chunk);
1775
+ }
1776
+ await writeFile(target, Buffer.concat(chunks));
1777
+ // The package watcher picks this up and hot-reloads the agent, which
1778
+ // is the point: editing AGENT.md should change the next turn.
1779
+ return json(200, { ok: true });
1780
+ }
1781
+ case "GET /api/workspace/file": {
1782
+ const target = laneFilePath(hub, url.searchParams.get("lane") ?? "source", url.searchParams.get("path"));
1783
+ let bytes;
1784
+ try {
1785
+ bytes = await readFile(target);
1786
+ } catch {
1787
+ return json(404, { error: "no such workspace file" });
1788
+ }
1789
+ const headers = { "content-type": contentTypeFor(target) };
1790
+ if (url.searchParams.get("download") === "1") {
1791
+ headers["content-disposition"] =
1792
+ `attachment; filename="${path.basename(target).replace(/["\\]/g, "")}"`;
1793
+ }
1794
+ response.writeHead(200, headers);
1795
+ response.end(bytes);
1796
+ return undefined;
1797
+ }
1798
+ case "POST /api/workspace/open": {
1799
+ const body = await readJsonBody(request);
1800
+ const target = laneFilePath(hub, body.lane ?? "source", body.path);
1801
+ await stat(target).catch(() => {
1802
+ throw new CliError("no such workspace file");
1803
+ });
1804
+ return json(200, { ok: await openWithPlatform(target) });
1805
+ }
1806
+ case "GET /api/history": {
1807
+ return json(200, { sessions: hub.db.listSessions() });
1808
+ }
1809
+ case "GET /api/models": {
1810
+ const models = await api.json("GET /v1/terminus/models");
1811
+ return json(200, models);
1812
+ }
1813
+ // One Store, three kinds. The public search takes the kind, so the
1814
+ // skills, services and agents places are the same door with a
1815
+ // different word — and a service also needs its operations, which
1816
+ // only its own record carries.
1817
+ //
1818
+ // No words is a BROWSE, not an empty answer: the picker opens onto
1819
+ // the Store's own ranking for that kind, the way the Store window
1820
+ // does, and typing narrows it. `track=false` on every request —
1821
+ // the door records a committed search when it is left out, and a
1822
+ // developer filling in a manifest is not a person searching the
1823
+ // Store.
1824
+ case "GET /api/store": {
1825
+ const query = (url.searchParams.get("q") ?? "").trim();
1826
+ const kind = { skills: "skill", services: "service", agents: "agent" }[
1827
+ url.searchParams.get("kind") ?? "skills"
1828
+ ];
1829
+ if (!kind) throw new CliError("store search takes kind=skills|services|agents");
1830
+ // A service costs one detail request per row before it can even be
1831
+ // offered, so its page is shorter than the other two.
1832
+ const limit = kind === "service" ? 12 : 24;
1833
+ const found = await api.json("GET /v1/skills/search", {
1834
+ query: { q: query, limit, kinds: kind, scope: "store", track: "false" },
1835
+ });
1836
+ const rows = (Array.isArray(found) ? found : found.results ?? found.skills ?? found.items ?? [])
1837
+ .map((row) => ({
1838
+ address: row.address ?? row.uid ?? "",
1839
+ name: row.name ?? row.title ?? row.address ?? "",
1840
+ description: row.description ?? row.short_description ?? "",
1841
+ kind,
1842
+ official: Boolean(row.is_official),
1843
+ icon_url: row.illustration_url ?? null,
1844
+ likes: row.like_count ?? 0,
1845
+ rating: row.rating_average ?? null,
1846
+ ratings: row.rating_count ?? 0,
1847
+ price: row.price_usd ?? null,
1848
+ pricing: row.pricing ?? "",
1849
+ version: row.release_version ?? null,
1850
+ }))
1851
+ .filter((row) => row.address);
1852
+ if (kind !== "service") return json(200, { results: rows });
1853
+ // A service is granted per operation, so the picker has to know
1854
+ // them — and what each one does and costs — before it can add one.
1855
+ const detailed = await Promise.all(rows.map(async (row) => {
1856
+ const [publisher, slug] = row.address.replace(/^@/, "").split("/");
1857
+ try {
1858
+ const record = await api.json("GET /v1/services/by-address/{publisher}/{slug}", {
1859
+ params: { publisher, slug },
1860
+ });
1861
+ return {
1862
+ ...row,
1863
+ operations: (record.operations ?? [])
1864
+ .filter((operation) => operation?.id)
1865
+ .map((operation) => ({
1866
+ id: operation.id,
1867
+ description: operation.description ?? "",
1868
+ price: operation.price_credits ?? null,
1869
+ })),
1870
+ };
1871
+ } catch {
1872
+ return { ...row, operations: [] };
1873
+ }
1874
+ }));
1875
+ return json(200, { results: detailed.filter((row) => row.operations.length) });
1876
+ }
1877
+ case "GET /api/connectors":
1878
+ return json(200, {
1879
+ connectors: await hub.connectorStatuses(),
1880
+ connect_url: `${webBase}/os?settings=connections`,
1881
+ });
1882
+ case "GET /api/tool-catalog": {
1883
+ // What the picker browses, in the two shapes a developer names out
1884
+ // loud: the platform's own lanes as BUNDLES, and one record per
1885
+ // CONNECTOR holding the profiles it offers. `tools` is the flat
1886
+ // list of every declarable id, kept because search filters over it
1887
+ // and the window's hover lines read from it. The catalog degrades
1888
+ // to the core lanes when the platform is unreachable — search
1889
+ // should never be the reason the picker errors.
1890
+ const tools = [...CORE_TOOL_CATALOG];
1891
+ const connectors = [];
1892
+ try {
1893
+ const data = await api.json("GET /v1/connectors");
1894
+ for (const connector of data.connectors ?? []) {
1895
+ const profiles = (connector.profiles ?? []).filter((profile) => profile?.id);
1896
+ for (const profile of profiles) {
1897
+ tools.push({
1898
+ id: profile.id,
1899
+ name: profile.name ?? profile.id,
1900
+ desc: profile.description ?? "",
1901
+ kind: "connector",
1902
+ connector: connector.slug ?? "",
1903
+ connector_name: connector.name ?? connector.slug ?? "",
1904
+ });
1905
+ }
1906
+ // A connector with no agent profiles grants nothing, so it is
1907
+ // not a thing this picker can offer.
1908
+ if (!profiles.length) continue;
1909
+ connectors.push({
1910
+ slug: connector.slug ?? "",
1911
+ name: connector.name ?? connector.slug ?? "",
1912
+ description: connector.description ?? "",
1913
+ category: connector.category ?? "other",
1914
+ // The developer's OWN link to the product, which is what
1915
+ // makes a grant testable in this chat — not the visitor's.
1916
+ status: connector.connection?.status ?? "not_connected",
1917
+ account: connector.connection?.account_label ?? null,
1918
+ profiles: profiles.map((profile) => ({
1919
+ id: profile.id,
1920
+ profile: profile.profile ?? String(profile.id).split(".").pop(),
1921
+ name: profile.name ?? profile.id,
1922
+ description: profile.description ?? "",
1923
+ operations: profile.operations ?? [],
1924
+ })),
1925
+ });
1926
+ }
1927
+ } catch { /* offline: the core lanes still list */ }
1928
+ return json(200, { tools, bundles: TOOL_BUNDLES, connectors });
1929
+ }
1930
+ case "POST /api/spend-approvals": {
1931
+ // The spend card's answer: proxy the approval to the account door
1932
+ // with the developer's own credential. Local turns never bill, so
1933
+ // this only matters on --remote (the draft lane runs the hosted
1934
+ // gate).
1935
+ const body = await readJsonBody(request);
1936
+ if (typeof body.session_id !== "string" || !body.session_id) {
1937
+ throw new CliError("a spend approval names its session_id");
1938
+ }
1939
+ const approved = await api.json("POST /v1/terminus/sessions/{session_id}/spend-approvals", {
1940
+ params: { session_id: body.session_id },
1941
+ body: { scope: body.scope ?? "" },
1942
+ });
1943
+ return json(200, approved);
1944
+ }
1945
+ case "PUT /api/manifest": {
1946
+ const ops = await readJsonBody(request);
1947
+ await editManifestFile(dir, (manifest) => applyManifestOps(manifest, ops));
1948
+ if (ops.model !== undefined) hub.readerModel = null;
1949
+ const result = await hub.reload();
1950
+ return json(200, { ok: true, ...result, dev: await hub.devInfo() });
1951
+ }
1952
+ default: {
1953
+ const messages = /^GET \/api\/sessions\/([0-9a-f-]{36})\/messages$/.exec(route);
1954
+ if (messages) {
1955
+ return json(200, { messages: hub.db.listMessages(messages[1]) });
1956
+ }
1957
+ return json(404, { error: "no such dev door" });
1958
+ }
1959
+ }
1960
+ } catch (error) {
1961
+ if (!response.headersSent) {
1962
+ response.writeHead(error instanceof CliError ? 400 : 500, {
1963
+ "content-type": "application/json",
1964
+ });
1965
+ response.end(JSON.stringify({ error: error.message ?? "dev server error" }));
1966
+ } else {
1967
+ response.end();
1968
+ }
1969
+ if (!(error instanceof CliError)) {
1970
+ console.error(`[dev] ${route}: ${error.stack ?? error}`);
1971
+ }
1972
+ }
1973
+ });
1974
+
1975
+ let port = basePort;
1976
+ let lastError = null;
1977
+ for (let attempt = 0; attempt < (flags.port !== undefined ? 1 : PORT_ATTEMPTS); attempt += 1) {
1978
+ try {
1979
+ await listenDevServer(server, port);
1980
+ lastError = null;
1981
+ port = server.address().port;
1982
+ break;
1983
+ } catch (error) {
1984
+ lastError = error;
1985
+ if (error?.code !== "EADDRINUSE") throw error;
1986
+ port += 1;
1987
+ }
1988
+ }
1989
+ if (lastError) {
1990
+ // The engine is already up; it goes down with the failed start.
1991
+ await hub.stop().catch(() => {});
1992
+ if (flags.port !== undefined) {
1993
+ // Taken while the engine started, after the check above said it was free.
1994
+ throw await devPortUnavailableError({
1995
+ basePort,
1996
+ commandArgs,
1997
+ count: 1,
1998
+ directory: dir,
1999
+ failures: [{ port: basePort, error: lastError }],
2000
+ });
2001
+ }
2002
+ throw new CliError(`no free port between ${basePort} and ${port - 1}; pick one with --port`);
2003
+ }
2004
+
2005
+ // Watch the package for edits — from any editor or coding agent — and hot
2006
+ // reload; terminus.json stays the single ground truth.
2007
+ /**
2008
+ * Reloads are paced, not just debounced.
2009
+ *
2010
+ * The compile door is rate-limited per user, and the editor autosaves on
2011
+ * every pause in typing — so a plain debounce still asks the server to
2012
+ * recompile once every couple of seconds for as long as someone is writing.
2013
+ * A floor between reloads coalesces a writing session into a handful, and
2014
+ * the trailing call guarantees the LAST edit is always the one that lands.
2015
+ */
2016
+ const MIN_RELOAD_GAP_MS = 3_000;
2017
+ let debounce = null;
2018
+ let lastReloadAt = 0;
2019
+
2020
+ const scheduleReload = () => {
2021
+ clearTimeout(debounce);
2022
+ const since = Date.now() - lastReloadAt;
2023
+ const wait = Math.max(WATCH_DEBOUNCE_MS, MIN_RELOAD_GAP_MS - since);
2024
+ debounce = setTimeout(() => {
2025
+ lastReloadAt = Date.now();
2026
+ hub
2027
+ .reload()
2028
+ .catch((error) => hub.broadcast({ type: "engine_log", text: `reload failed: ${error.message}` }));
2029
+ }, wait);
2030
+ };
2031
+
2032
+ const watcher = watch(dir, { recursive: true }, (_event, file) => {
2033
+ if (!file || file.includes(TERMINUS_DIRECTORY) || file.includes(".git")) return;
2034
+ // The tree is told at once; the recompile waits for the typing to settle.
2035
+ hub.broadcast({ type: "files", lane: "source" });
2036
+ scheduleReload();
2037
+ });
2038
+
2039
+ // The agent's own lanes change while a turn runs, and nothing was watching
2040
+ // them — the explorer only saw new output when something else forced a
2041
+ // reload. No debounce and no recompile: a file appearing is not a reason to
2042
+ // rebuild the agent.
2043
+ let lanesWatcher = null;
2044
+ try {
2045
+ lanesWatcher = watch(hub.workspace, { recursive: true }, (_event, file) => {
2046
+ if (!file) return;
2047
+ // The staged package is restaged on reload (which broadcasts its own
2048
+ // refresh) and hidden from the tree anyway, so only the agent's live
2049
+ // half of the tree pings the explorer.
2050
+ const relative = String(file).split(path.sep).join("/");
2051
+ if (stagedPackagePaths(hub).includes(relative)) return;
2052
+ hub.broadcast({ type: "files", lane: "workspace" });
2053
+ });
2054
+ } catch {
2055
+ // A platform without recursive watch still works; the tree just waits for
2056
+ // the next explicit load.
2057
+ }
2058
+
2059
+ const close = async () => {
2060
+ watcher.close();
2061
+ lanesWatcher?.close();
2062
+ clearTimeout(debounce);
2063
+ // End every open event stream first: an SSE response never finishes on
2064
+ // its own, and `server.close()` waits for it forever otherwise.
2065
+ for (const subscriber of hub.eventSubscribers) {
2066
+ try {
2067
+ subscriber.end();
2068
+ } catch {
2069
+ // The socket is already gone; nothing to end.
2070
+ }
2071
+ }
2072
+ hub.eventSubscribers.clear();
2073
+ const closed = new Promise((resolve) => server.close(resolve));
2074
+ // The browser also parks idle keep-alive sockets; reclaim them now
2075
+ // rather than waiting out the keep-alive timeout.
2076
+ server.closeAllConnections();
2077
+ await closed;
2078
+ await hub.stop();
2079
+ };
2080
+ const address = `http://127.0.0.1:${port}`;
2081
+ return { server, hub, port, token, url: `${address}/`, close };
2082
+ }
2083
+
2084
+ /** The interactive `terminus dev` entry: start the server, open the browser,
2085
+ * run until Ctrl-C. */
2086
+ export async function runDevServer({ commandArgs, dir, flags, api }) {
2087
+ const started = await startDevServer({ commandArgs, dir, flags, api });
2088
+ const substrate = flags.remote ? "remote (draft on the production engine)" : "local";
2089
+ console.log(`terminus dev (${substrate})`);
2090
+ console.log(`${started.url}`);
2091
+ console.log(
2092
+ "Chat, attachments, files, and settings live in the browser. Edits to "
2093
+ + "this package hot-reload. Headless lane: terminus dev --prompt \"…\" "
2094
+ + "--json. Ctrl-C stops the server.",
2095
+ );
2096
+ if (!flags.no_open) {
2097
+ await openWithPlatform(started.url);
2098
+ }
2099
+ await new Promise((resolve) => {
2100
+ process.once("SIGINT", resolve);
2101
+ process.once("SIGTERM", resolve);
2102
+ });
2103
+ console.log("\nClosing…");
2104
+ // A second Ctrl-C while we drain is an order, not a request.
2105
+ process.once("SIGINT", () => process.exit(130));
2106
+ // Read the goodbye numbers before close() shuts the SQLite mirror.
2107
+ const spend = started.hub.spend();
2108
+ let conversations = 0;
2109
+ try {
2110
+ // Only conversations someone actually had — the auto-created empty
2111
+ // session is not worth a goodbye line.
2112
+ conversations = started.hub.db?.listSessions().filter((s) => s.turns > 0).length ?? 0;
2113
+ } catch {
2114
+ // The mirror is best-effort at exit; missing numbers are not an error.
2115
+ }
2116
+ await started.close();
2117
+ const spent = spend.tokens > 0
2118
+ ? ` — ${spend.tokens} tok${spend.costUsd > 0 ? ` · $${spend.costUsd.toFixed(4)}` : ""} this run`
2119
+ : "";
2120
+ console.log(`Stopped${spent}.`);
2121
+ if (conversations > 0) {
2122
+ const noun = conversations === 1 ? "conversation" : "conversations";
2123
+ console.log(
2124
+ `${conversations} ${noun} saved in .terminus/dev/agent/dev.db — \`terminus dev .\` picks them up.`,
2125
+ );
2126
+ }
2127
+ }