@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.
- package/LICENSE +21 -0
- package/README.md +1055 -0
- package/bin/agent-discovery.mjs +71 -0
- package/bin/agent-icon.mjs +77 -0
- package/bin/agent-models.mjs +77 -0
- package/bin/agent-type.mjs +51 -0
- package/bin/agentdev.mjs +657 -0
- package/bin/app-route-script.mjs +59 -0
- package/bin/app-runtime-contract.mjs +2 -0
- package/bin/appdev-remote.mjs +346 -0
- package/bin/appdev.mjs +4446 -0
- package/bin/apps.mjs +5512 -0
- package/bin/capability-calls.mjs +437 -0
- package/bin/capsule-data.mjs +260 -0
- package/bin/client.mjs +189 -0
- package/bin/commands.mjs +1194 -0
- package/bin/dev-capsules.mjs +1599 -0
- package/bin/dev-contract.mjs +262 -0
- package/bin/dev-data.mjs +287 -0
- package/bin/dev-members.mjs +18 -0
- package/bin/dev-net.mjs +316 -0
- package/bin/dev-notification-popup.mjs +628 -0
- package/bin/dev-ports.mjs +567 -0
- package/bin/dev-server-binding.mjs +35 -0
- package/bin/dev-server-ops.mjs +1086 -0
- package/bin/dev-ui/IoskeleyMono-400.woff2 +0 -0
- package/bin/dev-ui/IoskeleyMono-600.woff2 +0 -0
- package/bin/dev-ui/OFL.txt +92 -0
- package/bin/dev-ui/agent-robot.webp +0 -0
- package/bin/dev-ui/app.js +5217 -0
- package/bin/dev-ui/highlight.js +195 -0
- package/bin/dev-ui/index.html +34 -0
- package/bin/dev-ui/style.css +3640 -0
- package/bin/devlint.mjs +112 -0
- package/bin/devserver.mjs +2127 -0
- package/bin/devtriggers.mjs +367 -0
- package/bin/endpoints.mjs +156 -0
- package/bin/errors.mjs +61 -0
- package/bin/files.mjs +169 -0
- package/bin/horizontal-capabilities/v1/contract.json +280 -0
- package/bin/http.mjs +500 -0
- package/bin/lint-manifests/justbash-commands.json +88 -0
- package/bin/lint-manifests/python-stdlib.json +295 -0
- package/bin/login-page.mjs +488 -0
- package/bin/schedules.mjs +664 -0
- package/bin/server-sandbox.mjs +204 -0
- package/bin/servicedev.mjs +425 -0
- package/bin/sync.mjs +357 -0
- package/bin/terminus.js +3666 -0
- package/bin/toolchain.mjs +125 -0
- package/bin/vendor/app-runtime-v1/app-host.json +124 -0
- package/bin/vendor/app-runtime-v1/capability-calls.json +412 -0
- package/bin/vendor/app-runtime-v1/doors.json +2867 -0
- package/bin/vendor/appd/node-harness.mjs +209 -0
- package/bin/vendor/appd/python-harness.py +12 -0
- package/bin/vendor/appd/server-protocol.json +84 -0
- package/bin/vendor/where.mjs +541 -0
- package/bin/versioning.mjs +72 -0
- package/bin/write-rules.mjs +398 -0
- package/package.json +41 -0
package/bin/agentdev.mjs
ADDED
|
@@ -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
|
+
}
|