@oxygen-agent/cli 1.922.14 → 1.948.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 (65) hide show
  1. package/README.md +1 -1
  2. package/dist/admin-primary-providers-render.d.ts +18 -0
  3. package/dist/admin-primary-providers-render.js +371 -0
  4. package/dist/command-manifest.js +30 -2
  5. package/dist/functions-commands.d.ts +6 -0
  6. package/dist/functions-commands.js +56 -0
  7. package/dist/help.js +1 -0
  8. package/dist/http-client.d.ts +4 -0
  9. package/dist/http-client.js +49 -2
  10. package/dist/index.js +515 -92
  11. package/dist/ugc-commands.d.ts +6 -0
  12. package/dist/ugc-commands.js +1089 -0
  13. package/dist/visual-commands.d.ts +6 -0
  14. package/dist/visual-commands.js +57 -0
  15. package/dist/visual-render-wait.d.ts +3 -0
  16. package/dist/visual-render-wait.js +56 -0
  17. package/node_modules/@oxygen/shared/dist/byok-connect.d.ts +48 -0
  18. package/node_modules/@oxygen/shared/dist/byok-connect.js +92 -0
  19. package/node_modules/@oxygen/shared/dist/capability-discovery.js +77 -13
  20. package/node_modules/@oxygen/shared/dist/email-dsn.d.ts +60 -0
  21. package/node_modules/@oxygen/shared/dist/email-dsn.js +120 -0
  22. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.d.ts +64 -0
  23. package/node_modules/@oxygen/shared/dist/email-warmup-readiness.js +90 -0
  24. package/node_modules/@oxygen/shared/dist/feature-gates.d.ts +2 -0
  25. package/node_modules/@oxygen/shared/dist/feature-gates.js +3 -0
  26. package/node_modules/@oxygen/shared/dist/index.d.ts +10 -0
  27. package/node_modules/@oxygen/shared/dist/index.js +10 -0
  28. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +50 -21
  29. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +47 -21
  30. package/node_modules/@oxygen/shared/dist/knowledge-constants.d.ts +1 -1
  31. package/node_modules/@oxygen/shared/dist/knowledge-constants.js +3 -2
  32. package/node_modules/@oxygen/shared/dist/knowledge-seed-content.js +1 -1
  33. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +8 -3
  34. package/node_modules/@oxygen/shared/dist/langfuse.js +185 -121
  35. package/node_modules/@oxygen/shared/dist/llm-payload.d.ts +10 -0
  36. package/node_modules/@oxygen/shared/dist/llm-payload.js +54 -0
  37. package/node_modules/@oxygen/shared/dist/llm-usage.d.ts +11 -0
  38. package/node_modules/@oxygen/shared/dist/llm-usage.js +30 -0
  39. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +6 -0
  40. package/node_modules/@oxygen/shared/dist/object-storage.js +5 -0
  41. package/node_modules/@oxygen/shared/dist/product-analytics-core.d.ts +98 -0
  42. package/node_modules/@oxygen/shared/dist/product-analytics-core.js +159 -0
  43. package/node_modules/@oxygen/shared/dist/product-analytics-environment.d.ts +18 -0
  44. package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +46 -0
  45. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +92 -0
  46. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +96 -0
  47. package/node_modules/@oxygen/shared/dist/provider-balance-signal.d.ts +113 -0
  48. package/node_modules/@oxygen/shared/dist/provider-balance-signal.js +158 -0
  49. package/node_modules/@oxygen/shared/dist/sequence-failures.d.ts +12 -0
  50. package/node_modules/@oxygen/shared/dist/sequence-failures.js +24 -0
  51. package/node_modules/@oxygen/shared/dist/sequences.d.ts +16 -0
  52. package/node_modules/@oxygen/shared/dist/sequences.js +54 -0
  53. package/node_modules/@oxygen/shared/dist/ugc.d.ts +133 -0
  54. package/node_modules/@oxygen/shared/dist/ugc.js +2 -0
  55. package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.d.ts +9 -0
  56. package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.js +33 -0
  57. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  58. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  59. package/node_modules/@oxygen/shared/dist/visual-render.d.ts +30 -0
  60. package/node_modules/@oxygen/shared/dist/visual-render.js +55 -0
  61. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +43 -0
  62. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +126 -0
  63. package/node_modules/@oxygen/shared/package.json +10 -0
  64. package/node_modules/@oxygen/workflows/dist/graph/lint.js +22 -0
  65. package/package.json +1 -1
package/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.922.14
37
+ Version: 1.948.1
@@ -0,0 +1,18 @@
1
+ /** Age of an ISO stamp as a short human span, measured against `now`. */
2
+ export declare function ago(iso: unknown, now?: Date): string;
3
+ export type RenderOptions = {
4
+ /** Binary name to print inside suggested commands. */
5
+ binary?: string;
6
+ now?: Date;
7
+ };
8
+ /**
9
+ * Render the serialized primary-provider board as a human board.
10
+ *
11
+ * Mirrors the field groups `serializePrimaryProviderBoard` emits: freshness,
12
+ * summary, platform guards, one block per managed provider (posture with EVERY
13
+ * reason, health, split 7d traffic, 30d recency, balance with staleness and
14
+ * runway, COGS, rate policy, ceilings, breaker, links), the customer-keyed
15
+ * rows, routed intents with no primary grouped by why, and unresolved registry
16
+ * slugs.
17
+ */
18
+ export declare function renderPrimaryProviderBoard(payload: unknown, options?: RenderOptions): string;
@@ -0,0 +1,371 @@
1
+ // Human rendering for `oxygen admin primary-providers`.
2
+ //
3
+ // The board answers three operator questions — does each primary provider work,
4
+ // what does it cost us, and what stops a runaway — and until this file existed
5
+ // the CLI answered them with ~1,500 lines of raw JSON while the MCP tool printed
6
+ // a worst-first summary. Same route, same payload, two completely different
7
+ // things to read. This renders the same facts the MCP text summary and the web
8
+ // board render, so the three surfaces agree in front of a human, not only in
9
+ // JSON.
10
+ //
11
+ // Every field is read defensively: the CLI receives untyped JSON from a server
12
+ // that may be older or newer than the binary, and a board that silently drops a
13
+ // column an operator is looking for is the exact failure this file exists to
14
+ // stop. Unknown/absent reads as "—", never as zero.
15
+ function obj(value) {
16
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
17
+ }
18
+ function arr(value) {
19
+ return Array.isArray(value) ? value : [];
20
+ }
21
+ function str(value) {
22
+ return typeof value === "string" && value.length > 0 ? value : null;
23
+ }
24
+ function num(value) {
25
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
26
+ }
27
+ function count(value) {
28
+ const parsed = num(value);
29
+ return parsed === null ? "—" : parsed.toLocaleString("en-US");
30
+ }
31
+ function usd(value) {
32
+ const parsed = num(value);
33
+ if (parsed === null)
34
+ return "—";
35
+ if (parsed === 0)
36
+ return "$0";
37
+ return parsed < 1
38
+ ? `$${parsed.toFixed(4)}`
39
+ : `$${parsed.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
40
+ }
41
+ function pct(value) {
42
+ const parsed = num(value);
43
+ if (parsed === null)
44
+ return "—";
45
+ if (parsed > 0 && parsed < 0.01)
46
+ return "<1%";
47
+ return `${Math.round(parsed * 100)}%`;
48
+ }
49
+ /** Age of an ISO stamp as a short human span, measured against `now`. */
50
+ export function ago(iso, now = new Date()) {
51
+ const raw = str(iso);
52
+ if (!raw)
53
+ return "never";
54
+ const at = new Date(raw);
55
+ if (Number.isNaN(at.getTime()))
56
+ return "never";
57
+ const delta = Math.round((now.getTime() - at.getTime()) / 1000);
58
+ // A reset date is in the future; clamping it to "0s ago" reads as "just
59
+ // happened", which is the opposite of what it says.
60
+ const seconds = Math.abs(delta);
61
+ const suffix = delta < 0 ? "from now" : "ago";
62
+ if (seconds < 90)
63
+ return `${seconds}s ${suffix}`;
64
+ const minutes = Math.round(seconds / 60);
65
+ if (minutes < 90)
66
+ return `${minutes}m ${suffix}`;
67
+ const hours = seconds / 3600;
68
+ if (hours < 48)
69
+ return `${hours.toFixed(1)}h ${suffix}`;
70
+ return `${Math.round(hours / 24)}d ${suffix}`;
71
+ }
72
+ function windowLabel(seconds) {
73
+ const parsed = num(seconds);
74
+ if (parsed === 60)
75
+ return "/min";
76
+ if (parsed === 3600)
77
+ return "/h";
78
+ if (parsed === 86400)
79
+ return "/day";
80
+ return parsed === null ? "" : `/${parsed}s`;
81
+ }
82
+ const POSTURE_RANK = { critical: 0, attention: 1, ok: 2, customer_keyed: 3 };
83
+ function postureRank(level) {
84
+ return level === null ? 4 : (POSTURE_RANK[level] ?? 4);
85
+ }
86
+ /**
87
+ * One snapshot's freshness as a sentence. `as_of` on the board is render time;
88
+ * this is how old the data behind a column actually is — the fact `--refresh`
89
+ * exists to fix, so it prints above the provider table, not in a footnote.
90
+ */
91
+ function freshnessPhrase(label, block, now) {
92
+ const row = obj(block);
93
+ if (!row)
94
+ return `${label} —`;
95
+ const asOf = str(row.as_of);
96
+ if (!asOf)
97
+ return `${label} never`;
98
+ const via = str(row.refreshed_via);
99
+ const level = str(row.level) ?? "";
100
+ return `${label} as of ${asOf}${via ? ` via ${via}` : ""} (${ago(asOf, now)}${level ? `, ${level}` : ""})`;
101
+ }
102
+ const STAGE_BY_SNAPSHOT = { health: "health", balance: "balances", cost: "costs" };
103
+ /**
104
+ * Render the serialized primary-provider board as a human board.
105
+ *
106
+ * Mirrors the field groups `serializePrimaryProviderBoard` emits: freshness,
107
+ * summary, platform guards, one block per managed provider (posture with EVERY
108
+ * reason, health, split 7d traffic, 30d recency, balance with staleness and
109
+ * runway, COGS, rate policy, ceilings, breaker, links), the customer-keyed
110
+ * rows, routed intents with no primary grouped by why, and unresolved registry
111
+ * slugs.
112
+ */
113
+ export function renderPrimaryProviderBoard(payload, options = {}) {
114
+ const now = options.now ?? new Date();
115
+ const binary = options.binary ?? "oxygen";
116
+ const board = obj(payload);
117
+ if (!board)
118
+ return "No board returned.";
119
+ const lines = [];
120
+ const out = (line = "") => lines.push(line);
121
+ out(`Primary managed providers — as of ${str(board.as_of) ?? "—"}`);
122
+ const freshness = obj(board.freshness);
123
+ if (freshness) {
124
+ out(`Snapshots: ${[
125
+ freshnessPhrase("health", freshness.health, now),
126
+ freshnessPhrase("balances", freshness.balance, now),
127
+ freshnessPhrase("costs", freshness.cost, now),
128
+ ].join(" · ")}`);
129
+ // Name the stale halves explicitly with the command that fixes them: a
130
+ // frozen board otherwise keeps announcing itself as current.
131
+ const stale = Object.entries(STAGE_BY_SNAPSHOT)
132
+ .filter(([key]) => {
133
+ const level = str(obj(freshness[key])?.level);
134
+ return level === "frozen" || level === "never";
135
+ })
136
+ .map(([, stage]) => stage);
137
+ if (stale.length > 0) {
138
+ out(` ! ${stale.join(", ")} ${stale.length === 1 ? "is" : "are"} not current — re-run: ` +
139
+ `${binary} admin primary-providers --refresh --stages ${stale.join(",")}`);
140
+ }
141
+ }
142
+ const refresh = obj(board.refresh);
143
+ if (refresh) {
144
+ const ran = arr(refresh.ran).map((stage) => String(stage));
145
+ const skipped = arr(refresh.skipped).map((stage) => String(stage));
146
+ const duration = num(refresh.duration_ms);
147
+ out(`Refreshed: ${ran.length > 0 ? ran.join(", ") : "nothing"}` +
148
+ (skipped.length > 0 ? ` · skipped ${skipped.join(", ")}` : "") +
149
+ (duration === null ? "" : ` · ${(duration / 1000).toFixed(1)}s`));
150
+ for (const entry of arr(refresh.errors)) {
151
+ const error = obj(entry);
152
+ if (!error)
153
+ continue;
154
+ out(` ! ${str(error.stage) ?? "stage"} failed: ${str(error.message) ?? "unknown error"}`);
155
+ }
156
+ }
157
+ const summary = obj(board.summary) ?? {};
158
+ out("");
159
+ out(`${count(summary.providers)} providers — ${count(summary.managed)} managed, ${count(summary.customer_keyed)} customer-keyed · ` +
160
+ `${count(summary.critical)} critical, ${count(summary.attention)} attention, ${count(summary.ok)} ok` +
161
+ ((num(summary.never_probed) ?? 0) > 0 ? `, ${count(summary.never_probed)} never probed` : "") +
162
+ ((num(summary.traffic_not_instrumented) ?? 0) > 0
163
+ ? `, ${count(summary.traffic_not_instrumented)} traffic not instrumented`
164
+ : "") +
165
+ ((num(summary.fallback_providers) ?? 0) > 0
166
+ ? ` · ${count(summary.fallback_providers)} default-chain fallback${(num(summary.fallback_providers) ?? 0) === 1 ? "" : "s"}` +
167
+ ((num(summary.fallback_critical) ?? 0) > 0 ? ` (${count(summary.fallback_critical)} critical)` : "")
168
+ : ""));
169
+ out(`COGS (managed primaries): 30d ${usd(summary.cost_30d_usd)} · 7d ${usd(summary.cost_7d_usd)}`);
170
+ out(renderGuards(obj(board.guards)));
171
+ const providers = arr(board.providers)
172
+ .map((row) => obj(row))
173
+ .filter((row) => row !== null)
174
+ .sort((a, b) => postureRank(str(obj(a.posture)?.level)) - postureRank(str(obj(b.posture)?.level)) ||
175
+ (str(a.provider) ?? "").localeCompare(str(b.provider) ?? ""));
176
+ const managed = providers.filter((row) => str(row.credential) === "managed");
177
+ const customerKeyed = providers.filter((row) => str(row.credential) !== "managed");
178
+ out("");
179
+ out("Managed primaries (worst first):");
180
+ if (managed.length === 0)
181
+ out(" (none)");
182
+ for (const row of managed)
183
+ out(renderProvider(row, now, binary));
184
+ if (customerKeyed.length > 0) {
185
+ out("");
186
+ out("Primary but customer-keyed (OXYGEN holds no managed key — these lanes run on the workspace's own key):");
187
+ for (const row of customerKeyed)
188
+ out(renderProvider(row, now, binary));
189
+ }
190
+ // A dry fallback account is invisible until a primary fails, which is
191
+ // exactly when nobody has time to notice it — so it renders here too.
192
+ const fallbacks = arr(board.default_chain_fallbacks)
193
+ .map((row) => obj(row))
194
+ .filter((row) => row !== null)
195
+ .sort((a, b) => postureRank(str(obj(a.posture)?.level)) - postureRank(str(obj(b.posture)?.level)) ||
196
+ (str(a.provider) ?? "").localeCompare(str(b.provider) ?? ""));
197
+ if (fallbacks.length > 0) {
198
+ out("");
199
+ out("Default-chain fallbacks (not primary anywhere — a routed chain reaches them when its primary fails):");
200
+ for (const row of fallbacks)
201
+ out(renderProvider(row, now, binary));
202
+ }
203
+ const intents = arr(board.intents_without_primary)
204
+ .map((row) => obj(row))
205
+ .filter((row) => row !== null);
206
+ if (intents.length > 0) {
207
+ out("");
208
+ out("Routed intents with no declared primary:");
209
+ for (const [kind, copy] of Object.entries(INTENT_KIND_COPY)) {
210
+ const group = intents.filter((row) => (str(row.kind) ?? "fallback_only") === kind);
211
+ if (group.length === 0)
212
+ continue;
213
+ out(` ${copy}`);
214
+ for (const row of group) {
215
+ out(` - ${str(row.function) ?? "?"}/${str(row.intent) ?? "?"} (${str(row.input_shape) ?? "?"})` +
216
+ ` → ${str(row.first_routed_tool_id) ?? "nothing routed"}`);
217
+ }
218
+ }
219
+ }
220
+ const unresolved = arr(board.unresolved_providers)
221
+ .map((row) => obj(row))
222
+ .filter((row) => row !== null);
223
+ if (unresolved.length > 0) {
224
+ out("");
225
+ out(`! ${unresolved.length} routing-registry provider slug${unresolved.length === 1 ? "" : "s"} PROVIDER_INFO cannot resolve:`);
226
+ for (const row of unresolved) {
227
+ out(` - ${str(row.provider) ?? "?"} (${str(row.tool_id) ?? "?"}, ${str(row.intent) ?? "?"})`);
228
+ }
229
+ }
230
+ const webUrl = str(board.web_url) ?? str(board.deepLink);
231
+ if (webUrl) {
232
+ out("");
233
+ out(`Open: ${webUrl}`);
234
+ }
235
+ return lines.join("\n");
236
+ }
237
+ const INTENT_KIND_COPY = {
238
+ fallback_only: "fallback only — paid routed steps exist but none is seeded primary (the real gap):",
239
+ preflight_only: "preflight only — every step is a free sizing/count probe (by design):",
240
+ no_routed_chain: "no routed chain — a ratified refusal, not a hole:",
241
+ };
242
+ function renderGuards(guards) {
243
+ if (!guards)
244
+ return "Guards: —";
245
+ const spend = obj(guards.managed_spend) ?? {};
246
+ const loop = arr(spend.loop_detector);
247
+ const breakerMs = num(guards.managed_provider_credit_breaker_window_ms);
248
+ const orgDaily = obj(guards.org_daily_guard);
249
+ const parts = [
250
+ `spend limiter ${spend.halted ? "HALTED" : spend.enforcing ? "enforcing" : "shadow"} (${count(spend.hard_limits)}/${count(spend.total_limits)} ceilings block, ${count(spend.open_breakers)} breakers open)`,
251
+ `loop detector ${loop.length === 0
252
+ ? "not seeded"
253
+ : loop.some((row) => str(obj(row)?.action) === "block")
254
+ ? "armed"
255
+ : "warn only"}`,
256
+ `credit breaker ${breakerMs === 0 ? "off" : breakerMs === null ? "—" : `${Math.round(breakerMs / 60_000)} min`}`,
257
+ `trigger caps ${str(obj(guards.trigger_default_caps)?.mode) ?? "—"}`,
258
+ `BYOK daily caps ${str(obj(guards.byok)?.daily_cap_mode) ?? "—"}`,
259
+ ];
260
+ if (orgDaily) {
261
+ parts.push(`org-daily guard warn ${count(orgDaily.warn_multiple_of_monthly_grant)}× / block ${count(orgDaily.block_multiple_of_monthly_grant)}× monthly grant`);
262
+ }
263
+ return `Guards: ${parts.join(" · ")}`;
264
+ }
265
+ function renderProvider(row, now, binary) {
266
+ const lines = [];
267
+ const posture = obj(row.posture) ?? {};
268
+ const health = obj(row.health) ?? {};
269
+ const traffic = obj(row.traffic_7d) ?? {};
270
+ const recent = obj(row.recent) ?? {};
271
+ const cost = obj(row.cost) ?? {};
272
+ const limits = obj(row.limits) ?? {};
273
+ const links = obj(row.links) ?? {};
274
+ const provider = str(row.provider) ?? "?";
275
+ const level = str(posture.level) ?? "unknown";
276
+ const functions = arr(row.functions).map((fn) => String(fn));
277
+ const probe = str(health.probe) ?? "none";
278
+ const healthStatus = str(health.latest_status) ?? (probe === "none" ? "unmonitored" : "no data");
279
+ const healthDetail = [
280
+ `${probe} probe`,
281
+ ago(health.last_checked_at, now),
282
+ num(health.uptime_pct_96h) === null ? null : `${pct(health.uptime_pct_96h)} up 96h`,
283
+ (num(health.degraded_pct_96h) ?? 0) > 0 ? `${pct(health.degraded_pct_96h)} degraded` : null,
284
+ (num(health.auth_error_pct_96h) ?? 0) > 0 ? `${pct(health.auth_error_pct_96h)} auth-error` : null,
285
+ num(health.last_latency_ms) === null ? null : `${count(health.last_latency_ms)} ms`,
286
+ ].filter((part) => part !== null);
287
+ lines.push(` - ${provider} [${level}]${functions.length > 0 ? ` ${functions.join("+")}` : ""} · ` +
288
+ `key ${row.key_configured === false ? `NOT SET (${str(row.managed_key_env_var) ?? "—"})` : str(row.managed_key_env_var) ?? "customer-supplied"} · ` +
289
+ `health ${healthStatus} (${healthDetail.join(", ")})`);
290
+ const scope = str(traffic.scope) ?? "workspace_attributed";
291
+ const trafficLine = row.traffic_7d === undefined
292
+ ? "7d —"
293
+ : traffic.instrumented === false
294
+ ? `7d ${count(traffic.calls)} calls (NOT instrumented — this provider spent money but wrote no request ledger rows)`
295
+ : `7d ${count(traffic.calls)} calls · ${count(traffic.failures)} fail · ${count(traffic.not_found)} no-match(404) · ` +
296
+ `${count(traffic.vendor_rate_limited)} vendor-429 · ${count(traffic.self_paced_blocks)} self-paced · ` +
297
+ `${count(traffic.payment_failures)} 402 · ${count(traffic.credit_refusals)} credit-refused · ` +
298
+ `${count(traffic.workspaces)} ws · p95 ${num(traffic.duration_p95_ms) === null ? "—" : `${count(traffic.duration_p95_ms)} ms`} · ` +
299
+ `${count(traffic.credits_estimate)} cr (${scope.replace(/_/g, " ")})`;
300
+ lines.push(` ${trafficLine} | last ok ${ago(recent.last_ok_at, now)} · last fail ${ago(recent.last_failure_at, now)} (${count(recent.lookback_days)}d)`);
301
+ lines.push(` ${balancePhrase(obj(row.balance), now)} | ${costPhrase(cost)}`);
302
+ const policies = arr(limits.rate_policies).map((policy) => obj(policy)).filter((p) => p !== null);
303
+ const firstPolicy = policies[0];
304
+ const ceilings = arr(limits.ceilings).map((ceiling) => obj(ceiling)).filter((c) => c !== null);
305
+ const worstCeiling = [...ceilings].sort((a, b) => (num(b.utilization) ?? -1) - (num(a.utilization) ?? -1))[0];
306
+ const breaker = obj(limits.breaker);
307
+ lines.push(` rate ${firstPolicy
308
+ ? `${count(firstPolicy.limit)}${windowLabel(firstPolicy.window_seconds)}` +
309
+ `${num(firstPolicy.max_in_flight) === null ? "" : ` · ${count(firstPolicy.max_in_flight)} in flight`}` +
310
+ ` · ${firstPolicy.window_expired === true ? "idle" : `${count(firstPolicy.current)} this window`}` +
311
+ `${(num(firstPolicy.rate_limited_hits_24h) ?? 0) > 0 ? ` · ${count(firstPolicy.rate_limited_hits_24h)} 429s/24h` : ""}` +
312
+ `${policies.length > 1 ? ` · +${policies.length - 1} more` : ""}`
313
+ : "NONE — unpaced"} | ceiling ${worstCeiling
314
+ ? `${pct(worstCeiling.utilization)} of ${ceilingLimit(worstCeiling)} (${str(worstCeiling.action) ?? "?"})${ceilings.length > 1 ? ` · +${ceilings.length - 1} more` : ""}`
315
+ : "none (global + category only)"} | breaker ${str(breaker?.manual_override) ?? str(breaker?.state) ?? "closed"}`);
316
+ // Every reason, never a slice: the third reason is routinely the one that
317
+ // explains the posture (a stale balance under a failing key).
318
+ for (const reason of arr(posture.reasons))
319
+ lines.push(` ! ${String(reason)}`);
320
+ const linkParts = [
321
+ str(links.pricing_url) ? `pricing ${str(links.pricing_url)}` : null,
322
+ str(links.rate_limit_url) ? `rate limits ${str(links.rate_limit_url)}` : null,
323
+ str(links.subscription_url) ? `subscription ${str(links.subscription_url)}` : null,
324
+ ].filter((part) => part !== null);
325
+ if (linkParts.length > 0)
326
+ lines.push(` ${linkParts.join(" · ")}`);
327
+ if (level === "critical" || level === "attention") {
328
+ lines.push(` halt: ${binary} admin spend halt --scope provider --key ${provider}`);
329
+ }
330
+ return lines.join("\n");
331
+ }
332
+ function ceilingLimit(ceiling) {
333
+ const parts = [
334
+ num(ceiling.limit_usd) === null ? null : usd(ceiling.limit_usd),
335
+ num(ceiling.limit_calls) === null ? null : `${count(ceiling.limit_calls)} calls`,
336
+ ].filter((part) => part !== null);
337
+ return `${parts.join(" / ") || "—"}${windowLabel(ceiling.window_seconds)}`;
338
+ }
339
+ function balancePhrase(balance, now) {
340
+ if (!balance)
341
+ return "balance no balance API";
342
+ const status = str(balance.status) ?? "unknown";
343
+ const head = status === "ok"
344
+ ? num(balance.remaining) === null
345
+ ? "balance —"
346
+ : `balance ${count(balance.remaining)} ${str(balance.unit) ?? ""}`.trim()
347
+ : `balance ${status.replace(/_/g, " ")}`;
348
+ const detail = [
349
+ str(balance.plan_name),
350
+ num(balance.usd) === null ? null : usd(balance.usd),
351
+ num(balance.runway_days) === null ? null : `${(num(balance.runway_days) ?? 0).toFixed(1)}d runway`,
352
+ str(balance.resets_at) ? `resets ${ago(balance.resets_at, now)}` : null,
353
+ // Age is printed even when a reset date exists: a reset stamp says nothing
354
+ // about how old the number in front of it is.
355
+ `fetched ${ago(balance.fetched_at, now)}${balance.stale === true ? " (STALE)" : ""}`,
356
+ str(balance.error_message),
357
+ ].filter((part) => part !== null && part !== "");
358
+ return detail.length > 0 ? `${head} (${detail.join(", ")})` : head;
359
+ }
360
+ function costPhrase(cost) {
361
+ const source30 = str(cost.actual_source_30d);
362
+ const source7 = str(cost.actual_source_7d);
363
+ const detail = [
364
+ `30d ${source30 ? `actual (${source30})` : "modeled"}`,
365
+ `7d ${source7 ? `actual (${source7})` : "modeled"}`,
366
+ str(cost.pricing_model),
367
+ num(cost.monthly_usd) === null ? null : `${usd(cost.monthly_usd)}/mo flat`,
368
+ num(cost.cost_per_provider_credit_usd) === null ? null : `${usd(cost.cost_per_provider_credit_usd)}/cr`,
369
+ ].filter((part) => part !== null);
370
+ return `COGS 30d ${usd(cost.total_30d_usd)} / 7d ${usd(cost.total_7d_usd)} (${detail.join(", ")})`;
371
+ }
@@ -25,6 +25,7 @@ const COMMAND_SEARCH_STOP_WORDS = new Set([
25
25
  // leaf in the tree using that verb, so the default (mutates:false) would have
26
26
  // advertised the most consequential collaboration write as a read.
27
27
  const MUTATING_VERBS = new Set([
28
+ "brand-approve", "revoke-invite", "confirm",
28
29
  "accept", "ack", "add", "adopt", "apply", "approve", "archive",
29
30
  "assign", "attach",
30
31
  // `linkedin intent autoenroll` arms (or revokes) a STANDING grant to contact
@@ -83,14 +84,27 @@ const MUTATING_COMMANDS = new Set([
83
84
  "mailboxes warmup reconnect",
84
85
  ]);
85
86
  // `--approved` usually identifies a paid or externally mutating execution
86
- // mode, but a small number of destructive local operations are explicitly
87
+ // mode, but a small number of approved operations are explicitly
87
88
  // zero-credit. Keep those exceptions exact so discovery never invents spend.
88
89
  const ZERO_CREDIT_APPROVAL_COMMANDS = new Set([
90
+ "tables watcher preview",
91
+ // Creator invitation emails need exact send authorization but cost 0 credits.
92
+ "ugc creators invite",
93
+ "ugc creators send-invite",
94
+ // These approvals record an observed outcome or stop future renewal;
95
+ // neither operation buys a service or consumes Oxygen credits.
96
+ "ugc amplification reconcile",
97
+ "ugc sponsorship cancel",
98
+ // A Function binding stores a cap for future runs; creating it executes nothing.
99
+ "functions bind",
89
100
  // Starting an attended session itself is free. This legacy compatibility
90
101
  // flag neither authorizes nor caps later inference; only `copilot send` can
91
102
  // trigger the billed model loop.
92
103
  "copilot start",
93
104
  "mailboxes delete",
105
+ // Previews without --approved; with it, removes a program no creator ever
106
+ // joined. No provider call, no credits.
107
+ "ugc programs delete",
94
108
  // This approval only reopens exact fingerprint-bound tenant rows. It never
95
109
  // calls the provider, dispatches an action, or touches the credit ledger.
96
110
  "sequences recover-capacity",
@@ -105,11 +119,25 @@ const ZERO_CREDIT_APPROVAL_COMMANDS = new Set([
105
119
  // Exact noun-style reads whose leaf happens to look like a mutating verb.
106
120
  // `workflows run <id>` gets an existing Workflow run; `workflows call` creates
107
121
  // one. Keep the exception exact so genuine `run` actions remain mutations.
108
- const READ_ONLY_COMMANDS = new Set(["workflows run"]);
122
+ const READ_ONLY_COMMANDS = new Set([
123
+ "workflows run",
124
+ // Inspect existing creator receipts; never generate or import again.
125
+ "ugc posts draft-status",
126
+ "ugc posts history",
127
+ ]);
109
128
  // Exact commands whose execution fence is not named `--live`. A bare
110
129
  // invocation still previews, so machine-readable discovery must not imply that
111
130
  // calling the command without its confirmation flag writes anything.
112
131
  const PREVIEW_BY_DEFAULT_COMMANDS = new Set([
132
+ "tables watcher preview",
133
+ "ugc creators invite",
134
+ "ugc creators send-invite",
135
+ "ugc posts draft",
136
+ "ugc programs delete",
137
+ "ugc voice import",
138
+ "ugc sponsorship preview",
139
+ "ugc sponsorship cancel",
140
+ "ugc amplification reconcile",
113
141
  // Fenced by --approved, not --live: a bare call resolves the sender, sequence,
114
142
  // audience and caps and writes nothing. Without this entry discovery would tell
115
143
  // an agent that previewing the play arms it, and the preview would go unrun.
@@ -0,0 +1,6 @@
1
+ import { Command } from "commander";
2
+ type JsonOptions = {
3
+ json?: boolean;
4
+ };
5
+ export declare function registerFunctionsCommands(program: Command, handle: (command: string, options: JsonOptions, action: () => Promise<unknown>) => Promise<void>): void;
6
+ export {};
@@ -0,0 +1,56 @@
1
+ import { OxygenError } from "@oxygen/shared";
2
+ import { parseJsonObject } from "./cli-values.js";
3
+ import { requestOxygen } from "./http-client.js";
4
+ export function registerFunctionsCommands(program, handle) {
5
+ const functions = program.command("functions")
6
+ .description("Create, edit, publish and reuse Functions backed by ordinary Tables.")
7
+ .addHelpText("after", "\nDraft edits are isolated. Publishing updates future invocations of every caller; queued, in-flight and past runs retain their captured version. Existing caller caps never increase automatically.\nFlow: draft → describe → publish → bind → columns run --dry-run → approved run → runs.\n");
8
+ functions.command("list").description("List the Function library, including draft and disabled Functions (up to 500).")
9
+ .option("--json", "Print a JSON envelope.")
10
+ .action((options) => handle("functions list", options, () => requestOxygen("/api/cli/functions")));
11
+ functions.command("describe <function>").description("Inspect a Function's draft, published version, inputs, outputs and credit caps.")
12
+ .option("--json", "Print a JSON envelope.")
13
+ .action((ref, options) => handle("functions describe", options, () => requestOxygen(`/api/cli/functions?${new URLSearchParams({ function: ref })}`)));
14
+ functions.command("draft [function]").description("Create a Function draft, or save an existing Function's draft; executes nothing.")
15
+ .requiredOption("--definition-json <json>", "Draft object: {table,display_name,description?,inputs:[{column,key,required}],action_columns:[column],outputs:[{column,key,required}],default_max_credits_per_item,default_max_items?}. Saving requires display_name and the full contract.")
16
+ .option("--expected-draft-hash <hash>", "Reject saving if the draft has changed since inspection.")
17
+ .option("--json", "Print a JSON envelope.")
18
+ .action((ref, options) => handle("functions draft", options, () => requestOxygen("/api/cli/functions", { method: "POST", body: {
19
+ ...parseJsonObject(options.definitionJson),
20
+ operation: ref ? "save_draft" : "create_draft",
21
+ function: ref,
22
+ ...(options.expectedDraftHash ? { expected_draft_hash: options.expectedDraftHash } : {}),
23
+ } })));
24
+ functions.command("publish <function>").description("Publish the inspected draft for every caller's future invocations; preserves captured runs and caller caps.")
25
+ .requiredOption("--expected-draft-hash <hash>", "function.draft.contentHash returned by draft or describe; rejects concurrent edits.")
26
+ .option("--json", "Print a JSON envelope.")
27
+ .action((ref, options) => handle("functions publish", options, () => requestOxygen("/api/cli/functions", { method: "POST", body: { operation: "publish", function: ref, expected_draft_hash: options.expectedDraftHash } })));
28
+ functions.command("runs <function>").description("Inspect a Function's latest 100 invocations and their captured versions.")
29
+ .option("--json", "Print a JSON envelope.")
30
+ .action((ref, options) => handle("functions runs", options, () => requestOxygen(`/api/cli/functions/runs?${new URLSearchParams({ function: ref })}`)));
31
+ functions.command("bind <table> <function>").description("Add a Function column with explicit input/output mappings and a caller credit cap.")
32
+ .requiredOption("--inputs-json <json>", "Object mapping Function input keys to caller column keys.")
33
+ .requiredOption("--outputs-json <json>", "Object mapping Function output keys to caller column keys.")
34
+ .requiredOption("--max-credits-per-item <n>", "Hard credit ceiling for each caller row; future publishes cannot raise it.")
35
+ .option("--overwrite-policy <policy>", "Callback writes: empty_only or overwrite.", "empty_only")
36
+ .option("--key <key>", "Function column key.").option("--label <label>", "Function column label.")
37
+ .option("--json", "Print a JSON envelope.")
38
+ .action((table, ref, options) => handle("functions bind", options, () => {
39
+ const cap = Number(options.maxCreditsPerItem);
40
+ if (!Number.isFinite(cap) || cap <= 0)
41
+ throw new OxygenError("invalid_number", "Expected a positive credit ceiling.");
42
+ if (!["empty_only", "overwrite"].includes(options.overwritePolicy))
43
+ throw new OxygenError("invalid_request", "Overwrite policy must be empty_only or overwrite.");
44
+ return requestOxygen("/api/cli/callables/bind", { method: "POST", body: {
45
+ table, callable: ref,
46
+ input_mapping: parseJsonObject(options.inputsJson), output_mapping: parseJsonObject(options.outputsJson),
47
+ max_credits_per_item: cap,
48
+ overwrite_policy: options.overwritePolicy, key: options.key, label: options.label,
49
+ } });
50
+ }));
51
+ for (const [command, status] of [["enable", "active"], ["disable", "disabled"]]) {
52
+ functions.command(`${command} <function>`).description(`${command === "enable" ? "Enable" : "Disable"} future Function invocations; captured runs are unchanged.`)
53
+ .option("--json", "Print a JSON envelope.")
54
+ .action((ref, options) => handle(`functions ${command}`, options, () => requestOxygen("/api/cli/functions", { method: "POST", body: { operation: "set_status", function: ref, status } })));
55
+ }
56
+ }
package/dist/help.js CHANGED
@@ -137,6 +137,7 @@ export function applyOxygenHelp(program, binaryName) {
137
137
  // OXYGEN_API_KEY never runs it — without this step nothing on any
138
138
  // first-touch surface names the skill that teaches the GTM loops.
139
139
  ` 2. ${binaryName} skills install --json load the skills that teach the GTM loops (automatic after login)`,
140
+ " Add --project to keep the skills inside the current project, including confined agent sessions.",
140
141
  ` 3. ${binaryName} context resolve --json load workspace context before operating primitives`,
141
142
  ` 4. ${binaryName} capabilities search "<goal>" --json route the goal, then hydrate one exact command`,
142
143
  ` 5. ${binaryName} recipes list --json choose a play from the Recipe catalog for your goal`,
@@ -1,6 +1,8 @@
1
1
  import { type StoredCredentials } from "./credentials.js";
2
2
  import { type CliEndpoint } from "./runtime.js";
3
3
  type RequestOptions = {
4
+ /** Internal bounded file download; never follows a caller-supplied URL. */
5
+ binaryMaxBytes?: number;
4
6
  method?: "GET" | "POST" | "PATCH" | "DELETE";
5
7
  body?: Record<string, unknown>;
6
8
  formData?: FormData;
@@ -8,6 +10,8 @@ type RequestOptions = {
8
10
  requireAuth?: boolean;
9
11
  enforceMinimumCliVersion?: boolean;
10
12
  timeoutMs?: number;
13
+ /** Caller-owned deadline for multi-request operations such as render waiting. */
14
+ signal?: AbortSignal;
11
15
  traceId?: string;
12
16
  fetch?: typeof fetch;
13
17
  selectedOrganization?: string;
@@ -7,6 +7,7 @@ const CLI_COMPATIBILITY_CHECK_TIMEOUT_MS = 5_000;
7
7
  const cliCompatibilityCheckKeys = new Set();
8
8
  export async function requestOxygen(// skipcq: JS-R1005
9
9
  path, options = {}) {
10
+ options.signal?.throwIfAborted();
10
11
  const loaded = options.credentials === undefined && options.requireAuth !== false
11
12
  ? await loadCredentialsWithEndpoint()
12
13
  : null;
@@ -25,10 +26,14 @@ path, options = {}) {
25
26
  // freshness probe, or the first thing a mispointed prod runbook gets is a
26
27
  // version complaint from dev instead of the host mismatch that explains it.
27
28
  assertResolvedApiUrl(endpoint);
28
- const fetchImpl = options.fetch ?? fetch;
29
+ const baseFetch = options.fetch ?? fetch;
30
+ const fetchImpl = options.signal ? (input, init) => baseFetch(input, {
31
+ ...init, signal: init?.signal ? AbortSignal.any([options.signal, init.signal]) : options.signal,
32
+ }) : baseFetch;
29
33
  if (path !== "/api/health" && shouldCheckCliCompatibility(options)) {
30
34
  await ensureFreshCliForApiUrl(apiUrl, { fetch: fetchImpl, endpoint });
31
35
  }
36
+ options.signal?.throwIfAborted();
32
37
  const traceId = options.traceId ?? randomUUID();
33
38
  const headers = {
34
39
  Accept: "application/json",
@@ -68,15 +73,24 @@ path, options = {}) {
68
73
  const requestInit = {
69
74
  method: options.method ?? "GET",
70
75
  headers,
76
+ ...(options.binaryMaxBytes === undefined ? {} : { redirect: "error" }),
71
77
  ...(options.body ? { body: JSON.stringify(options.body) } : {}),
72
78
  ...(options.formData ? { body: options.formData } : {}),
73
79
  };
74
- if (timeout.signal)
80
+ if (options.signal)
81
+ requestInit.signal = timeout.signal
82
+ ? AbortSignal.any([options.signal, timeout.signal]) : options.signal;
83
+ else if (timeout.signal)
75
84
  requestInit.signal = timeout.signal;
76
85
  try {
77
86
  response = await fetchImpl(`${apiUrl}${path}`, requestInit);
87
+ if (response.ok && options.binaryMaxBytes !== undefined) {
88
+ return await readBoundedBinaryResponse(response, options.binaryMaxBytes);
89
+ }
78
90
  }
79
91
  catch (error) {
92
+ if (error instanceof OxygenError)
93
+ throw error;
80
94
  if (timeout.timedOut() || (timeoutMs !== undefined && isAbortError(error))) {
81
95
  throw new OxygenError("network_timeout", "Oxygen API request timed out.", {
82
96
  details: {
@@ -116,6 +130,39 @@ path, options = {}) {
116
130
  }
117
131
  return envelope.data;
118
132
  }
133
+ async function readBoundedBinaryResponse(response, maxBytes) {
134
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 50 * 1024 * 1024)
135
+ throw new OxygenError("invalid_request", "Invalid file download limit.");
136
+ const length = response.headers.get("content-length");
137
+ if (length && Number(length) > maxBytes)
138
+ throw new OxygenError("file_too_large", "The file exceeds its download limit.");
139
+ const reader = response.body?.getReader();
140
+ if (!reader)
141
+ throw new OxygenError("invalid_response", "The download had no body.");
142
+ const chunks = [];
143
+ let size = 0;
144
+ try {
145
+ for (;;) {
146
+ const { value, done } = await reader.read();
147
+ if (done)
148
+ break;
149
+ size += value.byteLength;
150
+ if (size > maxBytes)
151
+ throw new OxygenError("file_too_large", "The file exceeds its download limit.");
152
+ chunks.push(value);
153
+ }
154
+ }
155
+ finally {
156
+ await reader.cancel();
157
+ }
158
+ const bytes = new Uint8Array(size);
159
+ let offset = 0;
160
+ for (const chunk of chunks) {
161
+ bytes.set(chunk, offset);
162
+ offset += chunk.byteLength;
163
+ }
164
+ return bytes;
165
+ }
119
166
  export async function ensureFreshCliForApiUrl(apiUrl, options = {}) {
120
167
  const checkKey = `${apiUrl}|${OXYGEN_VERSION}`;
121
168
  if (cliCompatibilityCheckKeys.has(checkKey))