@usenaive-sdk/cli 0.14.0 → 0.15.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.
@@ -0,0 +1,916 @@
1
+ import { Command } from "commander";
2
+ import { apiRequest, handleApiError, scopePathToUser, activeUserId, activeProjectId } from "../client.js";
3
+ import { loadConfig, getApiKey } from "../config.js";
4
+ import { agentOutput, agentOutputExit, agentError, humanMode } from "../output.js";
5
+ import { parseSse, highestSeq, redactFrame } from "../lib/stream-frames.js";
6
+ /** micro-USD → a dollar string. The only arithmetic in this file, and it is on
7
+ * a CAP (a configuration integer we set), never on the money ledger — every
8
+ * spent figure stays the decimal string the API sent. */
9
+ function usd(microUsd) {
10
+ return microUsd === undefined ? "—" : `$${(microUsd / 1_000_000).toFixed(2)}`;
11
+ }
12
+ function short(id) {
13
+ return id ? id.slice(0, 8) : "—";
14
+ }
15
+ /**
16
+ * Every `next_steps` command is DERIVED from the response the API just
17
+ * returned, so the id in a suggestion is the id the API echoed rather than an
18
+ * `<id>` placeholder, and a hint restates a field it actually sent. Neither can
19
+ * drift from what the platform did.
20
+ */
21
+ function agentSteps(a, extra = []) {
22
+ const id = a?.id;
23
+ if (!id)
24
+ return extra;
25
+ return [
26
+ { command: `naive agents run ${id} "…"`, description: "Send work and stream it" },
27
+ { command: `naive agents tasks ${id}`, description: "The task board" },
28
+ { command: `naive agents spend ${id}`, description: "Spend against the cap" },
29
+ ...extra,
30
+ ];
31
+ }
32
+ /** Hints echoed off what the API returned — restated, never asserted. */
33
+ function agentHints(a) {
34
+ if (!a)
35
+ return [];
36
+ const out = [];
37
+ if (a.status)
38
+ out.push(`Status: ${a.status}${a.blocked_reason ? ` (${a.blocked_reason})` : ""}`);
39
+ // 🔴 The idle-cost claim, rendered. An agent showing nothing is the NORMAL
40
+ // state of a long-horizon agent, so "nothing scheduled" has to be legible as
41
+ // a fact rather than as an absence.
42
+ if (a.status === "idle") {
43
+ out.push(a.next_wake_at ? `Next wake: ${a.next_wake_at}` : "No alarm armed — this agent costs storage only.");
44
+ }
45
+ if (a.attention)
46
+ out.push("Needs attention.");
47
+ if (a.budget?.cap_micro_usd !== undefined) {
48
+ out.push(`Cap: ${usd(a.budget.cap_micro_usd)} per ${a.budget.period ?? "period"} (per task ${usd(a.budget.max_task_micro_usd)})`);
49
+ }
50
+ if (a.harness_sha256 === null)
51
+ out.push("harness_sha256 is null — this agent has not run yet.");
52
+ return out;
53
+ }
54
+ function agentOf(data) {
55
+ const d = (data ?? {});
56
+ return d.agent;
57
+ }
58
+ /** `--budget-usd 50` is what an operator types; micro-USD is what the API takes. */
59
+ function microFromUsd(v, field) {
60
+ if (v === undefined)
61
+ return undefined;
62
+ const n = Number(v);
63
+ if (!Number.isFinite(n) || n <= 0) {
64
+ agentError({
65
+ action: "agents.invalid",
66
+ error: { code: "invalid_input", message: `${field} must be a positive number of US dollars.` },
67
+ recovery_steps: [{ command: "naive agents create --help", description: "Show the flags" }],
68
+ });
69
+ }
70
+ return Math.round(n * 1_000_000);
71
+ }
72
+ function body(opts) {
73
+ const out = {};
74
+ if (opts.name)
75
+ out.name = opts.name;
76
+ if (opts.instructions)
77
+ out.instructions = opts.instructions;
78
+ if (opts.model)
79
+ out.model = opts.model;
80
+ if (opts.window)
81
+ out.completion_window = opts.window;
82
+ if (opts.delegateModel)
83
+ out.delegate_models = opts.delegateModel;
84
+ if (opts.secret)
85
+ out.secrets = opts.secret;
86
+ if (opts.connection)
87
+ out.connections = opts.connection;
88
+ if (opts.paused !== undefined)
89
+ out.paused = opts.paused;
90
+ const cap = microFromUsd(opts.budgetUsd, "--budget-usd");
91
+ const perTask = microFromUsd(opts.maxTaskUsd, "--max-task-usd");
92
+ if (cap !== undefined || perTask !== undefined) {
93
+ out.budget = {
94
+ cap_micro_usd: cap,
95
+ // Defaulting the per-task ceiling to a tenth of the cap is a choice, and
96
+ // it is the safe direction: an operator who never thinks about it still
97
+ // cannot lose the whole budget to one runaway task.
98
+ max_task_micro_usd: perTask ?? (cap !== undefined ? Math.max(1, Math.round(cap / 10)) : undefined),
99
+ period: opts.budgetPeriod ?? "month",
100
+ };
101
+ }
102
+ const tools = {};
103
+ for (const k of ["webSearch", "browser", "storage", "delegation", "connections"]) {
104
+ if (opts[k] !== undefined)
105
+ tools[k === "webSearch" ? "web_search" : k] = opts[k];
106
+ }
107
+ if (opts.sandbox !== undefined)
108
+ tools.sandbox = opts.sandbox;
109
+ if (Object.keys(tools).length)
110
+ out.tools = tools;
111
+ if (opts.cron) {
112
+ out.schedule = opts.cron.map((spec) => {
113
+ // `--cron "0 3 * * *=Triage last night's issues"` — the work is required,
114
+ // because a schedule with no work is a bug that fires forever.
115
+ const eq = spec.indexOf("=");
116
+ if (eq === -1) {
117
+ agentError({
118
+ action: "agents.invalid",
119
+ error: {
120
+ code: "invalid_input",
121
+ message: `--cron must be "<5-field cron>=<the work to send>", got "${spec}".`,
122
+ },
123
+ recovery_steps: [{ command: `naive agents create --cron "0 3 * * *=Triage last night's issues"`, description: "The shape" }],
124
+ });
125
+ }
126
+ return { cron: spec.slice(0, eq).trim(), text: spec.slice(eq + 1), tz: opts.tz ?? "UTC" };
127
+ });
128
+ }
129
+ const limits = {};
130
+ if (opts.wakeMs)
131
+ limits.sliceWallMs = Number(opts.wakeMs);
132
+ if (opts.maxTurns)
133
+ limits.maxTurnsPerTask = Number(opts.maxTurns);
134
+ if (opts.maxToolCalls)
135
+ limits.maxToolCallsPerTurn = Number(opts.maxToolCalls);
136
+ if (Object.keys(limits).length)
137
+ out.limits = limits;
138
+ return out;
139
+ }
140
+ export const agentsCmd = new Command("agents")
141
+ .description("Agents (Vetta) — long-horizon agents that run on a schedule, spend a budget, and deliver")
142
+ .addHelpText("after", `
143
+ Subcommands:
144
+ naive agents status Configured, reachable, roster, harness hash
145
+ naive agents create --name n --model m … Create an agent (a budget is REQUIRED)
146
+ naive agents list Roster with status, next wake and cap
147
+ naive agents show <id> One agent, materialised config included
148
+ naive agents update <id> [flags] Any create flag, plus --paused / --no-paused
149
+ naive agents rm <id> Delete (confirm required; --yes in a pipe)
150
+ naive agents run <id> <text...> Send work and stream it
151
+ naive agents job <id> <text...> Send work and return the task
152
+ naive agents tasks <id> The task board — which IS the job queue
153
+ naive agents task <id> <task-id> One unit of work
154
+ naive agents cancel <id> <task-id> Cooperative cancel (never aborts mid-tool)
155
+ naive agents reply <id> <task-id> <text...> Answer a task parked on needs_input
156
+ naive agents logs <id> [--follow] The event trace; --follow tails it live
157
+ naive agents spend <id> Spend against the cap, by component or task
158
+ naive agents deliverables <id> Artifact manifests; --id fetches one
159
+
160
+ The model:
161
+ A turn is bounded; a task is not. The agent wakes on an alarm, does one turn,
162
+ commits, and sleeps. While it sleeps it costs storage and nothing else — which
163
+ is what "idle · no alarm armed" means in \`list\`.
164
+
165
+ Money:
166
+ A budget is REQUIRED at create. An agent with no cap is not creatable, because
167
+ a null cap means no cap AND no per-agent spend record at all. --budget-usd is
168
+ the cap; --max-task-usd is the per-task ceiling and defaults to a tenth of it.
169
+
170
+ Exit codes:
171
+ 0 ok · 1 error · 3 success, but the terminal state is not success.
172
+
173
+ Examples:
174
+ $ naive agents create --name nightly-triage --model anthropic/claude-sonnet-4 \\
175
+ --budget-usd 50 --cron "0 3 * * *=Triage last night's Sentry issues"
176
+ $ naive agents run <id> "What changed in main today?"
177
+ $ naive agents logs <id> --follow --human
178
+ $ naive agents spend <id> --human
179
+ `);
180
+ /* ── 1. status ───────────────────────────────────────────────────────────── */
181
+ agentsCmd
182
+ .command("status")
183
+ .description("Primitive status: configured, reachable, roster, deployed harness hash")
184
+ .action(async () => {
185
+ const resp = await apiRequest("GET", "/v1/agents/status");
186
+ handleApiError("agents.status", resp);
187
+ const s = (resp.data ?? {});
188
+ agentOutput({
189
+ action: "agents.status",
190
+ result: resp.data,
191
+ next_steps: s.configured
192
+ ? [{ command: "naive agents list", description: "The roster" }]
193
+ : [{ command: "naive agents create --help", description: "What creating one needs" }],
194
+ hints: [
195
+ `Configured: ${s.configured ? "yes" : "no"} · reachable: ${s.reachable ? "yes" : "no"}`,
196
+ `Agents: ${s.agents ?? 0}`,
197
+ // A hash the deployment reported, not one this CLI computed. A CLI that
198
+ // computed it would be attesting to a build it has never seen.
199
+ s.harness_sha256 ? `Harness: ${s.harness_sha256}` : "Harness hash unavailable — the runtime did not report one.",
200
+ ...(s.error ? [`Runtime error: ${s.error}`] : []),
201
+ ],
202
+ });
203
+ });
204
+ /* ── 2. create ───────────────────────────────────────────────────────────── */
205
+ agentsCmd
206
+ .command("create")
207
+ .description("Create an agent. A pinned model and a budget are required.")
208
+ .requiredOption("--name <name>", "1–64 chars of [a-zA-Z0-9 _-]")
209
+ .requiredOption("--model <slug>", "A PINNED slug, e.g. anthropic/claude-sonnet-4. Never a family or an alias.")
210
+ .requiredOption("--budget-usd <usd>", "Hard spend cap in US dollars. Required — an uncapped agent is not creatable.")
211
+ .option("--max-task-usd <usd>", "Per-task ceiling. Defaults to a tenth of the cap.")
212
+ .option("--budget-period <p>", "total | day | week | month | year", "month")
213
+ .option("--instructions <text>", "What this agent is for")
214
+ // THREE, not two. The flag always FORWARDED `standard` — it is a free string
215
+ // and the CLI never validated it — so the capability shipped and only
216
+ // `--help` was wrong. asap is dearest and fastest, flex cheapest and slowest,
217
+ // standard the middle that makes it a curve. The server does the refusing:
218
+ // a non-`asap` window on a model that does not declare one comes back
219
+ // `window_unavailable` rather than being quietly served as `asap`.
220
+ .option("--window <w>", "asap (default) | standard | flex — the SCHEDULING TIER, never the model. Tasks inherit it.")
221
+ .option("--delegate-model <slug...>", "Models a sub-agent may run. Empty = delegation off.")
222
+ .option("--cron <spec...>", 'A schedule: "<5-field cron>=<the work to send>"')
223
+ .option("--tz <iana>", "Timezone for --cron", "UTC")
224
+ .option("--web-search", "Allow web search")
225
+ .option("--browser", "Allow the browser")
226
+ .option("--sandbox <mode>", "none | auto | <workspace id>")
227
+ .option("--storage", "Allow writing deliverables to storage")
228
+ .option("--secret <name...>", "Vault entry NAMES to expose. NEVER values.")
229
+ .option("--connection <id...>", "Connection ids to attach")
230
+ .option("--wake-ms <ms>", "Wall budget per wake (30000–840000)")
231
+ .option("--max-turns <n>", "Turns per task")
232
+ .option("--max-tool-calls <n>", "Tool calls per turn (1–32)")
233
+ .action(async (opts) => {
234
+ const resp = await apiRequest("POST", "/v1/agents", body(opts));
235
+ handleApiError("agents.create", resp);
236
+ const a = agentOf(resp.data);
237
+ agentOutput({
238
+ action: "agents.create",
239
+ result: resp.data,
240
+ next_steps: agentSteps(a),
241
+ hints: [
242
+ ...agentHints(a),
243
+ // Said once, at the only moment it changes what someone does next.
244
+ "Creating an agent queues no work. Send some with `naive agents job`.",
245
+ ],
246
+ });
247
+ });
248
+ /* ── 3. list ─────────────────────────────────────────────────────────────── */
249
+ agentsCmd
250
+ .command("list")
251
+ .description("The roster: status, next wake, cap")
252
+ .option("--status <s>", "idle | running | blocked | braked | stopped")
253
+ .option("--limit <n>", "Page size (default 50, max 200)")
254
+ .option("--cursor <c>", "Keyset cursor from a previous page")
255
+ .action(async (opts) => {
256
+ const q = new URLSearchParams();
257
+ for (const k of ["status", "limit", "cursor"])
258
+ if (opts[k])
259
+ q.set(k, opts[k]);
260
+ const qs = q.toString();
261
+ const resp = await apiRequest("GET", `/v1/agents${qs ? `?${qs}` : ""}`);
262
+ handleApiError("agents.list", resp);
263
+ const items = (resp.data ?? {}).items ?? [];
264
+ if (humanMode()) {
265
+ if (!items.length) {
266
+ process.stdout.write("no agents\n");
267
+ }
268
+ for (const a of items) {
269
+ const wake = a.status === "idle" && !a.next_wake_at ? "no alarm armed" : (a.next_wake_at ?? "—");
270
+ process.stdout.write(`${short(a.id).padEnd(9)}${(a.name ?? "").padEnd(24)}${(a.status ?? "").padEnd(9)}` +
271
+ `${(a.model ?? "").padEnd(34)}cap ${usd(a.budget?.cap_micro_usd).padEnd(9)}${wake}\n`);
272
+ }
273
+ }
274
+ agentOutput({
275
+ action: "agents.list",
276
+ result: resp.data,
277
+ next_steps: items[0]?.id ? [{ command: `naive agents show ${items[0].id}`, description: "One agent" }] : [],
278
+ hints: [`${items.length} agent${items.length === 1 ? "" : "s"}`],
279
+ });
280
+ });
281
+ /* ── 4. show ─────────────────────────────────────────────────────────────── */
282
+ agentsCmd
283
+ .command("show <id>")
284
+ .description("One agent, with its MATERIALISED limits (the numbers it will actually run with)")
285
+ .action(async (id) => {
286
+ const resp = await apiRequest("GET", `/v1/agents/${id}`);
287
+ handleApiError("agents.show", resp);
288
+ const a = agentOf(resp.data);
289
+ agentOutput({ action: "agents.show", result: resp.data, next_steps: agentSteps(a), hints: agentHints(a) });
290
+ });
291
+ /* ── 5. update ───────────────────────────────────────────────────────────── */
292
+ agentsCmd
293
+ .command("update <id>")
294
+ .description("Change any create field. `limits` and `tools` merge field by field.")
295
+ .option("--name <name>")
296
+ .option("--instructions <text>")
297
+ .option("--model <slug>")
298
+ .option("--window <w>", "asap | standard | flex — the agent's tier; its tasks inherit it")
299
+ .option("--budget-usd <usd>", "Raise or lower the cap. Raising it un-brakes a braked agent.")
300
+ .option("--max-task-usd <usd>")
301
+ .option("--budget-period <p>")
302
+ .option("--delegate-model <slug...>")
303
+ .option("--secret <name...>")
304
+ .option("--connection <id...>")
305
+ .option("--cron <spec...>")
306
+ .option("--tz <iana>")
307
+ .option("--wake-ms <ms>")
308
+ .option("--max-turns <n>")
309
+ .option("--max-tool-calls <n>")
310
+ /* A HOLD ON EXECUTION, and the help text says both halves because the
311
+ * missing half is what an operator gets wrong. It arms no wake and fires no
312
+ * cron; it does NOT refuse work, and it does not abort a running slice —
313
+ * `naive agents cancel` is that, cooperatively. */
314
+ .option("--paused", "Hold execution: arm no wake and fire no cron. Work sent meanwhile is queued, not refused")
315
+ .option("--no-paused", "Resume, and run whatever was held")
316
+ .action(async (id, opts) => {
317
+ const resp = await apiRequest("PATCH", `/v1/agents/${id}`, body(opts));
318
+ handleApiError("agents.update", resp);
319
+ const a = agentOf(resp.data);
320
+ agentOutput({ action: "agents.update", result: resp.data, next_steps: agentSteps(a), hints: agentHints(a) });
321
+ });
322
+ /* ── 6. rm ───────────────────────────────────────────────────────────────── */
323
+ agentsCmd
324
+ .command("rm <id>")
325
+ .description("Delete an agent: cancel queued work, stop the alarm chain. THE SPEND LEDGER IS KEPT.")
326
+ .option("--yes", "Skip the confirmation (required in a pipe)")
327
+ .action(async (id, opts) => {
328
+ if (!opts.yes) {
329
+ // Refuse rather than prompt. A prompt in a pipe either hangs or is
330
+ // answered by whatever byte arrives next, and this verb stops an agent
331
+ // mid-task.
332
+ agentError({
333
+ action: "agents.rm",
334
+ error: {
335
+ code: "invalid_input",
336
+ message: "Deleting an agent cancels its queued work and stops its alarm chain. Pass --yes to confirm.",
337
+ },
338
+ recovery_steps: [
339
+ { command: `naive agents tasks ${id}`, description: "See what is queued first" },
340
+ { command: `naive agents rm ${id} --yes`, description: "Delete it" },
341
+ ],
342
+ });
343
+ return;
344
+ }
345
+ const resp = await apiRequest("DELETE", `/v1/agents/${id}`);
346
+ handleApiError("agents.rm", resp);
347
+ agentOutput({
348
+ action: "agents.rm",
349
+ result: resp.data,
350
+ next_steps: [{ command: "naive agents list", description: "The remaining roster" }],
351
+ hints: ["The spend ledger is kept — deleting an agent does not change what it cost."],
352
+ });
353
+ });
354
+ /* ── 7–8. send work ──────────────────────────────────────────────────────── */
355
+ function taskFlags(cmd) {
356
+ return cmd
357
+ .option("--mode <m>", "act (default, every granted tool) | ask (read-only)")
358
+ // Shared by `job` and `run`. `taskBody` OMITS the field when the flag is
359
+ // absent, and an omitted window is what the runtime resolves against the
360
+ // agent's `completion_window` — so leaving the flag off INHERITS the agent.
361
+ // It stopped meaning `asap` when the edge stopped resolving the default and
362
+ // started forwarding `undefined`, leaving one component holding it.
363
+ .option("--window <w>", "asap | standard | flex — this task only; omitted inherits the agent")
364
+ .option("--priority <p>", "normal | low")
365
+ .option("--not-before <iso>", "Do not start before this ISO-8601 instant")
366
+ .option("--idempotency-key <k>", "A PERMANENT business key: the same key returns the same task, forever");
367
+ }
368
+ function taskBody(text, opts) {
369
+ return {
370
+ text: text.join(" "),
371
+ mode: opts.mode,
372
+ window: opts.window,
373
+ priority: opts.priority,
374
+ not_before: opts.notBefore,
375
+ idempotency_key: opts.idempotencyKey,
376
+ };
377
+ }
378
+ taskFlags(agentsCmd.command("job <id> <text...>").description("Send work and return the task record"))
379
+ .action(async (id, text, opts) => {
380
+ const resp = await apiRequest("POST", `/v1/agents/${id}/tasks`, taskBody(text, opts));
381
+ handleApiError("agents.job", resp);
382
+ const t = (resp.data ?? {}).task;
383
+ agentOutput({
384
+ action: "agents.job",
385
+ result: resp.data,
386
+ next_steps: t?.id
387
+ ? [
388
+ { command: `naive agents logs ${id} --task ${t.id} --follow`, description: "Watch it" },
389
+ { command: `naive agents task ${id} ${t.id}`, description: "Check on it later" },
390
+ ]
391
+ : [],
392
+ // 202 is a fact worth restating: the row is committed and the alarm is
393
+ // armed before this returned. Nothing is sitting in a client-side queue.
394
+ hints: [`Status: ${t?.status ?? "queued"} — durably queued, alarm armed.`],
395
+ });
396
+ });
397
+ taskFlags(agentsCmd.command("run <id> <text...>").description("Send work and stream it (NDJSON, or --human)"))
398
+ .action(async (id, text, opts) => {
399
+ // 🔴 `flex` IS NOT REFUSED HERE, and the deletion is the point.
400
+ //
401
+ // The refusal was written when `flex` meant submit-then-poll, which has no
402
+ // tokens to stream. Every window now produces a real token stream, so
403
+ // `POST /:id/tasks` accepts and streams all three.
404
+ //
405
+ // The SDK dropped its identical client-side throw for the reason that
406
+ // applies twice as hard to a CLI: a client-side refusal is the worst place
407
+ // for a rule that has stopped being true, because a deploy cannot relax it
408
+ // — only a new install can. This copy outlived the rule by a release, and
409
+ // `naive agents run --window flex` refused a request the route serves.
410
+ //
411
+ // A window the platform cannot serve is still refused — server-side, at the
412
+ // gate, as `window_unavailable`, before a provider is chosen and before
413
+ // anything is spent. That is the right place for it.
414
+ await streamEvents("agents.run", `/v1/agents/${id}/tasks`, "POST", taskBody(text, opts), { follow: false });
415
+ });
416
+ /* ── 9–12. the board ─────────────────────────────────────────────────────── */
417
+ agentsCmd
418
+ .command("tasks <id>")
419
+ .description("The task board — which IS the job queue. One noun, one table.")
420
+ .option("--status <s>", "queued | running | waiting | done | failed | cancelled | braked")
421
+ .option("--source <s>", "api | schedule | webhook | agent | self")
422
+ .option("--since <t>", "ISO-8601")
423
+ .option("--limit <n>")
424
+ .option("--cursor <c>")
425
+ .action(async (id, opts) => {
426
+ const q = new URLSearchParams();
427
+ for (const k of ["status", "source", "since", "limit", "cursor"])
428
+ if (opts[k])
429
+ q.set(k, opts[k]);
430
+ const qs = q.toString();
431
+ const resp = await apiRequest("GET", `/v1/agents/${id}/tasks${qs ? `?${qs}` : ""}`);
432
+ handleApiError("agents.tasks", resp);
433
+ const items = (resp.data ?? {}).items ?? [];
434
+ if (humanMode()) {
435
+ for (const t of items) {
436
+ process.stdout.write(`${short(t.id).padEnd(9)}${String(t.status ?? "").padEnd(11)}` +
437
+ `${String(t.window ?? "").padEnd(6)}t${String(t.turns ?? 0).padEnd(5)}` +
438
+ `${String(t.title ?? t.text ?? "").slice(0, 60)}\n`);
439
+ }
440
+ }
441
+ agentOutput({
442
+ action: "agents.tasks",
443
+ result: resp.data,
444
+ next_steps: items[0]?.id ? [{ command: `naive agents task ${id} ${items[0].id}`, description: "One task" }] : [],
445
+ hints: [`${items.length} task${items.length === 1 ? "" : "s"}`],
446
+ });
447
+ });
448
+ agentsCmd
449
+ .command("task <id> <task-id>")
450
+ .description("One unit of work. Exits 3 when its terminal state is not success.")
451
+ .action(async (id, taskId) => {
452
+ const resp = await apiRequest("GET", `/v1/agents/${id}/tasks/${taskId}`);
453
+ handleApiError("agents.task", resp);
454
+ const t = (resp.data ?? {}).task ?? {};
455
+ const status = String(t.status ?? "");
456
+ agentOutputExit({
457
+ action: "agents.task",
458
+ result: resp.data,
459
+ next_steps: [
460
+ { command: `naive agents logs ${id} --task ${taskId}`, description: "The event trace" },
461
+ { command: `naive agents spend ${id} --task ${taskId}`, description: "The authoritative cost" },
462
+ ],
463
+ hints: [
464
+ // 🔴 `spent_micro_usd` is the runtime's own synchronous counter, which
465
+ // the pre-call budget gate needs. `spend --task` prices from the ledger.
466
+ // The two are never printed in one column, and the `~` says which is
467
+ // which without the reader having to know.
468
+ `Spent (running counter): ~${usd(t.spent_micro_usd)} — authoritative: naive agents spend ${id} --task ${taskId}`,
469
+ ...(t.cancel_requested_at ? [`Cancelling since ${t.cancel_requested_at} — cancel never aborts mid-tool.`] : []),
470
+ ...(t.blocked_reason ? [`Blocked: ${t.blocked_reason}`] : []),
471
+ ],
472
+ // Exit 3, not 1: the command did what it said, and the WORK did not
473
+ // succeed. A CI script reading 0 as "the work is done" would otherwise
474
+ // count a failed task as a pass.
475
+ exitCode: ["failed", "cancelled", "braked"].includes(status) ? 3 : 0,
476
+ });
477
+ });
478
+ agentsCmd
479
+ .command("cancel <id> <task-id>")
480
+ .description("Cooperative cancel — it never aborts mid-tool, so it can take as long as the current tool")
481
+ .action(async (id, taskId) => {
482
+ const resp = await apiRequest("POST", `/v1/agents/${id}/tasks/${taskId}/cancel`, {});
483
+ handleApiError("agents.cancel", resp);
484
+ const t = (resp.data ?? {}).task ?? {};
485
+ agentOutput({
486
+ action: "agents.cancel",
487
+ result: resp.data,
488
+ next_steps: [{ command: `naive agents task ${id} ${taskId}`, description: "Watch it wind down" }],
489
+ hints: [
490
+ "Cancel is cooperative: the task ends after its current tool, and that tool is billed.",
491
+ ...(t.cancel_requested_at ? [`Requested at ${t.cancel_requested_at}`] : []),
492
+ ],
493
+ });
494
+ });
495
+ agentsCmd
496
+ .command("reply <id> <task-id> <text...>")
497
+ .description("Answer a task parked on needs_input. Without this the task is unrecoverable.")
498
+ .action(async (id, taskId, text) => {
499
+ const resp = await apiRequest("POST", `/v1/agents/${id}/tasks/${taskId}/reply`, { text: text.join(" ") });
500
+ handleApiError("agents.reply", resp);
501
+ agentOutput({
502
+ action: "agents.reply",
503
+ result: resp.data,
504
+ next_steps: [{ command: `naive agents logs ${id} --task ${taskId} --follow`, description: "Watch it resume" }],
505
+ hints: ["The alarm is re-armed; the task resumes on its next wake."],
506
+ });
507
+ });
508
+ /* ── 13. logs ────────────────────────────────────────────────────────────── */
509
+ agentsCmd
510
+ .command("logs <id>")
511
+ .description("The event trace. --follow tails it live; --after resumes from a position.")
512
+ .option("--task <task-id>", "Only this task")
513
+ .option("--kind <k>", "Only this event kind")
514
+ .option("--after <seq>", "Resume strictly after this seq. A POSITION, never a time.")
515
+ .option("--limit <n>", "Page size (paged mode only)")
516
+ .option("--follow", "Tail the live stream instead of reading a page")
517
+ .option("--show-args", "Print tool arguments instead of a digest (interactive terminals only)")
518
+ .action(async (id, opts) => {
519
+ const q = new URLSearchParams();
520
+ for (const [flag, key] of [["task", "task"], ["kind", "kind"], ["after", "after"], ["limit", "limit"]]) {
521
+ if (opts[flag])
522
+ q.set(key, String(opts[flag]));
523
+ }
524
+ if (!opts.follow) {
525
+ const qs = q.toString();
526
+ const resp = await apiRequest("GET", `/v1/agents/${id}/events${qs ? `?${qs}` : ""}`);
527
+ handleApiError("agents.logs", resp);
528
+ const page = (resp.data ?? {});
529
+ agentOutput({
530
+ action: "agents.logs",
531
+ result: resp.data,
532
+ next_steps: [{ command: `naive agents logs ${id} --follow`, description: "Tail it live" }],
533
+ hints: [
534
+ `${page.items?.length ?? 0} events`,
535
+ // 🔴 A truncated replay is a FACT ABOUT RETENTION, not a failure — but
536
+ // it must never be silent. The log is the source of truth, so a gap in
537
+ // it that nobody was told about is worse than a loud one.
538
+ ...(page.truncated
539
+ ? [`⚠ replay truncated — earliest retained seq ${page.earliest_seq} (20,000-row cap); starting there`]
540
+ : []),
541
+ ],
542
+ });
543
+ return;
544
+ }
545
+ await streamEvents("agents.logs", `/v1/agents/${id}/events?${q.toString()}`, "GET", undefined, {
546
+ follow: true,
547
+ showArgs: opts.showArgs === true,
548
+ after: opts.after === undefined ? undefined : Number(opts.after),
549
+ });
550
+ });
551
+ /* ── 14. spend ───────────────────────────────────────────────────────────── */
552
+ agentsCmd
553
+ .command("spend <id>")
554
+ .description("Spend against the cap. Every paid component prints, INCLUDING the zeros.")
555
+ .option("--by <b>", "component (default) | task")
556
+ .option("--task <task-id>", "The authoritative per-task figure")
557
+ .action(async (id, opts) => {
558
+ const q = new URLSearchParams();
559
+ if (opts.by)
560
+ q.set("by", opts.by);
561
+ if (opts.task)
562
+ q.set("task", opts.task);
563
+ const qs = q.toString();
564
+ const resp = await apiRequest("GET", `/v1/agents/${id}/spend${qs ? `?${qs}` : ""}`);
565
+ handleApiError("agents.spend", resp);
566
+ const s = (resp.data ?? {});
567
+ if (humanMode()) {
568
+ process.stdout.write(` cap ${usd(s.cap_micro_usd)} per ${s.period ?? "period"}${s.hard ? " (hard)" : ""}\n`);
569
+ for (const g of s.groups ?? []) {
570
+ // A component with a zero is printed WITH its zero. A paid tool absent
571
+ // from this view is exactly the hole that lets one bill unnoticed.
572
+ const why = g.rate_configured === false ? " (rate not configured)" : g.calls === 0 ? " (0 calls)" : "";
573
+ process.stdout.write(` ${g.key.padEnd(14)}${g.credits.padStart(12)} cr${why}\n`);
574
+ }
575
+ process.stdout.write(` ${"total".padEnd(14)}${(s.spent_credits ?? "0.0000").padStart(12)} cr\n`);
576
+ /* 🔴 THE SECOND TOTAL, PRINTED UNDER THE FIRST AND NEVER ADDED TO IT.
577
+ * `total` is what the agent's model calls COST, from the runtime's meter;
578
+ * `billed` is what the credit ledger holds. They differ today because an
579
+ * agent's inference writes no ledger row at all — which is why this view
580
+ * read `0.0000` for agents with real spend before it learned to read the
581
+ * meter. An operator seeing one number would have no way to know which. */
582
+ if (s.billed_credits !== undefined) {
583
+ process.stdout.write(` ${"billed".padEnd(14)}${s.billed_credits.padStart(12)} cr (credit ledger)\n`);
584
+ }
585
+ if (s.metered?.available === false) {
586
+ process.stdout.write(" no agents runtime on this deployment — the ledger is the only figure\n");
587
+ }
588
+ else if ((s.metered?.unpriced_calls ?? 0) > 0) {
589
+ // Counted, and costing nothing, because nobody priced them. Silence
590
+ // here would make an unpriced call indistinguishable from a free one.
591
+ process.stdout.write(` ${s.metered.unpriced_calls} metered call(s) carry no price and add nothing above\n`);
592
+ }
593
+ }
594
+ agentOutput({
595
+ action: "agents.spend",
596
+ result: resp.data,
597
+ next_steps: [
598
+ { command: `naive agents update ${id} --budget-usd <usd>`, description: "Raise the cap (this un-brakes a braked agent)" },
599
+ ],
600
+ // The CLI NEVER computes money: every figure here is the string the API
601
+ // sent. The only number this file derives is the cap, which is a
602
+ // configuration integer, not a ledger sum.
603
+ hints: [
604
+ `Spent ${s.spent_credits ?? "0.0000"} credits against a cap of ${usd(s.cap_micro_usd)}.`,
605
+ ...(s.billed_credits === undefined
606
+ ? []
607
+ : [
608
+ `Metered by the agent's runtime. The credit ledger holds ${s.billed_credits} — model calls are not billed through it. Do not add the two.`,
609
+ ]),
610
+ ],
611
+ });
612
+ });
613
+ /* ── 15. deliverables ────────────────────────────────────────────────────── */
614
+ agentsCmd
615
+ .command("deliverables <id>")
616
+ .description("Artifact manifests. --id fetches one with a fresh download url and inline text.")
617
+ .option("--task <task-id>", "Only this task")
618
+ .option("--id <deliverable-id>", "Fetch one")
619
+ .option("--limit <n>")
620
+ .action(async (id, opts) => {
621
+ if (opts.id) {
622
+ const resp = await apiRequest("GET", `/v1/agents/${id}/deliverables/${opts.id}`);
623
+ handleApiError("agents.deliverables", resp);
624
+ const d = (resp.data ?? {});
625
+ agentOutput({
626
+ action: "agents.deliverables",
627
+ result: resp.data,
628
+ next_steps: [],
629
+ hints: [
630
+ // 🔴 Both halves are honest. A null url is a real state — the
631
+ // deployment may have no storage sink, and then the inline text is
632
+ // the whole artifact. Printing a url that 403s would be worse.
633
+ d.download_url
634
+ ? "download_url expires in about an hour and is unauthenticated once issued — fetch it again rather than caching it."
635
+ : "No download url on this deployment; `content` carries the artifact inline.",
636
+ ...(d.content === null && !d.download_url ? ["This deliverable carries no bytes — check `no_artifact_reason`."] : []),
637
+ ],
638
+ });
639
+ return;
640
+ }
641
+ const q = new URLSearchParams();
642
+ for (const k of ["task", "limit"])
643
+ if (opts[k])
644
+ q.set(k, opts[k]);
645
+ const qs = q.toString();
646
+ const resp = await apiRequest("GET", `/v1/agents/${id}/deliverables${qs ? `?${qs}` : ""}`);
647
+ handleApiError("agents.deliverables", resp);
648
+ const items = (resp.data ?? {}).items ?? [];
649
+ agentOutput({
650
+ action: "agents.deliverables",
651
+ result: resp.data,
652
+ next_steps: items[0]?.id
653
+ ? [{ command: `naive agents deliverables ${id} --id ${items[0].id}`, description: "Fetch it" }]
654
+ : [],
655
+ hints: [`${items.length} deliverable${items.length === 1 ? "" : "s"}`],
656
+ });
657
+ });
658
+ /* ── 16–18. inbound webhooks ─────────────────────────────────────────────── */
659
+ /** 🔴 THE SECRET PRINTS ONCE, AND THE HUMAN LINE SAYS SO. It stays in the JSON
660
+ * envelope — a CLI that hides a value from `--json` is a CLI you cannot script
661
+ * — but a secret scrolled past unremarked is a secret nobody stored, and the
662
+ * next event is a support request for a value that exists nowhere. */
663
+ agentsCmd
664
+ .command("webhook-add <id>")
665
+ .description("Mint an inbound URL for this agent. A signed POST to it wakes the agent.")
666
+ .option("--name <name>", "Label it — which provider is on the other end")
667
+ .action(async (id, opts) => {
668
+ const resp = await apiRequest("POST", `/v1/agents/${id}/webhooks`, opts.name ? { name: opts.name } : {});
669
+ handleApiError("agents.webhook-add", resp);
670
+ const w_ = (resp.data ?? {});
671
+ if (humanMode()) {
672
+ process.stdout.write(` url ${w_.url ?? ""}\n`);
673
+ process.stdout.write(` secret ${w_.secret ?? ""}\n`);
674
+ process.stdout.write(" ⚠ the secret prints once. Store it now; rotate if you lose it.\n");
675
+ }
676
+ agentOutput({
677
+ action: "agents.webhook-add",
678
+ result: resp.data,
679
+ next_steps: [
680
+ { command: `naive agents webhooks ${id}`, description: "List this agent's URLs (never the secrets)" },
681
+ { command: `naive agents logs ${id} --follow`, description: "Watch the agent wake when the first event lands" },
682
+ ],
683
+ hints: [
684
+ "Sign `${unix_seconds}.${raw_body}` with HMAC-SHA256; hex digest in X-Naive-Signature, the same seconds in " +
685
+ "X-Naive-Timestamp. The timestamp is inside the signed content on purpose — that is what bounds a replay to 5m.",
686
+ ],
687
+ });
688
+ });
689
+ agentsCmd
690
+ .command("webhooks <id>")
691
+ .description("The inbound URLs bound to this agent. Secrets are never returned.")
692
+ .action(async (id) => {
693
+ const resp = await apiRequest("GET", `/v1/agents/${id}/webhooks`);
694
+ handleApiError("agents.webhooks", resp);
695
+ const hooks = (resp.data ?? {}).webhooks ?? [];
696
+ if (humanMode()) {
697
+ if (hooks.length === 0)
698
+ process.stdout.write(" no inbound URLs. `naive agents webhook-add <id>` mints one.\n");
699
+ // `never fired` separates "the provider never called" from "we called and
700
+ // dropped" — the first question when someone reports a dead webhook.
701
+ for (const h of hooks) {
702
+ const seen = h.last_event_at ? String(h.last_event_at) : "never fired";
703
+ process.stdout.write(` ${String(h.id ?? "")} ${String(h.url ?? "")} ${h.active ? "active" : "inactive"} ${seen}\n`);
704
+ }
705
+ }
706
+ agentOutput({
707
+ action: "agents.webhooks",
708
+ result: resp.data,
709
+ next_steps: hooks[0]?.id
710
+ ? [{ command: `naive agents webhook-rm ${id} ${String(hooks[0].id)}`, description: "Take it down" }]
711
+ : [{ command: `naive agents webhook-add ${id}`, description: "Mint one" }],
712
+ hints: [`${hooks.length} inbound URL${hooks.length === 1 ? "" : "s"}`],
713
+ });
714
+ });
715
+ agentsCmd
716
+ .command("webhook-rm <id> <hook-id>")
717
+ .description("Take an inbound URL down. Refuses if it removed nothing.")
718
+ .action(async (id, hookId) => {
719
+ const resp = await apiRequest("DELETE", `/v1/agents/${id}/webhooks/${hookId}`);
720
+ // 404 here is the honest answer, not a nuisance: "the URL is dead" and "the
721
+ // URL is still live and I no longer control it" must not share a response.
722
+ handleApiError("agents.webhook-rm", resp);
723
+ agentOutput({
724
+ action: "agents.webhook-rm",
725
+ result: resp.data,
726
+ next_steps: [{ command: `naive agents webhooks ${id}`, description: "Confirm it is gone" }],
727
+ hints: ["The URL now 404s. Anything still posting to it is told nothing exists there."],
728
+ });
729
+ });
730
+ /* ── the stream ──────────────────────────────────────────────────────────── */
731
+ /**
732
+ * One streaming implementation for `run` and `logs --follow`.
733
+ *
734
+ * NDJSON, one object per line, terminated by an end frame with a reason and a
735
+ * status — never a JSON array, because a stream has no closing bracket, and
736
+ * never `[DONE]`, which belongs to the OpenAI-compatible lane.
737
+ *
738
+ * The SSE parsing and the redaction are `lib/stream-frames.ts`, unchanged: a
739
+ * second parser in one binary is a second place for a chunk boundary to lose an
740
+ * event, and the redactor already knows both secret shapes that can appear
741
+ * inside an arbitrary tool argument.
742
+ */
743
+ async function streamEvents(action, path, method, payload, opts) {
744
+ // `--show-args` requires an interactive terminal, so a pipe or a CI job
745
+ // cannot turn it on — the same rule `naive teams watch` applies, and for the
746
+ // same reason: tool arguments are the one place an unaudited value arrives.
747
+ const showArgs = opts.showArgs === true && process.stdout.isTTY === true;
748
+ const config = loadConfig();
749
+ const headers = {
750
+ Authorization: `Bearer ${getApiKey()}`,
751
+ Accept: "text/event-stream",
752
+ ...(method === "POST" ? { "Content-Type": "application/json" } : {}),
753
+ };
754
+ /**
755
+ * 🔴 THE SUBJECT, WHICH THIS FUNCTION USED TO DROP ON THE FLOOR.
756
+ *
757
+ * Every other agents command goes through `apiRequest`, which rewrites
758
+ * `/v1/agents/...` to `/v1/users/<subject>/agents/...` and attaches the
759
+ * subject and project headers. `streamEvents` calls `fetch` directly, and it
760
+ * did neither — so `naive agents run` and `naive agents logs --follow`, the
761
+ * two commands an operator uses to WATCH an agent, were the only two that
762
+ * reached the COMPANY mount.
763
+ *
764
+ * That is not a cosmetic difference. On the company mount `subjectUserId` is
765
+ * empty, so the second half of `scopedAgent`'s predicate —
766
+ * `if (scope.tenantUserId && row.vetta.tenantUserId !== scope.tenantUserId)
767
+ * throw notFound()` — is INERT, and a company-wide key tails any agent in the
768
+ * company, including another team's. The event log carries the transcript.
769
+ * R25 exists precisely to stop that, and these two commands walked around it.
770
+ *
771
+ * It could not be seen from `agents-command.test.ts`, which mocks
772
+ * `apiRequest` — the very function that does the rewrite — and never reaches
773
+ * this code path at all.
774
+ */
775
+ const subject = activeUserId();
776
+ if (subject)
777
+ headers["X-Naive-User-Id"] = subject;
778
+ const project = activeProjectId();
779
+ if (project)
780
+ headers["X-Naive-Project-Id"] = project;
781
+ const scopedPath = scopePathToUser(path, subject);
782
+ // A POSITION, never a time. The server replays strictly greater.
783
+ if (opts.after !== undefined)
784
+ headers["Last-Event-ID"] = String(opts.after);
785
+ let response;
786
+ try {
787
+ response = await fetch(`${config.base_url}${scopedPath}`, {
788
+ method,
789
+ headers,
790
+ body: payload ? JSON.stringify(payload) : undefined,
791
+ });
792
+ }
793
+ catch (error) {
794
+ agentError({
795
+ action,
796
+ error: { code: "upstream_error", message: error instanceof Error ? error.message : String(error) },
797
+ recovery_steps: [{ command: "naive agents status", description: "Is the runtime reachable?" }],
798
+ });
799
+ return;
800
+ }
801
+ if (!response.ok || !response.body) {
802
+ let data = {};
803
+ try {
804
+ data = JSON.parse(await response.text());
805
+ }
806
+ catch {
807
+ /* a non-JSON body falls through to the generic branch */
808
+ }
809
+ handleApiError(action, { status: response.status, data });
810
+ return;
811
+ }
812
+ const decoder = new TextDecoder();
813
+ let buffer = "";
814
+ let cursor = opts.after;
815
+ let finalStatus;
816
+ let ended = false;
817
+ for await (const chunk of response.body) {
818
+ buffer += decoder.decode(chunk, { stream: true });
819
+ const { events, rest } = parseSse(buffer);
820
+ buffer = rest;
821
+ for (const ev of events) {
822
+ let frame;
823
+ try {
824
+ frame = JSON.parse(ev.data);
825
+ }
826
+ catch {
827
+ continue; // a keep-alive or a comment, not a frame
828
+ }
829
+ cursor = highestSeq(cursor, frame);
830
+ const f = frame;
831
+ if (f.kind === "task_finished") {
832
+ finalStatus = f.data?.status ?? finalStatus;
833
+ ended = true;
834
+ }
835
+ if (humanMode())
836
+ renderHumanEvent(frame, showArgs);
837
+ else
838
+ process.stdout.write(`${JSON.stringify(redactFrame(frame, { showArgs }))}\n`);
839
+ // Without --follow the command stops at the terminal frame. With it, the
840
+ // caller has asked to keep watching past this task — which is the right
841
+ // default for something left open on a second monitor, because an agent's
842
+ // NORMAL state is silence between wakes.
843
+ if (ended && !opts.follow)
844
+ break;
845
+ }
846
+ if (ended && !opts.follow)
847
+ break;
848
+ }
849
+ // Anything still in `buffer` is a partial event that never got its blank
850
+ // line. Half a JSON object is worse than none, so it is dropped deliberately.
851
+ if (!humanMode()) {
852
+ process.stdout.write(`${JSON.stringify({ type: "end", reason: ended ? "task_terminal" : "stream_closed", status: finalStatus ?? null, seq: cursor ?? null })}\n`);
853
+ }
854
+ // Exit 3 on a terminal state that is not success — the work is what the
855
+ // caller cares about, and exit 0 would report a failed task as a pass.
856
+ if (finalStatus && ["failed", "cancelled", "braked"].includes(finalStatus))
857
+ process.exitCode = 3;
858
+ }
859
+ /**
860
+ * The human renderer. Append-only, no spinner and no cursor movement, so
861
+ * `| tee` and scrollback both work.
862
+ */
863
+ function renderHumanEvent(frame, showArgs) {
864
+ const f = redactFrame(frame, { showArgs });
865
+ const t = (f.at ?? "").slice(11, 19) || "--:--:--";
866
+ const d = f.data ?? {};
867
+ const w = process.stdout.write.bind(process.stdout);
868
+ switch (f.kind) {
869
+ case "text_delta":
870
+ w(String(d.text ?? ""));
871
+ return;
872
+ case "tool_call":
873
+ // `initiator` is printed and NEVER totalled against the other kind.
874
+ // Summing agent-chosen and harness-fired calls is what once let a null
875
+ // (zero agent-chosen calls) read as a positive.
876
+ w(`\n${t} ▸ ${String(d.name ?? "").padEnd(12)}${String(d.argsPreview ?? "")} (${String(d.initiator ?? "?")})\n`);
877
+ return;
878
+ case "tool_result":
879
+ w(`${t} ${String(d.status ?? "")} ${String(d.preview ?? "").slice(0, 100)}\n`);
880
+ return;
881
+ case "turn_started":
882
+ w(`${t} turn ${String(d.turn ?? "")} · ${String(d.model ?? "")}${Number(d.depth ?? 0) > 0 ? ` · depth ${d.depth}` : ""}\n`);
883
+ return;
884
+ case "usage": {
885
+ // 🔴 A NULL TIER RENDERS AS `—`, NEVER AS 0. Null means NOT MEASURED, and
886
+ // a column containing one must never be summed.
887
+ const tier = (k) => (d[k] === null || d[k] === undefined ? "—" : String(d[k]));
888
+ w(`${t} in ${tier("input")} · cache_read ${tier("cacheRead")} · cw5m ${tier("cacheWrite5m")} · cw1h ${tier("cacheWrite1h")} · out ${tier("output")} · ${String(d.window ?? "")}\n`);
889
+ return;
890
+ }
891
+ case "budget": {
892
+ // `create` prints the per-task ceiling, so the stream prints what that
893
+ // ceiling has left. Printed only when one is declared (`0` ⇒ none), so an
894
+ // agent without one reads exactly as this line did before.
895
+ const ceiling = Number(d.maxTaskMicroUsd ?? 0);
896
+ const perTask = ceiling > 0 ? ` · this task ${usd(Number(d.taskMicroUsd ?? 0))} of ${usd(ceiling)}` : "";
897
+ w(`${t} budget ${usd(Number(d.spentMicroUsd ?? 0))} of ${usd(Number(d.capMicroUsd ?? 0))}${perTask}\n`);
898
+ return;
899
+ }
900
+ case "delivered":
901
+ w(`${t} ✓ delivered${d.final ? " (final)" : ""} · ${String(d.summary ?? "")}\n`);
902
+ return;
903
+ case "tools_offered":
904
+ w(`${t} tools offered: ${d.tools?.length ?? 0} · ${String(d.totalChars ?? "")} chars\n`);
905
+ return;
906
+ case "task_finished":
907
+ w(`${t} ■ ${String(d.status ?? "")}\n`);
908
+ return;
909
+ case "error":
910
+ w(`${t} ✗ ${String(d.message ?? "")}${d.retryable ? " (retryable)" : ""}\n`);
911
+ return;
912
+ default:
913
+ w(`${t} ${f.kind ?? "?"}\n`);
914
+ }
915
+ }
916
+ //# sourceMappingURL=agents.js.map