@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,657 @@
1
+ /**
2
+ * `terminus dev` for kind:"agent" packages.
3
+ *
4
+ * DEFAULT (local): a local web server on 127.0.0.1 serving a chat UI — the
5
+ * developer tests the agent the way its users will meet it. The agent loop
6
+ * runs on THIS machine in `terminus-agentd dev`, against a runtime-shaped
7
+ * workspace assembled under `.terminus/dev/agent/` (your whole
8
+ * package staged read-only in place; the rest of the root and output/
9
+ * writable).
10
+ * The system prompt and tool definitions are compiled
11
+ * by the backend (`POST /v1/agent-dev/compile`) from your local manifest —
12
+ * the same composition publishing produces — while exec runs natively in a
13
+ * workspace-write sandbox (jailed writes, no network) with prod-compat
14
+ * warnings when a program leans on something the hosted isolate lacks.
15
+ * Works immediately after `terminus init --kind agent`: no draft, no link.
16
+ * State stays local; models, web, skills, and connector calls are brokered
17
+ * through the platform under your own account. Sessions mirror into
18
+ * `.terminus/dev/agent/dev.db` (SQLite — prod sessions are DB rows,
19
+ * so local ones are too); outputs stay real files in the workspace lanes.
20
+ *
21
+ * `--remote`: the pre-publish fidelity gear — pushes the tree as your
22
+ * Studio draft and chats with it on the platform's real engine (real bashd
23
+ * isolate, real broker), through the SAME web UI. Interface constant,
24
+ * substrate swapped.
25
+ *
26
+ * One-shot mode for scripts and coding agents (headless, no server):
27
+ * `terminus dev --prompt "…" [--json] [--resume <session-id>]`.
28
+ *
29
+ * There is no terminal REPL: the browser is the interactive surface, the
30
+ * one-shot lane is the machine surface (decision of
31
+ * 2026-08-31).
32
+ */
33
+
34
+ import { spawn } from "node:child_process";
35
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
36
+ import path from "node:path";
37
+ import readline from "node:readline";
38
+ import { randomUUID } from "node:crypto";
39
+
40
+ import { pushDraft, readAppPackage } from "./apps.mjs";
41
+ import { DEV_DIRECTORY, ensureDevDirectory } from "./files.mjs";
42
+ import { lintBashCommand, lintPythonImports } from "./devlint.mjs";
43
+ import { buildTriggerInput, listDevTriggers, runTriggerFiring } from "./devtriggers.mjs";
44
+ import { resolveAgentd } from "./toolchain.mjs";
45
+ import { CliError, parseFlags } from "./client.mjs";
46
+ import { connect } from "./http.mjs";
47
+
48
+ /** Send one message to a draft session and stream its turn's SSE frames,
49
+ * invoking onFrame per decoded JSON event. A refused turn throws what the
50
+ * backend said. */
51
+ export async function streamTurn(api, sessionId, body, onFrame, signal) {
52
+ const response = await api.stream("POST /v1/terminus/sessions/{session_id}/messages", {
53
+ params: { session_id: sessionId },
54
+ body,
55
+ headers: { Accept: "text/event-stream" },
56
+ signal,
57
+ });
58
+ if (!response.body) throw new CliError("the turn answered with no event stream");
59
+ const decoder = new TextDecoder();
60
+ let buffer = "";
61
+ const dispatch = (rawEvent) => {
62
+ const data = rawEvent
63
+ .split("\n")
64
+ .filter((line) => line.startsWith("data:"))
65
+ .map((line) => line.slice(5).trimStart())
66
+ .join("\n");
67
+ if (!data || data === "[DONE]") return;
68
+ try {
69
+ onFrame(JSON.parse(data));
70
+ } catch {
71
+ // Skip malformed frames rather than kill the stream.
72
+ }
73
+ };
74
+ for await (const chunk of response.body) {
75
+ buffer += decoder.decode(chunk, { stream: true });
76
+ buffer = buffer.replace(/\r\n/g, "\n");
77
+ let separator = buffer.indexOf("\n\n");
78
+ while (separator !== -1) {
79
+ dispatch(buffer.slice(0, separator));
80
+ buffer = buffer.slice(separator + 2);
81
+ separator = buffer.indexOf("\n\n");
82
+ }
83
+ }
84
+ if (buffer.trim()) dispatch(buffer);
85
+ }
86
+
87
+ /** Fold a frame into the spend/error ledger (playground/agent-proto
88
+ * vocabulary). Presentation lives elsewhere; this is the accounting. */
89
+ export function trackFrameSpend(frame, spend) {
90
+ if (frame.type === "usage") {
91
+ spend.tokens += (frame.input_tokens ?? 0) + (frame.output_tokens ?? 0);
92
+ const billed = Number.parseFloat(frame.billed_amount_usd);
93
+ const cost = Number.parseFloat(
94
+ Number.isFinite(billed) && billed > 0
95
+ ? frame.billed_amount_usd
96
+ : frame.est_provider_cost_usd,
97
+ );
98
+ if (Number.isFinite(cost)) spend.costUsd += cost;
99
+ }
100
+ if (frame.type === "error") spend.errored = true;
101
+ }
102
+
103
+ /** The prod-compat lint for one tool call: non-blocking warnings when a
104
+ * program leans on something the hosted isolate lacks. */
105
+ export function compatWarningsForToolCall(frame) {
106
+ if (frame.name === "bash") return lintBashCommand(frame.input?.command);
107
+ if (frame.name === "python") return lintPythonImports(frame.input?.code);
108
+ return [];
109
+ }
110
+
111
+ /** Terminal renderer for the one-shot lane (presentation only). */
112
+ function renderFrame(frame, options = {}) {
113
+ switch (frame.type) {
114
+ case "delta":
115
+ if (typeof frame.text === "string") process.stdout.write(frame.text);
116
+ return;
117
+ case "tool_call": {
118
+ process.stdout.write(`\n[tool] ${frame.name ?? "?"}\n`);
119
+ if (options.lint) {
120
+ for (const warning of compatWarningsForToolCall(frame)) console.error(warning);
121
+ }
122
+ return;
123
+ }
124
+ case "plan_update": {
125
+ const steps = Array.isArray(frame.steps) ? frame.steps : [];
126
+ process.stdout.write(
127
+ `\n[plan] ${steps
128
+ .map((step) => `${step.status === "completed" ? "✓" : step.status === "in_progress" ? "→" : "·"} ${step.step}`)
129
+ .join(" ")}\n`,
130
+ );
131
+ return;
132
+ }
133
+ case "notice":
134
+ if (typeof frame.message === "string") process.stdout.write(`\n[${frame.message}]\n`);
135
+ return;
136
+ case "error":
137
+ process.stdout.write(`\n[error] ${frame.message ?? "the turn failed"}\n`);
138
+ return;
139
+ default:
140
+ // Tool outputs, usage, queue updates, and lifecycle frames stay quiet.
141
+ }
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Local engine (the default substrate)
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Assemble the runtime-shaped dev workspace from the manifest: the WHOLE
150
+ * package stages read-only IN PLACE at the workspace root, verbatim
151
+ * (AGENT.md and terminus.json included — an agent works in its own source).
152
+ * Staged paths are the reserved names; `previousStaged` (from the last
153
+ * dev.json) is removed first so a dropped package file does not linger,
154
+ * while everything the agent wrote persists across reloads. Returns the
155
+ * staged path list. Mirrors the hosted reserved-names rule.
156
+ */
157
+ export async function assembleDevWorkspace(manifest, workspaceDir, previousStaged = []) {
158
+ await mkdir(workspaceDir, { recursive: true });
159
+ // The previous in-place staging goes first, so a reloaded workspace never
160
+ // shows two generations of the same files.
161
+ for (const stale of previousStaged) {
162
+ const parts = String(stale).split("/");
163
+ if (parts.some((part) => !part || part === "." || part === "..")) continue;
164
+ await rm(path.join(workspaceDir, ...parts), { force: true }).catch(() => undefined);
165
+ }
166
+ // The intrinsic workspace: the ROOT is the agent's own writable folder
167
+ // with the package staged read-only in place; output/ holds deliverables,
168
+ // attachments/ the chat file exchange, memory/ its cross-session notes
169
+ // (markdown the agent chooses to keep — session history lives in the
170
+ // DATABASE and is browsed from the dev UI's History surface, never files).
171
+ for (const lane of ["output", "attachments", "memory"]) {
172
+ await mkdir(path.join(workspaceDir, lane), { recursive: true });
173
+ }
174
+ const staged = [];
175
+ for (const material of manifest.materials ?? []) {
176
+ if (material.role === "source") continue; // ledger-only plane
177
+ const relative = material.path ?? "";
178
+ if (!relative || relative.split("/").some((part) => part === "." || part === "..")) continue;
179
+ const target = path.join(workspaceDir, relative);
180
+ await mkdir(path.dirname(target), { recursive: true });
181
+ // A collision with a file the agent wrote resolves release-wins, same
182
+ // as hosted (the stored object is shadowed there, replaced here); a
183
+ // read-only file from the previous staging must be removed before the
184
+ // write, or writeFile dies with EACCES.
185
+ await rm(target, { force: true }).catch(() => undefined);
186
+ const bytes = material.encoding === "base64"
187
+ ? Buffer.from(material.content ?? "", "base64")
188
+ : Buffer.from(material.content ?? "", "utf8");
189
+ await writeFile(target, bytes);
190
+ // Read-only, as in production: bash writes over package files must fail
191
+ // locally the way the hosted commit predicate rejects them.
192
+ await chmod(target, 0o444);
193
+ staged.push(relative);
194
+ }
195
+ return staged;
196
+ }
197
+
198
+ /**
199
+ * One local dev engine: compile via the backend, assemble the workspace,
200
+ * spawn `terminus-agentd dev`, speak stdio frames. Presentation-free —
201
+ * every frame is handed to `onFrame` (the dev server's SSE fan-out, or the
202
+ * one-shot renderer).
203
+ */
204
+ export class LocalDev {
205
+ constructor(dir, flags, api, { onFrame } = {}) {
206
+ this.dir = dir;
207
+ this.flags = flags;
208
+ this.api = api;
209
+ this.onFrame = onFrame ?? (() => {});
210
+ this.devRoot = path.join(dir, DEV_DIRECTORY, "agent");
211
+ this.workspace = path.join(this.devRoot, "workspace");
212
+ // The engine's JSONL session log is crash/resume plumbing — a WAL,
213
+ // not a data store — so it lives OUTSIDE the workspace: the agent never
214
+ // sees raw history (its memory is what it wrote to memory/), and the
215
+ // browsable record is the SQLite mirror behind the History surface.
216
+ this.sessionDir = path.join(this.devRoot, "engine");
217
+ this.configPath = path.join(this.devRoot, "dev.json");
218
+ this.sessionIdPath = path.join(this.devRoot, "session-id");
219
+ this.child = null;
220
+ this.sessionId = null;
221
+ this.spend = { tokens: 0, costUsd: 0, errored: false };
222
+ this.turnDone = null;
223
+ this.grants = null;
224
+ this.model = null;
225
+ /** A reader's pick in the dev page, which outranks the manifest's default
226
+ * for this run — the ENGINE has to start on it, or the page would be
227
+ * showing a model the agent is not answering as. */
228
+ this.modelOverride = null;
229
+ this.compiled = null;
230
+ this.exitNotice = null;
231
+ }
232
+
233
+ async currentSessionId() {
234
+ try {
235
+ const id = (await readFile(this.sessionIdPath, "utf8")).trim();
236
+ return /^[0-9a-f-]{36}$/.test(id) ? id : null;
237
+ } catch {
238
+ return null;
239
+ }
240
+ }
241
+
242
+ async rememberSessionId(id) {
243
+ await writeFile(this.sessionIdPath, `${id}\n`).catch(() => undefined);
244
+ }
245
+
246
+ /** Compile via the backend, assemble the workspace, spawn the local host. */
247
+ async start() {
248
+ const pkg = await readAppPackage(this.dir, { includeSource: false });
249
+ if (pkg.manifest.kind !== "agent") {
250
+ throw new CliError(
251
+ `terminus dev chat is for kind 'agent' packages (found '${pkg.manifest.kind}')`,
252
+ );
253
+ }
254
+ // The compile door is rate-limited per user (30/min), and a hot reload
255
+ // fires on every saved keystroke-pause. Most of those saves do not change
256
+ // what the server would compose — a data file, a README, a save that
257
+ // rewrote identical bytes — so ask only when the input actually differs.
258
+ const compileKey = JSON.stringify(pkg.manifest);
259
+ let compiled;
260
+ if (this.compileKey === compileKey && this.compileCache) {
261
+ compiled = this.compileCache;
262
+ } else {
263
+ compiled = await this.api.json("POST /v1/agent-dev/compile", { body: pkg.manifest });
264
+ this.compileKey = compileKey;
265
+ this.compileCache = compiled;
266
+ }
267
+ this.compiled = compiled;
268
+ this.manifest = pkg.manifest;
269
+ this.grants = compiled.grants ?? {};
270
+ this.model = this.modelOverride ?? this.flags.model ?? compiled.model;
271
+ if (!this.model) {
272
+ throw new CliError(
273
+ "this agent has no model — set \"models\": { \"default\": \"provider:model\" } in terminus.json (or pass --model)",
274
+ );
275
+ }
276
+ // The dev folder ignores itself before anything is written into it.
277
+ await ensureDevDirectory(this.dir);
278
+ await mkdir(this.sessionDir, { recursive: true });
279
+ // The previous staging (recorded in the last dev config) is removed
280
+ // before restaging, so a file dropped from the package leaves the
281
+ // workspace too.
282
+ let previousStaged = [];
283
+ try {
284
+ previousStaged = JSON.parse(await readFile(this.configPath, "utf8")).reserved_paths ?? [];
285
+ } catch { /* first start, or an old config shape */ }
286
+ const staged = await assembleDevWorkspace(pkg.manifest, this.workspace, previousStaged);
287
+ this.reservedPaths = staged;
288
+ const resume = this.flags.resume ?? (await this.currentSessionId());
289
+ const devConfig = {
290
+ workspace: this.workspace,
291
+ reserved_paths: staged,
292
+ api_base: this.api.base,
293
+ model: this.model,
294
+ system_prompt: compiled.system_prompt,
295
+ capabilities: pkg.manifest.capabilities ?? {},
296
+ tools: compiled.tools ?? [],
297
+ session_dir: this.sessionDir,
298
+ ...(resume ? { resume } : {}),
299
+ ...(this.flags.sandbox ? { sandbox: this.flags.sandbox } : {}),
300
+ };
301
+ await writeFile(this.configPath, `${JSON.stringify(devConfig, null, 2)}\n`);
302
+ const agentd = await resolveAgentd(this.flags);
303
+ // The dev child gets a short-lived scoped runtime token, never the
304
+ // CLI's own session token — the same child-credential contract the
305
+ // Desktop uses for its local agents. Minted fresh per dev start.
306
+ const runtime = await this.api.json("POST /v1/auth/native-runtime-token");
307
+ // The child credential expires (the backend mints ~2h). The host owns
308
+ // reauthentication, so the expiry is surfaced for whoever runs this dev
309
+ // long enough to need a re-mint — the web dev arms a refresh off it.
310
+ this.runtimeExpiresAt = Date.parse(runtime.expires_at ?? "") || null;
311
+ this.exitNotice = null;
312
+ this.child = spawn(agentd, ["dev", "--config", this.configPath], {
313
+ env: {
314
+ ...process.env,
315
+ TERMINUS_DEV_TOKEN: runtime.token,
316
+ TERMINUS_API_BASE: this.api.base,
317
+ },
318
+ stdio: ["pipe", "pipe", "pipe"],
319
+ });
320
+ this.child.on("exit", (code) => {
321
+ this.exitNotice = `the dev host exited (${code ?? "signal"})`;
322
+ this.turnDone?.reject?.(new CliError(this.exitNotice));
323
+ });
324
+ // Ctrl-C reaches the whole foreground process group, so the child can be
325
+ // gone before a frame goes out; a torn stdin pipe at that point is an
326
+ // exit condition, not an error worth crashing over.
327
+ this.child.stdin.on("error", () => {});
328
+ this.child.stderr.on("data", (chunk) => {
329
+ const text = String(chunk).trim();
330
+ if (text) console.error(`[agentd] ${text}`);
331
+ });
332
+ const lines = readline.createInterface({ input: this.child.stdout });
333
+ lines.on("line", (line) => {
334
+ if (!line.trim()) return;
335
+ let frame;
336
+ try {
337
+ frame = JSON.parse(line);
338
+ } catch {
339
+ return;
340
+ }
341
+ this.handleFrame(frame);
342
+ });
343
+ return { staged, toolCount: (compiled.tools ?? []).length, compiled };
344
+ }
345
+
346
+ handleFrame(frame) {
347
+ if (frame.type === "session_started") {
348
+ if (typeof frame.session_id === "string") {
349
+ this.sessionId = frame.session_id;
350
+ void this.rememberSessionId(frame.session_id);
351
+ }
352
+ }
353
+ trackFrameSpend(frame, this.spend);
354
+ try {
355
+ this.onFrame(frame);
356
+ } catch {
357
+ // A broken subscriber must not kill the engine pump.
358
+ }
359
+ if (frame.type === "turn_done") this.turnDone?.resolve?.();
360
+ }
361
+
362
+ /** Send one user turn. `attachments` are inline images for vision models:
363
+ * `[{media_type, data}]`, data base64 — the same shape the stdio protocol
364
+ * and the hosted turn ledger carry. */
365
+ async send(text, attachments = []) {
366
+ if (!this.child) throw new CliError("the agent is not running");
367
+ const done = new Promise((resolve, reject) => {
368
+ this.turnDone = { resolve, reject };
369
+ });
370
+ if (!this.writeFrame({ type: "user_message", text, ...(attachments.length ? { attachments } : {}) })) {
371
+ this.turnDone = null;
372
+ throw new CliError(this.exitNotice ?? "the agent is not running");
373
+ }
374
+ try {
375
+ await done;
376
+ } finally {
377
+ this.turnDone = null;
378
+ }
379
+ }
380
+
381
+ interrupt() {
382
+ this.writeFrame({ type: "interrupt" });
383
+ }
384
+
385
+ /** Best-effort frame to the child; false when the pipe is already gone. */
386
+ writeFrame(frame) {
387
+ const stdin = this.child?.stdin;
388
+ if (!stdin || stdin.destroyed || !stdin.writable) return false;
389
+ try {
390
+ stdin.write(`${JSON.stringify(frame)}\n`);
391
+ return true;
392
+ } catch {
393
+ return false;
394
+ }
395
+ }
396
+
397
+ usageLine() {
398
+ if (this.spend.tokens === 0) return null;
399
+ const cost = this.spend.costUsd > 0 ? ` · $${this.spend.costUsd.toFixed(4)}` : "";
400
+ return `${this.spend.tokens} tok${cost} this session`;
401
+ }
402
+
403
+ async stop() {
404
+ const child = this.child;
405
+ if (!child) return;
406
+ // Ctrl-C usually killed the child with us; a dead child needs no
407
+ // shutdown frame and its `exit` already fired, so waiting would only
408
+ // burn the 3s timeout.
409
+ if (child.exitCode !== null || child.signalCode !== null) {
410
+ this.child = null;
411
+ return;
412
+ }
413
+ this.writeFrame({ type: "shutdown" });
414
+ this.child = null;
415
+ await new Promise((resolve) => {
416
+ const timer = setTimeout(() => {
417
+ child.kill("SIGKILL");
418
+ resolve();
419
+ }, 3000);
420
+ child.once("exit", () => {
421
+ clearTimeout(timer);
422
+ resolve();
423
+ });
424
+ });
425
+ }
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Remote engine (`--remote`): the pre-publish fidelity gear
430
+ // ---------------------------------------------------------------------------
431
+
432
+ /** One draft dev session: push, create, serial turns, delete. The web
433
+ * dev drives it through the same interface shape as LocalDev. */
434
+ export class AgentDevSession {
435
+ constructor(api) {
436
+ this.api = api;
437
+ this.sessionId = null;
438
+ this.turnAbort = null;
439
+ this.spend = { tokens: 0, costUsd: 0, errored: false };
440
+ }
441
+
442
+ /** Stage the local tree as the server draft, then open a remote session
443
+ * whose persona and grants the server compiles from that draft. */
444
+ async start(dir, model) {
445
+ const pkg = await readAppPackage(dir, { includeSource: true, includeContent: false });
446
+ if (pkg.manifest.kind !== "agent") {
447
+ throw new CliError(
448
+ `terminus dev chat is for kind 'agent' packages (found '${pkg.manifest.kind}')`,
449
+ );
450
+ }
451
+ const pushed = await pushDraft({
452
+ api: this.api,
453
+ manifest: pkg.manifest,
454
+ pkg,
455
+ message: "Staged by terminus dev --remote",
456
+ });
457
+ const session = await this.api.json("POST /v1/terminus/sessions", {
458
+ body: {
459
+ model: model ?? "",
460
+ surface: "agent_draft",
461
+ agent_app_id: pushed.appId,
462
+ },
463
+ });
464
+ this.sessionId = session.id;
465
+ return { address: pushed.address, model: session.model, revision: pushed.revision };
466
+ }
467
+
468
+ /** POST one message and stream its turn frames to `onFrame`; resolves on
469
+ * the terminal frame. `attachments` are image content parts, forwarded on
470
+ * the sessions door's ratified attachments field. */
471
+ async send(text, attachments, onFrame) {
472
+ if (!this.sessionId) throw new CliError("the remote session is not started");
473
+ const controller = new AbortController();
474
+ this.turnAbort = controller;
475
+ try {
476
+ await streamTurn(
477
+ this.api,
478
+ this.sessionId,
479
+ {
480
+ content: text,
481
+ client_request_id: randomUUID(),
482
+ ...(attachments?.length
483
+ ? {
484
+ attachments: attachments.map((image) => ({
485
+ kind: "image",
486
+ media_type: image.media_type,
487
+ data: image.data,
488
+ })),
489
+ }
490
+ : {}),
491
+ },
492
+ (frame) => {
493
+ trackFrameSpend(frame, this.spend);
494
+ onFrame(frame);
495
+ },
496
+ controller.signal,
497
+ );
498
+ } catch (error) {
499
+ // Interrupted, whether before the turn answered or while it streamed.
500
+ if (controller.signal.aborted) {
501
+ onFrame({ type: "notice", message: "interrupted — the turn may still settle server-side" });
502
+ return;
503
+ }
504
+ throw error;
505
+ } finally {
506
+ if (this.turnAbort === controller) this.turnAbort = null;
507
+ }
508
+ }
509
+
510
+ interrupt() {
511
+ this.turnAbort?.abort();
512
+ }
513
+
514
+ usageLine() {
515
+ if (this.spend.tokens === 0) return null;
516
+ return `${this.spend.tokens} tok · $${this.spend.costUsd.toFixed(4)} this session`;
517
+ }
518
+
519
+ async stop() {
520
+ const sessionId = this.sessionId;
521
+ this.sessionId = null;
522
+ this.turnAbort?.abort();
523
+ if (sessionId) {
524
+ await this.api.json("DELETE /v1/terminus/sessions/{session_id}", {
525
+ params: { session_id: sessionId },
526
+ }).catch(() => undefined);
527
+ }
528
+ }
529
+ }
530
+
531
+ // ---------------------------------------------------------------------------
532
+ // One-shot lane (headless — the Codex/Claude Code/CI surface)
533
+ // ---------------------------------------------------------------------------
534
+
535
+ async function oneShotAgentDev(dir, flags, api) {
536
+ const dev = new LocalDev(dir, flags, api, {
537
+ onFrame: (frame) => {
538
+ if (flags.json) {
539
+ process.stdout.write(`${JSON.stringify(frame)}\n`);
540
+ } else {
541
+ renderFrame(frame, { lint: true });
542
+ }
543
+ },
544
+ });
545
+ await dev.start();
546
+ await dev.send(flags.prompt);
547
+ await dev.stop();
548
+ if (!flags.json) {
549
+ process.stdout.write("\n");
550
+ const usage = dev.usageLine();
551
+ if (usage) console.error(`[${usage}]`);
552
+ }
553
+ process.exitCode = dev.spend.errored ? 1 : 0;
554
+ }
555
+
556
+ /** One-shot trigger simulation (headless): fire one declared trigger the
557
+ * way the platform would — same input shape, same template resolution, same
558
+ * untrusted-event fence — with agent.run steps as real dev turns. The CI
559
+ * lane for standing-agent loops. */
560
+ async function oneShotTriggerDev(dir, flags, api) {
561
+ const raw = JSON.parse(await readFile(path.join(dir, "terminus.json"), "utf8"));
562
+ const declared = listDevTriggers(raw);
563
+ const trigger = declared.find((candidate) => candidate.name === flags.trigger);
564
+ if (!trigger) {
565
+ const names = declared.map((entry) => `${entry.name} (${entry.kind})`).join(", ");
566
+ throw new CliError(
567
+ `no declared trigger named '${flags.trigger}'${
568
+ names ? ` — declared: ${names}` : " — this package declares no triggers"
569
+ }`,
570
+ );
571
+ }
572
+ let payload;
573
+ if (flags.payload) {
574
+ try {
575
+ payload = JSON.parse(await readFile(flags.payload, "utf8"));
576
+ } catch (error) {
577
+ throw new CliError(`--payload must name a JSON file: ${error?.message ?? error}`);
578
+ }
579
+ }
580
+ const emit = (frame) => {
581
+ if (flags.json) process.stdout.write(`${JSON.stringify(frame)}\n`);
582
+ else renderFrame(frame, { lint: true });
583
+ };
584
+ let collector = null;
585
+ const dev = new LocalDev(dir, flags, api, {
586
+ onFrame: (frame) => {
587
+ if (collector && frame.type === "delta" && typeof frame.text === "string") {
588
+ collector.text += frame.text;
589
+ }
590
+ emit(frame);
591
+ },
592
+ });
593
+ await dev.start();
594
+ try {
595
+ const built = await buildTriggerInput(trigger, { payload, devRoot: dev.devRoot });
596
+ emit({
597
+ type: "trigger_fired",
598
+ trigger: trigger.name,
599
+ kind: trigger.kind,
600
+ ...(built.baseline ? { baseline: true } : {}),
601
+ ...(built.unchanged ? { unchanged: true } : {}),
602
+ });
603
+ if (built.baseline) {
604
+ emit({
605
+ type: "notice",
606
+ message: `watch '${trigger.name}': first poll recorded the baseline — this firing tests the loop with the current content`,
607
+ });
608
+ }
609
+ await runTriggerFiring({
610
+ manifest: raw,
611
+ trigger,
612
+ input: built.input,
613
+ runTurn: async (prompt) => {
614
+ collector = { text: "" };
615
+ try {
616
+ await dev.send(prompt);
617
+ return collector.text;
618
+ } finally {
619
+ collector = null;
620
+ }
621
+ },
622
+ emit,
623
+ });
624
+ } finally {
625
+ await dev.stop();
626
+ }
627
+ if (!flags.json) {
628
+ process.stdout.write("\n");
629
+ const usage = dev.usageLine();
630
+ if (usage) console.error(`[${usage}]`);
631
+ }
632
+ process.exitCode = dev.spend.errored ? 1 : 0;
633
+ }
634
+
635
+ // ---------------------------------------------------------------------------
636
+
637
+ export async function agentDevCommand(dir, flags = parseFlags([], "dev"), commandArgs = []) {
638
+ const api = await connect(flags);
639
+ if (typeof flags.trigger === "string" && flags.trigger.length > 0) {
640
+ if (flags.remote) {
641
+ throw new CliError(
642
+ "trigger simulation runs on the local dev; drop --remote (publish exercises the real trigger plane)",
643
+ );
644
+ }
645
+ return oneShotTriggerDev(dir, flags, api);
646
+ }
647
+ if (typeof flags.prompt === "string" && flags.prompt.length > 0) {
648
+ if (flags.remote) {
649
+ throw new CliError(
650
+ "one-shot mode runs on the local dev; drop --remote (the web dev serves the remote gear)",
651
+ );
652
+ }
653
+ return oneShotAgentDev(dir, flags, api);
654
+ }
655
+ const { runDevServer } = await import("./devserver.mjs");
656
+ return runDevServer({ commandArgs, dir, flags, api });
657
+ }