@phnx-labs/agents-cli 1.22.55 → 1.22.57

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 (71) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +4 -4
  3. package/dist/bootstrap.js +3 -1
  4. package/dist/cli/command-registry.d.ts +0 -1
  5. package/dist/cli/command-registry.js +0 -3
  6. package/dist/commands/exec.js +1 -1
  7. package/dist/commands/hooks.js +4 -4
  8. package/dist/commands/insights.d.ts +7 -5
  9. package/dist/commands/insights.js +16 -9
  10. package/dist/commands/perf.d.ts +16 -7
  11. package/dist/commands/perf.js +29 -20
  12. package/dist/commands/rules.js +1 -1
  13. package/dist/commands/share.js +7 -6
  14. package/dist/commands/ssh.js +24 -14
  15. package/dist/commands/trash.d.ts +2 -2
  16. package/dist/commands/trash.js +2 -6
  17. package/dist/commands/versions.d.ts +2 -2
  18. package/dist/commands/versions.js +1 -10
  19. package/dist/commands/view.d.ts +2 -2
  20. package/dist/commands/view.js +7 -6
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +9 -0
  23. package/dist/lib/accounting/usage-ingest.d.ts +1 -0
  24. package/dist/lib/accounting/usage-ingest.js +75 -0
  25. package/dist/lib/accounting/usage-sync.d.ts +69 -0
  26. package/dist/lib/accounting/usage-sync.js +129 -0
  27. package/dist/lib/accounting/usage.d.ts +48 -2
  28. package/dist/lib/accounting/usage.js +72 -1
  29. package/dist/lib/agent-spec/agents.js +1 -1
  30. package/dist/lib/analytics/mix-commands.d.ts +8 -7
  31. package/dist/lib/analytics/mix-commands.js +50 -73
  32. package/dist/lib/daemon/daemon.js +5 -0
  33. package/dist/lib/daemon/runner.js +9 -8
  34. package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
  35. package/dist/lib/daemon/usage-sync-service.js +36 -0
  36. package/dist/lib/daemon-services.d.ts +1 -1
  37. package/dist/lib/daemon-services.js +5 -0
  38. package/dist/lib/device-config.d.ts +17 -6
  39. package/dist/lib/device-config.js +25 -11
  40. package/dist/lib/devices/pool.d.ts +4 -3
  41. package/dist/lib/devices/pool.js +13 -5
  42. package/dist/lib/exec.d.ts +6 -41
  43. package/dist/lib/exec.js +6 -41
  44. package/dist/lib/git.d.ts +13 -1
  45. package/dist/lib/git.js +36 -7
  46. package/dist/lib/harness/adapter.d.ts +7 -7
  47. package/dist/lib/harness/adapters/claude.js +3 -2
  48. package/dist/lib/hosts/remote-cmd.d.ts +9 -0
  49. package/dist/lib/hosts/remote-cmd.js +22 -0
  50. package/dist/lib/overdue.js +7 -37
  51. package/dist/lib/perf/db.d.ts +1 -1
  52. package/dist/lib/perf/db.js +1 -1
  53. package/dist/lib/scheduler.d.ts +21 -2
  54. package/dist/lib/scheduler.js +28 -5
  55. package/dist/lib/scheduling/routines.d.ts +21 -0
  56. package/dist/lib/scheduling/routines.js +56 -0
  57. package/dist/lib/session/active.d.ts +3 -31
  58. package/dist/lib/session/active.js +8 -68
  59. package/dist/lib/session/db.d.ts +4 -35
  60. package/dist/lib/session/db.js +4 -35
  61. package/dist/lib/session/discover.d.ts +6 -58
  62. package/dist/lib/session/discover.js +5 -43
  63. package/dist/lib/session/parse.d.ts +1 -19
  64. package/dist/lib/session/parse.js +2 -15
  65. package/dist/lib/share/provision.d.ts +3 -2
  66. package/dist/lib/share/provision.js +9 -4
  67. package/dist/lib/share/worker-template.d.ts +24 -1
  68. package/dist/lib/share/worker-template.js +65 -84
  69. package/dist/lib/startup/command-registry.d.ts +8 -2
  70. package/dist/lib/startup/command-registry.js +12 -4
  71. package/package.json +1 -1
@@ -3390,15 +3390,9 @@ export function topSessionsByCost(n, options = {}) {
3390
3390
  durationMs: r.duration_ms ?? 0,
3391
3391
  }));
3392
3392
  }
3393
- /** Look up a single session by its unique ID. */
3394
3393
  /**
3395
- * Batch-resolve session ids to the machine each one runs on, in ONE indexed
3396
- * query. `getActiveSessions` needs only this column for every live row, and
3397
- * `getSessionById` would re-`prepare` a `SELECT *` and materialize a full
3398
- * `SessionMeta` per id to read it — mirrors {@link findSessionsByShortIds}'s
3399
- * single-round-trip pattern. Ids absent from the index are simply absent from
3400
- * the map. Best-effort: an unavailable DB yields an empty map, so the live view
3401
- * still renders (the caller then leaves rows attributed to this box).
3394
+ * Read machine attribution in batches without materializing full sessions.
3395
+ * Failure is best-effort so the live view can still render local attribution.
3402
3396
  */
3403
3397
  export function findSessionMachinesByIds(ids) {
3404
3398
  const out = new Map();
@@ -3429,20 +3423,7 @@ export function getSessionById(id) {
3429
3423
  const row = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id);
3430
3424
  return row ? rowToMeta(row) : null;
3431
3425
  }
3432
- /**
3433
- * Resolve a full-or-partial session id against the index, exact-first then
3434
- * prefix — the DB-backed equivalent of resolveSessionById() that runs over the
3435
- * SQLite table instead of a pre-loaded array. Matches both the full id and the
3436
- * short id. An exact hit short-circuits so a complete id never also drags in its
3437
- * prefix siblings. `scope` narrows by agent / version / project (cwd) so an
3438
- * ambiguous prefix disambiguates against the caller's context.
3439
- *
3440
- * Routes through the full querySessions existence check (NOT skipExistenceCheck)
3441
- * on purpose (RUSH-2436): that check now KEEPS a file-gone session whose user
3442
- * turns still live in session_text (flagged archived) and only suppresses a
3443
- * contentless phantom — so `agents sessions <id>` resolves an archived session
3444
- * instead of failing with "No session found", while a phantom id still misses.
3445
- */
3426
+ /** Exact ids win over prefixes; the normal existence check preserves archived content but excludes phantoms. */
3446
3427
  export function findSessionsById(idQuery, scope = {}) {
3447
3428
  const q = idQuery.trim();
3448
3429
  if (!q)
@@ -3452,19 +3433,7 @@ export function findSessionsById(idQuery, scope = {}) {
3452
3433
  return exact;
3453
3434
  return querySessions({ ...scope, idPrefix: q });
3454
3435
  }
3455
- /**
3456
- * Batch-resolve many 8-char short ids to their sessions in ONE indexed query.
3457
- * The live-scan path (listTmuxAgentSessions) turns every `ag-<agent>-<shortid>`
3458
- * tmux pane name back into a full session id this way, so it pays a single
3459
- * `short_id IN (…)` round-trip per scan instead of N per-pane lookups.
3460
- *
3461
- * Returns a map keyed by short_id (lowercased). Short ids are the first 8 chars
3462
- * of the lowercase session UUID (deriveShortId), so a lowercased `IN` matches and
3463
- * still uses idx_sessions_short_id. When several sessions share a short id — only
3464
- * time-ordered ids (ULID/UUIDv7) ever collide; random UUIDv4 short ids are unique
3465
- * in practice — the most-recently-active one wins (the caller can further
3466
- * disambiguate by cwd).
3467
- */
3436
+ /** Batch-resolve pane short ids; on collision the most recently active session wins. */
3468
3437
  export function findSessionsByShortIds(shortIds) {
3469
3438
  const out = new Map();
3470
3439
  const uniq = [...new Set(shortIds.map((s) => s.trim().toLowerCase()).filter(Boolean))];
@@ -54,14 +54,7 @@ export interface DiscoverOptions {
54
54
  idExact?: string;
55
55
  /** Session id prefix — a targeted indexed lookup with no scan (RUSH-2477). */
56
56
  idPrefix?: string;
57
- /**
58
- * Cold-miss repair: when another live process already holds the scan claim,
59
- * wait (bounded) for that in-flight scan to finish before reading the index,
60
- * instead of returning the pre-scan snapshot (RUSH-2682). A caller repairing a
61
- * "not indexed yet" miss wants the fresh result the concurrent scan is about to
62
- * write, not the stale read that just missed. Ignored when THIS process wins
63
- * the claim (it scans itself) or no scan is in progress.
64
- */
57
+ /** On a cold miss, briefly await the scan already holding the single-flight claim. */
65
58
  waitForScan?: boolean;
66
59
  }
67
60
  /** Progress report emitted during incremental scanning. */
@@ -181,41 +174,15 @@ export declare function discoverSessions(options?: DiscoverOptions): Promise<Ses
181
174
  interface IncrementalScanResult {
182
175
  /** True when this process won the single-flight claim and ran the scan. */
183
176
  claimed: boolean;
184
- /**
185
- * Transcripts parsed this scan — i.e. those whose (mtime, size) changed. Zero
186
- * is the steady state on an idle box and does NOT mean the scan was skipped;
187
- * read `claimed` for that.
188
- *
189
- * Twelve of the 13 SESSION_AGENTS contribute, including OpenCode, whose
190
- * scanner filters to sessions whose own per-session stamp changed and reports
191
- * that batch — so a tick whose only changed sessions live there no longer
192
- * reports 0 (RUSH-2691). OpenClaw is the exception and contributes nothing:
193
- * its scanner has no change detection to report (a TTL gate, a fresh stamp
194
- * every run, and an entry list rebuilt as the current inventory), so counting
195
- * it would overstate rather than measure. See `scanOpenClawIncremental`.
196
- */
177
+ /** Changed transcripts parsed; zero with `claimed: true` is a successful no-op scan. */
197
178
  scanned: number;
198
179
  }
199
- /**
200
- * The write half of {@link discoverSessions}: claim the single-flight scan slot,
201
- * incrementally index this host's transcript dirs, and report what was parsed.
202
- *
203
- * Split out so a caller that only wants the index refreshed — the daemon's warm
204
- * tick — can run it WITHOUT the listing query `discoverSessions` ends with. That
205
- * query is not free: it applies a cwd filter, runs the `archived_at`-writing
206
- * existence check, and can issue a Linear fetch, none of which index anything
207
- * (RUSH-2691). Keeping one implementation here is also what stops the tick and
208
- * the foreground path from drifting apart.
209
- */
180
+ /** Separate write half so daemon warming does not pay for listing or external enrichment. */
210
181
  export declare function scanSessionsIncremental(options?: {
211
182
  agent?: SessionAgentId;
212
183
  onProgress?: (p: ScanProgress) => void;
213
184
  }): Promise<IncrementalScanResult>;
214
- /**
215
- * Poll until no live process holds the scan claim, or the bound elapses
216
- * (RUSH-2682). Bounded so a wedged/slow scan can never hang a foreground preview.
217
- * Exported for the cold-miss repair test.
218
- */
185
+ /** Bounded wait so a wedged scan cannot hang a foreground cold-miss repair. */
219
186
  export declare function waitForScanToSettle(timeoutMs?: number, pollMs?: number): Promise<boolean>;
220
187
  /** Read the current SQLite snapshot without scanning or parsing transcript files. */
221
188
  export declare function queryIndexedSessions(options?: DiscoverOptions, indexedOptions?: {
@@ -223,27 +190,8 @@ export declare function queryIndexedSessions(options?: DiscoverOptions, indexedO
223
190
  skipExistenceCheck?: boolean;
224
191
  }): Promise<SessionMeta[]>;
225
192
  /**
226
- * Resolve a full-or-partial session id against the LOCAL SQLite index only.
227
- *
228
- * A plain WAL read through `queryIndexedSessions` — same origin-machine
229
- * attribution and managed scoping every indexed read gets — with NO incremental
230
- * discovery scan (so none of `tryClaimScan`/`releaseScan`'s `BEGIN IMMEDIATE`
231
- * writer lock) and NO fleet SSH fan-out. This is the crash-restart storm path
232
- * (RUSH-2477): dozens of `sessions resume <id>` at once for a known local id must
233
- * each be a cheap read, never a writer-lock contender or a dial into the
234
- * not-yet-up tailnet. Exact id first, then prefix (matching `findSessionsById`),
235
- * so a complete id never also drags in its prefix siblings. Returns `[]` on a
236
- * genuine local miss, leaving the caller to fall back to the fleet resolver.
237
- *
238
- * The existence check is left ON (`skipExistenceCheck: false`), exactly as the old
239
- * `discoverSessions` path and `findSessionsById` do (RUSH-2436): it KEEPS a
240
- * file-gone session whose user turns still live in `session_text` (flagged
241
- * archived) and SUPPRESSES a contentless phantom — so a phantom id misses here and
242
- * falls through to the fleet resolver, instead of resolving to a row with no real
243
- * transcript to resume. For a present transcript — the storm's actual case, since
244
- * the crashed tabs' files are on disk — the check does no writes, so the lock-free
245
- * guarantee holds; it only writes to (un)archive a genuinely missing or resurrected
246
- * file, which is not the 20-at-once resume path.
193
+ * Resolve locally without scanning or fleet I/O, keeping concurrent crash recovery lock-light.
194
+ * The existence check preserves archived content while rejecting transcriptless phantoms.
247
195
  */
248
196
  export declare function resolveIndexedSessionById(idQuery: string): Promise<SessionMeta[]>;
249
197
  /**
@@ -131,12 +131,7 @@ async function applyJsonlAppend(filePath, fromOffset, wasDroppingOversizedLine,
131
131
  * between `agents sessions` calls but is still being written to.
132
132
  */
133
133
  const HOT_FILE_WINDOW_MS = 600_000;
134
- /**
135
- * Kill-switch: set `AGENTS_SESSIONS_NO_DIR_LEDGER=1` to force the old full-walk
136
- * path (readdir + per-file stat every dir, every run — the pre-A-2 behavior),
137
- * skipping the dir_ledger short-circuit entirely. One env var reverts a field
138
- * regression to today's behavior.
139
- */
134
+ /** Emergency kill-switch for the directory-ledger optimization. */
140
135
  function dirLedgerDisabled() {
141
136
  const v = process.env.AGENTS_SESSIONS_NO_DIR_LEDGER;
142
137
  return v === '1' || v === 'true';
@@ -175,17 +170,7 @@ export async function discoverSessions(options) {
175
170
  skipExistenceCheck: options?.skipExistenceCheck ?? false,
176
171
  });
177
172
  }
178
- /**
179
- * The write half of {@link discoverSessions}: claim the single-flight scan slot,
180
- * incrementally index this host's transcript dirs, and report what was parsed.
181
- *
182
- * Split out so a caller that only wants the index refreshed — the daemon's warm
183
- * tick — can run it WITHOUT the listing query `discoverSessions` ends with. That
184
- * query is not free: it applies a cwd filter, runs the `archived_at`-writing
185
- * existence check, and can issue a Linear fetch, none of which index anything
186
- * (RUSH-2691). Keeping one implementation here is also what stops the tick and
187
- * the foreground path from drifting apart.
188
- */
173
+ /** Separate write half so daemon warming does not pay for listing or external enrichment. */
189
174
  export async function scanSessionsIncremental(options) {
190
175
  // Touch the DB so the schema is ready and connection is cached for this run.
191
176
  getDB();
@@ -222,11 +207,7 @@ export async function scanSessionsIncremental(options) {
222
207
  scanned += n;
223
208
  return { claimed: true, scanned };
224
209
  }
225
- /**
226
- * Poll until no live process holds the scan claim, or the bound elapses
227
- * (RUSH-2682). Bounded so a wedged/slow scan can never hang a foreground preview.
228
- * Exported for the cold-miss repair test.
229
- */
210
+ /** Bounded wait so a wedged scan cannot hang a foreground cold-miss repair. */
230
211
  export async function waitForScanToSettle(timeoutMs = WAIT_FOR_SCAN_TIMEOUT_MS, pollMs = WAIT_FOR_SCAN_POLL_MS) {
231
212
  const deadline = Date.now() + timeoutMs;
232
213
  while (scanInProgressByLivePid()) {
@@ -259,27 +240,8 @@ export async function queryIndexedSessions(options, indexedOptions = {}) {
259
240
  return scopeToManaged(sessions, agents, options);
260
241
  }
261
242
  /**
262
- * Resolve a full-or-partial session id against the LOCAL SQLite index only.
263
- *
264
- * A plain WAL read through `queryIndexedSessions` — same origin-machine
265
- * attribution and managed scoping every indexed read gets — with NO incremental
266
- * discovery scan (so none of `tryClaimScan`/`releaseScan`'s `BEGIN IMMEDIATE`
267
- * writer lock) and NO fleet SSH fan-out. This is the crash-restart storm path
268
- * (RUSH-2477): dozens of `sessions resume <id>` at once for a known local id must
269
- * each be a cheap read, never a writer-lock contender or a dial into the
270
- * not-yet-up tailnet. Exact id first, then prefix (matching `findSessionsById`),
271
- * so a complete id never also drags in its prefix siblings. Returns `[]` on a
272
- * genuine local miss, leaving the caller to fall back to the fleet resolver.
273
- *
274
- * The existence check is left ON (`skipExistenceCheck: false`), exactly as the old
275
- * `discoverSessions` path and `findSessionsById` do (RUSH-2436): it KEEPS a
276
- * file-gone session whose user turns still live in `session_text` (flagged
277
- * archived) and SUPPRESSES a contentless phantom — so a phantom id misses here and
278
- * falls through to the fleet resolver, instead of resolving to a row with no real
279
- * transcript to resume. For a present transcript — the storm's actual case, since
280
- * the crashed tabs' files are on disk — the check does no writes, so the lock-free
281
- * guarantee holds; it only writes to (un)archive a genuinely missing or resurrected
282
- * file, which is not the 20-at-once resume path.
243
+ * Resolve locally without scanning or fleet I/O, keeping concurrent crash recovery lock-light.
244
+ * The existence check preserves archived content while rejecting transcriptless phantoms.
283
245
  */
284
246
  export async function resolveIndexedSessionById(idQuery) {
285
247
  const q = idQuery.trim();
@@ -28,28 +28,10 @@ export declare function sanitizeEvents(events: SessionEvent[]): void;
28
28
  * ERR_STRING_TOO_LONG ceiling.
29
29
  */
30
30
  export declare function safeReadSessionFile(filePath: string, maxBytes?: number): string;
31
- /**
32
- * Auto-detect agent type from file path and parse the session.
33
- */
34
31
  export interface ParseSessionOptions {
35
32
  /** Keep normalized tool results compact by default; renderers can request full output. */
36
33
  maxToolOutputChars?: number;
37
- /**
38
- * Emit an `interrupt` event where the transcript records `[Request interrupted`.
39
- *
40
- * OFF by default, deliberately. That marker is not a user message, and the default
41
- * event array is a versioned consumer contract: `agents sessions <id> --json`
42
- * serializes it verbatim (see render.ts, issue #743), `computeSummaryStats` folds
43
- * every event's timestamp into the session duration, and the live-state reader and
44
- * tail renderer inspect fixed-size windows of the last N events. Emitting it
45
- * unconditionally changed all four — a measured 12x duration swing on one real
46
- * transcript, a new object in a published payload, and an eviction from the
47
- * 12-event rate-limit window whose trigger shape (a trailing interrupt) is exactly
48
- * a session the user just cancelled.
49
- *
50
- * `agents insights` opts in: an interruption is a real friction signal, and dropping
51
- * it outright is what made it unrecoverable.
52
- */
34
+ /** Opt-in because interrupts are not user messages and would change the published event stream. */
53
35
  includeInterrupts?: boolean;
54
36
  }
55
37
  export declare function parseSession(filePath: string, agent?: SessionAgentId, opts?: ParseSessionOptions): SessionEvent[];
@@ -117,14 +117,7 @@ function truncateNormalizedToolOutput(output, maxChars) {
117
117
  return output;
118
118
  return `${output.slice(0, maxChars)}\n\n[Output truncated: ${output.length - maxChars} characters omitted.]`;
119
119
  }
120
- /**
121
- * Registry-dispatch table: each `SessionAgentId` to its offline transcript
122
- * parser. Replaces the per-harness `switch` — the harness axis of Move 3. Kept
123
- * as its own table (not on the HarnessAdapter registry) because its id domain is
124
- * `SessionAgentId`: `rush` is a session agent with no `AgentId`, and the offline
125
- * transcript reader is a deliberately separate concern from live team events.
126
- * The `Record` is total, so a new session harness must add an entry here.
127
- */
120
+ /** Separate from HarnessAdapter because offline transcripts include session-only agents such as Rush. */
128
121
  const TRANSCRIPT_PARSERS = {
129
122
  claude: (filePath, opts) => parseClaude(filePath, opts),
130
123
  codex: (filePath) => parseCodex(filePath),
@@ -146,13 +139,7 @@ export function parseSession(filePath, agent, opts = {}) {
146
139
  throw new Error(`Cannot detect agent type from path: ${filePath}`);
147
140
  }
148
141
  const events = TRANSCRIPT_PARSERS[detected](filePath, opts);
149
- // Chokepoint: every string field that originated in an untrusted session
150
- // file gets stripped of terminal escapes here, so renderers downstream can
151
- // safely splat values into chalk/console output. Same pass flags
152
- // harness-injected `role=user` scaffolding (Claude `<bash-input>`/`<bash-stdout>`
153
- // from `!`-prefix runs, `<system-reminder>`, etc.) as `_synthetic` so turn
154
- // slicing and `--include user` count only genuine user intent — one place,
155
- // every harness, instead of per-consumer regex.
142
+ // Sanitize untrusted strings and identify synthetic user scaffolding once for every consumer.
156
143
  const maxToolOutputChars = opts.maxToolOutputChars ?? 500;
157
144
  for (const e of events) {
158
145
  if (e.type === 'tool_result' && e.output) {
@@ -1,3 +1,4 @@
1
+ import type { WorkerBundle } from './worker-template.js';
1
2
  export declare const SHARE_LIFECYCLE_RULE_ID = "agents-share-expire-objects";
2
3
  export declare const SHARE_LIFECYCLE_RETENTION_DAYS = 366;
3
4
  interface R2LifecycleRule {
@@ -50,7 +51,7 @@ export declare function mergeShareLifecycleRule(existing?: R2LifecycleRule[], ru
50
51
  /** Ensure the share bucket self-cleans old objects. Exact per-link expiry is enforced by the Worker. */
51
52
  export declare function configureBucketLifecycle(apiToken: string, accountId: string, bucketName: string, opts?: ProvisionOptions): Promise<void>;
52
53
  /** Upload the module Worker with an R2 binding (`BUCKET`). Secrets are set via the Workers Secrets API. */
53
- export declare function deployWorker(apiToken: string, accountId: string, workerName: string, script: string, bucketName: string, opts?: ProvisionOptions): Promise<void>;
54
+ export declare function deployWorker(apiToken: string, accountId: string, workerName: string, worker: string | WorkerBundle, bucketName: string, opts?: ProvisionOptions): Promise<void>;
54
55
  /** sha256 of the rendered Worker script, so a deployed endpoint can be compared
55
56
  * against the current `worker-template.ts` without redeploying to find out. */
56
57
  export declare function hashWorkerScript(script: string): string;
@@ -100,7 +101,7 @@ export type UpdateWorkerOpts = ProvisionOptions & {
100
101
  * re-setting the secret to its own value after every deploy gets the same
101
102
  * outcome without depending on an unverified upload-time flag.
102
103
  */
103
- export declare function updateWorker(apiToken: string, accountId: string, workerName: string, bucketName: string, script: string, writeToken: string, previousHash: string | undefined, opts?: UpdateWorkerOpts): Promise<UpdateWorkerResult>;
104
+ export declare function updateWorker(apiToken: string, accountId: string, workerName: string, bucketName: string, worker: string | WorkerBundle, writeToken: string, previousHash: string | undefined, opts?: UpdateWorkerOpts): Promise<UpdateWorkerResult>;
104
105
  /** Add/update a secret_text binding using Cloudflare's Workers Secrets API. */
105
106
  export declare function putWorkerSecret(apiToken: string, accountId: string, workerName: string, name: string, text: string, opts?: ProvisionOptions): Promise<void>;
106
107
  /** Add/update the WRITE_TOKEN binding using Cloudflare's Workers Secrets API. */
@@ -77,7 +77,7 @@ export async function configureBucketLifecycle(apiToken, accountId, bucketName,
77
77
  });
78
78
  }
79
79
  /** Upload the module Worker with an R2 binding (`BUCKET`). Secrets are set via the Workers Secrets API. */
80
- export async function deployWorker(apiToken, accountId, workerName, script, bucketName, opts = {}) {
80
+ export async function deployWorker(apiToken, accountId, workerName, worker, bucketName, opts = {}) {
81
81
  const request = opts.request ?? defaultCloudflareRequester;
82
82
  const metadata = {
83
83
  main_module: 'worker.js',
@@ -88,7 +88,11 @@ export async function deployWorker(apiToken, accountId, workerName, script, buck
88
88
  };
89
89
  const form = new FormData();
90
90
  form.set('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
91
- form.set('worker.js', new Blob([script], { type: 'application/javascript+module' }), 'worker.js');
91
+ const bundle = typeof worker === 'string' ? { script: worker, modules: [] } : worker;
92
+ form.set('worker.js', new Blob([bundle.script], { type: 'application/javascript+module' }), 'worker.js');
93
+ for (const module of bundle.modules) {
94
+ form.set(module.name, new Blob([module.contents], { type: module.contentType }), module.name);
95
+ }
92
96
  await request({
93
97
  apiToken,
94
98
  method: 'PUT',
@@ -130,7 +134,8 @@ export const WORKER_PHOENIX_ID_BASE_SECRET = 'PHOENIX_ID_BASE';
130
134
  * re-setting the secret to its own value after every deploy gets the same
131
135
  * outcome without depending on an unverified upload-time flag.
132
136
  */
133
- export async function updateWorker(apiToken, accountId, workerName, bucketName, script, writeToken, previousHash, opts = {}) {
137
+ export async function updateWorker(apiToken, accountId, workerName, bucketName, worker, writeToken, previousHash, opts = {}) {
138
+ const script = typeof worker === 'string' ? worker : worker.script;
134
139
  const templateHash = hashWorkerScript(script);
135
140
  if (!opts.force && previousHash === templateHash) {
136
141
  // Script is current so we skip the upload (which would wipe secrets). A
@@ -142,7 +147,7 @@ export async function updateWorker(apiToken, accountId, workerName, bucketName,
142
147
  }
143
148
  return { templateHash, skipped: true };
144
149
  }
145
- await deployWorker(apiToken, accountId, workerName, script, bucketName, opts);
150
+ await deployWorker(apiToken, accountId, workerName, worker, bucketName, opts);
146
151
  // Script upload clears bindings/secrets (see JSDoc above). If re-applying
147
152
  // WRITE_TOKEN fails here, the live Worker has no write token — every
148
153
  // `agents artifacts share` publish/delete 401s until a re-run of `agents artifacts share update`
@@ -1,4 +1,27 @@
1
- /** Bundle the Worker and its renderer into one uploadable ES module. */
1
+ /**
2
+ * Render the Worker source. Pure — the R2 binding + token are wired at deploy time.
3
+ *
4
+ * The literal below still spells the CLI `agents share` in its provenance comment,
5
+ * its root response, and its gallery title, even though the command is now
6
+ * `agents artifacts share` (RUSH-2580). That is deliberate: `hashWorkerScript` of
7
+ * this exact text is what `shareTemplateStatus` compares a provisioned endpoint's
8
+ * recorded `templateHash` against, so editing ANY byte here marks every already-
9
+ * deployed endpoint `outdated` — which makes `agents artifacts share list` refuse
10
+ * until its owner re-runs `agents artifacts share update`. Cosmetic renames are not
11
+ * worth that; change this text only alongside a real Worker behavior change.
12
+ */
13
+ export interface WorkerModule {
14
+ name: string;
15
+ contentType: 'application/wasm';
16
+ contents: Uint8Array<ArrayBuffer>;
17
+ }
18
+ export interface WorkerBundle {
19
+ script: string;
20
+ modules: WorkerModule[];
21
+ }
22
+ /** Bundle the Worker renderer into an ES module plus workerd-compiled WASM modules. */
23
+ export declare function renderWorkerBundle(): WorkerBundle;
24
+ /** Single-file representation for direct Node tests, which cannot import compiled WASM modules. */
2
25
  export declare function renderWorkerScript(): string;
3
26
  /** Unbundled Worker source. Kept separate so esbuild can resolve npm modules. */
4
27
  export declare function renderWorkerSource(): string;
@@ -1,81 +1,53 @@
1
1
  import { buildSync } from 'esbuild';
2
2
  import { dirname } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- // The Cloudflare Worker that fronts the R2 share bucket.
5
- //
6
- // One tiny Worker does both sides:
7
- // - PUT /<username>/<slug> — write-gated by authorizeWrite's THREE principals
8
- // (not a fallback chain — each is a distinct legitimate identity):
9
- // 1. Static WRITE_TOKEN (BYO Cloudflare) — checked first when the presented
10
- // bearer equals env.WRITE_TOKEN. owner = env.SHARE_NAMESPACE or the
11
- // path's first segment.
12
- // 2. Phoenix bearer — otherwise GET ${env.PHOENIX_ID_BASE}/api/v1/auth/me
13
- // with that bearer → {userId,email}; 401 if absent/invalid.
14
- // customMetadata.owner = userId (stable). The path's first segment MUST
15
- // equal handleFromEmail(email) (the public handle, e.g. muqsitnawaz),
16
- // so one user cannot write another's prefix (403 namespace mismatch).
17
- // A __handles/<handle> claim binds the handle to the first userId that
18
- // writes it; a different userId gets 409 handle taken.
19
- // 3. __share HMAC cookie — the signed-in viewer's identity cookie (same
20
- // {userId,email} identityFromCookie verifies for GET). Lets the shared
21
- // page's inline visibility control PATCH with credentials:'include' and
22
- // no bearer; SameSite=Lax blocks it cross-site, and the same namespace/
23
- // owner checks confine it to the holder's own pages. Applies to PUT,
24
- // PATCH, and DELETE alike, since all three share authorizeWrite.
25
- // A managed deployment sets PHOENIX_ID_BASE; BYO sets WRITE_TOKEN; the
26
- // platform endpoint may set both. Fail loud (401) when none authenticates.
27
- // Writes the body to R2 via the BUCKET binding, storing visibility
28
- // (public|unlisted|me|org), owner, org_domain (org only), an optional
29
- // expires-at, plus provenance (agent/session/host/repo/date), a label, and
30
- // any `--meta` entries in object metadata. me/org require a Phoenix
31
- // identity (BYO WRITE_TOKEN cannot publish them). org from a public inbox
32
- // domain is 400. Overwriting an existing slug first copies the current
33
- // object to <slug>/rev-<ts>-<rand> (revision history) unless
34
- // x-share-no-revision is set.
35
- // - PATCH /<username>/<slug> — authenticated metadata-only edit. Rewrites the
36
- // exact existing body with all HTTP/custom metadata preserved except the
37
- // explicitly requested label/arbitrary metadata changes; never revisions.
38
- // Conditional put (onlyIf etagMatches) so a concurrent republish 409s
39
- // instead of rolling the body back. Phoenix requires customMetadata.owner
40
- // === auth.owner (fail closed when the stamp is missing); WRITE_TOKEN is
41
- // the admin repair path.
42
- // - GET /<username>/<slug> — public|unlisted are anonymous; me requires the
43
- // Phoenix owner, org requires a same-domain Phoenix identity (Bearer, then
44
- // HMAC cookie, then phoenix_ticket). Unauthenticated me/org 302s to
45
- // Phoenix login (or 401 JSON if PHOENIX_ID_BASE is unset). 410s (and lazily
46
- // deletes) once its expiry has passed. A bucket lifecycle rule is the durable
47
- // sweeper; this is the immediate gate.
48
- // - GET /<username>/<slug>?revisions=json — machine-readable history of the
49
- // retained prior versions under that slug, newest first.
50
- // - GET /<username> — public gallery of that user's shares (HTML).
51
- // - GET /<username>?format=json — public machine-readable listing of that user's
52
- // ACTIVE shares (`agents artifacts share list`). Same single-segment path as the HTML
53
- // gallery and gated on the SAME "does <username>/ hold any object" check, so it
54
- // only intercepts a genuine namespace — a legacy flat slug with ?format=json
55
- // still serves its real content, never a fake empty listing.
56
- // - GET /<slug> — backward-compat flat slug (legacy shares before
57
- // per-user namespaces).
58
- //
59
- // Emitted as a string, so it compiles into `dist/**` and ships with no
60
- // package.json#files change. `provision.ts` uploads
61
- // this verbatim as an ES-module Worker with a BUCKET (R2) binding + a WRITE_TOKEN secret.
62
- /**
63
- * Render the Worker source. Pure — the R2 binding + token are wired at deploy time.
64
- *
65
- * The literal below still spells the CLI `agents share` in its provenance comment,
66
- * its root response, and its gallery title, even though the command is now
67
- * `agents artifacts share` (RUSH-2580). That is deliberate: `hashWorkerScript` of
68
- * this exact text is what `shareTemplateStatus` compares a provisioned endpoint's
69
- * recorded `templateHash` against, so editing ANY byte here marks every already-
70
- * deployed endpoint `outdated` — which makes `agents artifacts share list` refuse
71
- * until its owner re-runs `agents artifacts share update`. Cosmetic renames are not
72
- * worth that; change this text only alongside a real Worker behavior change.
73
- */
74
- let bundledWorkerScript;
75
- /** Bundle the Worker and its renderer into one uploadable ES module. */
4
+ let bundledWorker;
5
+ let nodeWorkerScript;
6
+ /** Bundle the Worker renderer into an ES module plus workerd-compiled WASM modules. */
7
+ export function renderWorkerBundle() {
8
+ if (bundledWorker)
9
+ return bundledWorker;
10
+ const result = buildSync({
11
+ stdin: {
12
+ contents: renderWorkerSource(),
13
+ loader: 'js',
14
+ resolveDir: dirname(fileURLToPath(import.meta.url)),
15
+ sourcefile: 'agents-share-worker.js',
16
+ },
17
+ bundle: true,
18
+ format: 'esm',
19
+ platform: 'browser',
20
+ target: 'es2022',
21
+ write: false,
22
+ outdir: 'worker-bundle',
23
+ assetNames: '[name]-[hash]',
24
+ minify: true,
25
+ // Fonts are plain data and can live in JavaScript. WASM must remain a
26
+ // compiled module: workerd deliberately forbids runtime code generation,
27
+ // so esbuild's `binary` loader produces a bundle that works in Node but
28
+ // throws "Wasm code generation disallowed by embedder" in Cloudflare.
29
+ loader: { '.wasm': 'copy', '.woff': 'binary' },
30
+ });
31
+ const script = result.outputFiles.find((output) => output.path.endsWith('.js'));
32
+ if (!script)
33
+ throw new Error('Worker bundling produced no JavaScript output.');
34
+ const modules = result.outputFiles
35
+ .filter((output) => output.path.endsWith('.wasm'))
36
+ .map((output) => ({
37
+ name: output.path.split('/').pop(),
38
+ contentType: 'application/wasm',
39
+ contents: new Uint8Array(output.contents),
40
+ }));
41
+ if (modules.length !== 2) {
42
+ throw new Error(`Worker bundling produced ${modules.length} WASM modules; expected yoga and resvg.`);
43
+ }
44
+ bundledWorker = { script: script.text, modules };
45
+ return bundledWorker;
46
+ }
47
+ /** Single-file representation for direct Node tests, which cannot import compiled WASM modules. */
76
48
  export function renderWorkerScript() {
77
- if (bundledWorkerScript)
78
- return bundledWorkerScript;
49
+ if (nodeWorkerScript)
50
+ return nodeWorkerScript;
79
51
  const result = buildSync({
80
52
  stdin: {
81
53
  contents: renderWorkerSource(),
@@ -93,9 +65,9 @@ export function renderWorkerScript() {
93
65
  });
94
66
  const output = result.outputFiles[0];
95
67
  if (!output)
96
- throw new Error('Worker bundling produced no JavaScript output.');
97
- bundledWorkerScript = output.text;
98
- return bundledWorkerScript;
68
+ throw new Error('Worker test bundling produced no JavaScript output.');
69
+ nodeWorkerScript = output.text;
70
+ return nodeWorkerScript;
99
71
  }
100
72
  /** Unbundled Worker source. Kept separate so esbuild can resolve npm modules. */
101
73
  export function renderWorkerSource() {
@@ -491,13 +463,22 @@ export default {
491
463
 
492
464
  const pageHtml = await page.text();
493
465
  const meta = page.customMetadata || {};
494
- const png = await hooks.renderOgCard({
495
- title: meta['og-title'] || extractHtmlMeta(pageHtml, 'title') || meta.label || segments[1],
496
- description: meta['og-description'] || extractHtmlMeta(pageHtml, 'description') || '',
497
- handle: segments[0],
498
- visibility: pageVisibility,
499
- orgDomain: meta.org_domain || '',
500
- });
466
+ let png;
467
+ try {
468
+ png = await hooks.renderOgCard({
469
+ title: meta['og-title'] || extractHtmlMeta(pageHtml, 'title') || meta.label || segments[1],
470
+ description: meta['og-description'] || extractHtmlMeta(pageHtml, 'description') || '',
471
+ handle: segments[0],
472
+ visibility: pageVisibility,
473
+ orgDomain: meta.org_domain || '',
474
+ });
475
+ } catch (error) {
476
+ const detail = error instanceof Error ? error.message : String(error);
477
+ return new Response('OG card render failed: ' + detail, {
478
+ status: 500,
479
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
480
+ });
481
+ }
501
482
  const beforeStore = await env.BUCKET.get(pagePath);
502
483
  if (!beforeStore || beforeStore.etag !== page.etag) { existingCover = null; continue; }
503
484
  await env.BUCKET.put(path, png, {
@@ -26,7 +26,8 @@ export declare const KNOWN_TOP_LEVEL_COMMANDS: ReadonlySet<string>;
26
26
  * (linear-cli) (RUSH-2932). `alias` moved under `agents setup alias` (RUSH-2965).
27
27
  * `inbox` was a pure alias of `agents feed` (RUSH-2984). `unshare` nested under
28
28
  * `agents artifacts unshare` (RUSH-2989). `audit` nested under `agents events audit`.
29
- * `trends` nested under `agents insights mix` / `insights trends`. `serve` (the
29
+ * `trends` was removed with the insights recipe collapse — the one counter
30
+ * surface is `agents insights mix` (PHNX-3391). `serve` (the
30
31
  * read-only local web companion + `--control` anchor) was removed with the
31
32
  * unshipped iOS Fleet Cockpit it existed for (RUSH-3001). `apply` nested under
32
33
  * `agents fleet apply` / `agents devices apply`. `beta` nested under
@@ -34,7 +35,12 @@ export declare const KNOWN_TOP_LEVEL_COMMANDS: ReadonlySet<string>;
34
35
  * removed; `agents auth` returned against Phoenix ID with `auth space` as the
35
36
  * team surface (RUSH-2581). `usage` was removed as a duplicate surface of
36
37
  * `agents view`, which renders per-account usage with account, version, and
37
- * auth state beside it (RUSH-3079).
38
+ * auth state beside it (RUSH-3079). `perf` nested under `agents insights perf`
39
+ * — performance metrics are an insight, not a top-level noun (PHNX-3391).
40
+ * `list` was removed — it was a long-deprecated full duplicate of `agents view`
41
+ * (it already printed "agents list is now agents view"); `agents view` is the
42
+ * one version-listing surface (PHNX-3391). The `agents trash restore` subcommand
43
+ * was likewise removed as an exact duplicate of top-level `agents restore`.
38
44
  */
39
45
  export declare const RETIRED_TOP_LEVEL_COMMANDS: ReadonlySet<string>;
40
46
  export declare function isKnownTopLevelCommand(name: string): boolean;
@@ -1,11 +1,11 @@
1
1
  const LOADED_COMMAND_NAMES = [
2
2
  'accounts', 'auth', 'view', 'inspect', 'feedback', 'commands', 'hooks', 'skills', 'rules', 'memory',
3
- 'permissions', 'mcp', 'clis', 'subagents', 'plugins', 'workflows', 'add', 'use', 'list',
3
+ 'permissions', 'mcp', 'clis', 'subagents', 'plugins', 'workflows', 'add', 'use',
4
4
  'remove', 'rm', 'purge', 'update', 'prune', 'import', 'registry', 'search', 'install',
5
5
  'routines', 'monitors', 'projects', 'run', 'open', 'reconnect', 'fork', 'config',
6
6
  'models', 'modes', 'trash', 'restore', 'doctor',
7
7
  'route', 'harness', 'harnesses', 'secrets', 'menubar', 'sync',
8
- 'refresh-rules', 'factory', 'insights', 'trace', 'perf',
8
+ 'refresh-rules', 'factory', 'insights', 'trace',
9
9
  'pty', 'tmux', 'watchdog', 'browser', 'computer', 'logs', 'events',
10
10
  'ssh', 'devices', 'fleet', 'repos', 'repo', 'setup', 'uninstall', 'upgrade', 'sessions',
11
11
  'teams', 'cloud', 'message', 'send', 'notify', 'feed',
@@ -46,7 +46,8 @@ export const KNOWN_TOP_LEVEL_COMMANDS = new Set([
46
46
  * (linear-cli) (RUSH-2932). `alias` moved under `agents setup alias` (RUSH-2965).
47
47
  * `inbox` was a pure alias of `agents feed` (RUSH-2984). `unshare` nested under
48
48
  * `agents artifacts unshare` (RUSH-2989). `audit` nested under `agents events audit`.
49
- * `trends` nested under `agents insights mix` / `insights trends`. `serve` (the
49
+ * `trends` was removed with the insights recipe collapse — the one counter
50
+ * surface is `agents insights mix` (PHNX-3391). `serve` (the
50
51
  * read-only local web companion + `--control` anchor) was removed with the
51
52
  * unshipped iOS Fleet Cockpit it existed for (RUSH-3001). `apply` nested under
52
53
  * `agents fleet apply` / `agents devices apply`. `beta` nested under
@@ -54,7 +55,12 @@ export const KNOWN_TOP_LEVEL_COMMANDS = new Set([
54
55
  * removed; `agents auth` returned against Phoenix ID with `auth space` as the
55
56
  * team surface (RUSH-2581). `usage` was removed as a duplicate surface of
56
57
  * `agents view`, which renders per-account usage with account, version, and
57
- * auth state beside it (RUSH-3079).
58
+ * auth state beside it (RUSH-3079). `perf` nested under `agents insights perf`
59
+ * — performance metrics are an insight, not a top-level noun (PHNX-3391).
60
+ * `list` was removed — it was a long-deprecated full duplicate of `agents view`
61
+ * (it already printed "agents list is now agents view"); `agents view` is the
62
+ * one version-listing surface (PHNX-3391). The `agents trash restore` subcommand
63
+ * was likewise removed as an exact duplicate of top-level `agents restore`.
58
64
  */
59
65
  export const RETIRED_TOP_LEVEL_COMMANDS = new Set([
60
66
  'webhook',
@@ -85,6 +91,8 @@ export const RETIRED_TOP_LEVEL_COMMANDS = new Set([
85
91
  'trends',
86
92
  'apply',
87
93
  'beta',
94
+ 'perf',
95
+ 'list',
88
96
  ]);
89
97
  export function isKnownTopLevelCommand(name) {
90
98
  return KNOWN_TOP_LEVEL_COMMANDS.has(name);