@phnx-labs/agents-cli 1.22.54 → 1.22.56

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 (56) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +19 -2
  3. package/dist/commands/routines.js +31 -2
  4. package/dist/commands/share.d.ts +27 -0
  5. package/dist/commands/share.js +92 -6
  6. package/dist/commands/view.d.ts +8 -0
  7. package/dist/commands/view.js +31 -4
  8. package/dist/lib/accounting/usage.d.ts +31 -0
  9. package/dist/lib/accounting/usage.js +45 -4
  10. package/dist/lib/browser/cdp.d.ts +1 -1
  11. package/dist/lib/browser/cdp.js +1 -1
  12. package/dist/lib/browser/ffmpeg.d.ts +12 -0
  13. package/dist/lib/browser/ffmpeg.js +184 -0
  14. package/dist/lib/browser/service.js +119 -25
  15. package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
  16. package/dist/lib/daemon/browser-task-reap-service.js +26 -0
  17. package/dist/lib/daemon/daemon.js +57 -172
  18. package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
  19. package/dist/lib/daemon/heartbeat-service.js +26 -0
  20. package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
  21. package/dist/lib/daemon/monitor-engine-service.js +15 -7
  22. package/dist/lib/daemon/runner.d.ts +15 -0
  23. package/dist/lib/daemon/runner.js +23 -0
  24. package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
  25. package/dist/lib/daemon/secrets-broker-service.js +17 -32
  26. package/dist/lib/daemon/service.d.ts +2 -2
  27. package/dist/lib/daemon/service.js +1 -1
  28. package/dist/lib/daemon/session-state-service.d.ts +21 -0
  29. package/dist/lib/daemon/session-state-service.js +34 -0
  30. package/dist/lib/daemon/supervisor.d.ts +17 -7
  31. package/dist/lib/daemon/supervisor.js +87 -14
  32. package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
  33. package/dist/lib/daemon/tmux-reap-service.js +28 -0
  34. package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
  35. package/dist/lib/daemon/webhook-receiver-service.js +17 -0
  36. package/dist/lib/daemon-services.d.ts +1 -1
  37. package/dist/lib/daemon-services.js +20 -0
  38. package/dist/lib/devices/harness-inventory.js +5 -2
  39. package/dist/lib/monitors/engine.d.ts +5 -1
  40. package/dist/lib/monitors/engine.js +5 -3
  41. package/dist/lib/overdue.js +7 -37
  42. package/dist/lib/scheduler.d.ts +21 -2
  43. package/dist/lib/scheduler.js +28 -5
  44. package/dist/lib/scheduling/routines.d.ts +68 -0
  45. package/dist/lib/scheduling/routines.js +126 -1
  46. package/dist/lib/session/db.d.ts +2 -1
  47. package/dist/lib/session/db.js +2 -1
  48. package/dist/lib/share/capture.js +11 -2
  49. package/dist/lib/share/provision.d.ts +3 -2
  50. package/dist/lib/share/provision.js +9 -4
  51. package/dist/lib/share/publish.d.ts +4 -1
  52. package/dist/lib/share/publish.js +27 -1
  53. package/dist/lib/share/worker-template.d.ts +14 -0
  54. package/dist/lib/share/worker-template.js +330 -73
  55. package/dist/lib/view-types.d.ts +6 -1
  56. package/package.json +8 -2
@@ -1,78 +1,90 @@
1
- // The Cloudflare Worker that fronts the R2 share bucket.
2
- //
3
- // One tiny Worker does both sides:
4
- // - PUT /<username>/<slug> — write-gated by authorizeWrite's THREE principals
5
- // (not a fallback chain — each is a distinct legitimate identity):
6
- // 1. Static WRITE_TOKEN (BYO Cloudflare) — checked first when the presented
7
- // bearer equals env.WRITE_TOKEN. owner = env.SHARE_NAMESPACE or the
8
- // path's first segment.
9
- // 2. Phoenix bearer — otherwise GET ${env.PHOENIX_ID_BASE}/api/v1/auth/me
10
- // with that bearer → {userId,email}; 401 if absent/invalid.
11
- // customMetadata.owner = userId (stable). The path's first segment MUST
12
- // equal handleFromEmail(email) (the public handle, e.g. muqsitnawaz),
13
- // so one user cannot write another's prefix (403 namespace mismatch).
14
- // A __handles/<handle> claim binds the handle to the first userId that
15
- // writes it; a different userId gets 409 handle taken.
16
- // 3. __share HMAC cookie — the signed-in viewer's identity cookie (same
17
- // {userId,email} identityFromCookie verifies for GET). Lets the shared
18
- // page's inline visibility control PATCH with credentials:'include' and
19
- // no bearer; SameSite=Lax blocks it cross-site, and the same namespace/
20
- // owner checks confine it to the holder's own pages. Applies to PUT,
21
- // PATCH, and DELETE alike, since all three share authorizeWrite.
22
- // A managed deployment sets PHOENIX_ID_BASE; BYO sets WRITE_TOKEN; the
23
- // platform endpoint may set both. Fail loud (401) when none authenticates.
24
- // Writes the body to R2 via the BUCKET binding, storing visibility
25
- // (public|unlisted|me|org), owner, org_domain (org only), an optional
26
- // expires-at, plus provenance (agent/session/host/repo/date), a label, and
27
- // any `--meta` entries in object metadata. me/org require a Phoenix
28
- // identity (BYO WRITE_TOKEN cannot publish them). org from a public inbox
29
- // domain is 400. Overwriting an existing slug first copies the current
30
- // object to <slug>/rev-<ts>-<rand> (revision history) unless
31
- // x-share-no-revision is set.
32
- // - PATCH /<username>/<slug> — authenticated metadata-only edit. Rewrites the
33
- // exact existing body with all HTTP/custom metadata preserved except the
34
- // explicitly requested label/arbitrary metadata changes; never revisions.
35
- // Conditional put (onlyIf etagMatches) so a concurrent republish 409s
36
- // instead of rolling the body back. Phoenix requires customMetadata.owner
37
- // === auth.owner (fail closed when the stamp is missing); WRITE_TOKEN is
38
- // the admin repair path.
39
- // - GET /<username>/<slug> — public|unlisted are anonymous; me requires the
40
- // Phoenix owner, org requires a same-domain Phoenix identity (Bearer, then
41
- // HMAC cookie, then phoenix_ticket). Unauthenticated me/org 302s to
42
- // Phoenix login (or 401 JSON if PHOENIX_ID_BASE is unset). 410s (and lazily
43
- // deletes) once its expiry has passed. A bucket lifecycle rule is the durable
44
- // sweeper; this is the immediate gate.
45
- // - GET /<username>/<slug>?revisions=json — machine-readable history of the
46
- // retained prior versions under that slug, newest first.
47
- // - GET /<username> — public gallery of that user's shares (HTML).
48
- // - GET /<username>?format=json — public machine-readable listing of that user's
49
- // ACTIVE shares (`agents artifacts share list`). Same single-segment path as the HTML
50
- // gallery and gated on the SAME "does <username>/ hold any object" check, so it
51
- // only intercepts a genuine namespace — a legacy flat slug with ?format=json
52
- // still serves its real content, never a fake empty listing.
53
- // - GET /<slug> — backward-compat flat slug (legacy shares before
54
- // per-user namespaces).
55
- //
56
- // Emitted as a string, so it compiles into `dist/**` and ships with no
57
- // package.json#files change. `provision.ts` uploads
58
- // this verbatim as an ES-module Worker with a BUCKET (R2) binding + a WRITE_TOKEN secret.
59
- /**
60
- * Render the Worker source. Pure — the R2 binding + token are wired at deploy time.
61
- *
62
- * The literal below still spells the CLI `agents share` in its provenance comment,
63
- * its root response, and its gallery title, even though the command is now
64
- * `agents artifacts share` (RUSH-2580). That is deliberate: `hashWorkerScript` of
65
- * this exact text is what `shareTemplateStatus` compares a provisioned endpoint's
66
- * recorded `templateHash` against, so editing ANY byte here marks every already-
67
- * deployed endpoint `outdated` — which makes `agents artifacts share list` refuse
68
- * until its owner re-runs `agents artifacts share update`. Cosmetic renames are not
69
- * worth that; change this text only alongside a real Worker behavior change.
70
- */
1
+ import { buildSync } from 'esbuild';
2
+ import { dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
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. */
71
48
  export function renderWorkerScript() {
72
- return `// GENERATED by agents-cli agents share setup — do not edit here; edit
49
+ if (nodeWorkerScript)
50
+ return nodeWorkerScript;
51
+ const result = buildSync({
52
+ stdin: {
53
+ contents: renderWorkerSource(),
54
+ loader: 'js',
55
+ resolveDir: dirname(fileURLToPath(import.meta.url)),
56
+ sourcefile: 'agents-share-worker.js',
57
+ },
58
+ bundle: true,
59
+ format: 'esm',
60
+ platform: 'browser',
61
+ target: 'es2022',
62
+ write: false,
63
+ minify: true,
64
+ loader: { '.wasm': 'binary', '.woff': 'binary' },
65
+ });
66
+ const output = result.outputFiles[0];
67
+ if (!output)
68
+ throw new Error('Worker test bundling produced no JavaScript output.');
69
+ nodeWorkerScript = output.text;
70
+ return nodeWorkerScript;
71
+ }
72
+ /** Unbundled Worker source. Kept separate so esbuild can resolve npm modules. */
73
+ export function renderWorkerSource() {
74
+ return `import satori, { init as initSatori } from 'satori/wasm';
75
+ import initYoga from 'yoga-wasm-web';
76
+ import { Resvg, initWasm as initResvg } from '@resvg/resvg-wasm';
77
+ import yogaWasm from 'yoga-wasm-web/dist/yoga.wasm';
78
+ import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm';
79
+ import interRegular from '@fontsource/inter/files/inter-latin-400-normal.woff';
80
+ import interBold from '@fontsource/inter/files/inter-latin-700-normal.woff';
81
+ import jetbrainsMono from '@fontsource/jetbrains-mono/files/jetbrains-mono-latin-600-normal.woff';
82
+
83
+ // GENERATED by agents-cli agents share setup — do not edit here; edit
73
84
  // src/lib/share/worker-template.ts and re-run setup.
74
85
  export const hooks = {
75
86
  verifyPhoenixToken: defaultVerifyPhoenixToken,
87
+ renderOgCard: renderOgCard,
76
88
  };
77
89
 
78
90
  export default {
@@ -91,6 +103,21 @@ export default {
91
103
  return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
92
104
  }
93
105
 
106
+ // POST /__ticket — mint a short-lived, single-navigation login ticket for the
107
+ // authenticated owner (PHNX-3370). The CLI (which holds the Phoenix bearer)
108
+ // calls this, then opens the owner's page with ?phoenix_ticket=<t> so the
109
+ // browser trades it for the __share cookie and the inline visibility control
110
+ // goes live. The ticket is SELF-signed with the Worker's own HMAC secret — no
111
+ // external ticket service — and carries the SAME identity authorizeWrite
112
+ // already proved, so it grants nothing the caller didn't already have.
113
+ if (request.method === 'POST' && firstSeg === '__ticket') {
114
+ const auth = await authorizeWrite(request, env);
115
+ if (auth.error) return auth.error;
116
+ const ticket = await signSelfTicket({ userId: auth.owner, email: auth.email || '' }, env);
117
+ if (!ticket) return json({ error: 'ticket minting is not configured' }, 501);
118
+ return json({ ticket: ticket }, 200);
119
+ }
120
+
94
121
  if (request.method === 'PUT') {
95
122
  const auth = await authorizeWrite(request, env);
96
123
  if (auth.error) return auth.error;
@@ -162,6 +189,8 @@ export default {
162
189
  const date = readText('date');
163
190
  const avatar = readText('avatar');
164
191
  const label = readText('label');
192
+ const ogTitle = readText('og-title');
193
+ const ogDescription = readText('og-description');
165
194
  const labelSource = request.headers.get('x-share-label-source') || '';
166
195
  let extraMeta = {};
167
196
  // The -u meta companion carries the whole raw object percent-encoded, so a
@@ -210,6 +239,8 @@ export default {
210
239
  if (avatar) customMetadata['avatar'] = avatar;
211
240
  if (label) customMetadata['label'] = label;
212
241
  if (labelSource) customMetadata['label-source'] = labelSource;
242
+ if (ogTitle) customMetadata['og-title'] = ogTitle;
243
+ if (ogDescription) customMetadata['og-description'] = ogDescription;
213
244
 
214
245
  // Revision retention (RUSH-2683): R2 has no native object versioning, so a
215
246
  // republish over an EXISTING key first copies the current object to
@@ -238,6 +269,10 @@ export default {
238
269
  httpMetadata: { contentType },
239
270
  customMetadata,
240
271
  });
272
+ // A managed republish may change the title/description. Invalidate only
273
+ // its generated sibling so the next crawler receives a fresh card; BYO
274
+ // publishes send no OG metadata and keep their explicitly uploaded cover.
275
+ if (ogTitle) await env.BUCKET.delete(path + '.png');
241
276
  return json({ ok: true, url: url.origin + '/' + path, expiresAt: expiresAt || null, unlisted: visibility === 'unlisted', visibility }, 200);
242
277
  }
243
278
 
@@ -263,6 +298,7 @@ export default {
263
298
  if (!edit || typeof edit !== 'object') return json({ error: 'PATCH body must be an object' }, 400);
264
299
  const metadata = { ...(existing.customMetadata || {}) };
265
300
  const previousVisibility = metadata.visibility || 'public';
301
+ let visibilityChanged = false;
266
302
  if (Object.prototype.hasOwnProperty.call(edit, 'label')) {
267
303
  if (edit.label !== null && typeof edit.label !== 'string') return json({ error: 'label must be a string or null' }, 400);
268
304
  if (typeof edit.label === 'string' && (edit.label.trim() !== edit.label || !edit.label || edit.label.length > 200)) return json({ error: 'label must be 1-200 trimmed characters' }, 400);
@@ -289,6 +325,7 @@ export default {
289
325
  }
290
326
  }
291
327
  metadata.visibility = visibility;
328
+ visibilityChanged = visibility !== previousVisibility;
292
329
  // org_domain is meaningful only for org — set it there, drop the stale
293
330
  // value on any move away from org so it can never gate a later read.
294
331
  if (visibility === 'org') metadata.org_domain = emailDomain(auth.email);
@@ -327,6 +364,16 @@ export default {
327
364
  if (existing.etag) putOpts.onlyIf = { etagMatches: existing.etag };
328
365
  const putResult = await env.BUCKET.put(path, existing.body, putOpts);
329
366
  if (putResult === null) return json({ error: 'conflict', key: path }, 409);
367
+ // A generated cover copies the canonical visibility gate into its own R2
368
+ // metadata. Invalidate it after a successful visibility rewrite so the
369
+ // next cover GET re-gates and renders from the canonical page. Explicitly
370
+ // uploaded BYO siblings carry no marker and remain independent assets.
371
+ if (visibilityChanged) {
372
+ const cover = await env.BUCKET.get(path + '.png');
373
+ if (cover && cover.customMetadata && cover.customMetadata['og-generated'] === 'true') {
374
+ await env.BUCKET.delete(path + '.png');
375
+ }
376
+ }
330
377
  return json({ ok: true, url: url.origin + '/' + path, label: metadata.label || null, meta: extraMetaOf(metadata), visibility: metadata.visibility || 'public', previousVisibility: previousVisibility }, 200);
331
378
  }
332
379
 
@@ -383,6 +430,79 @@ export default {
383
430
  );
384
431
  }
385
432
 
433
+ // Managed OG cover: generated siblings are caches, never visibility
434
+ // authorities. Gate every request against the current canonical page and
435
+ // bind each cached render to that page's etag. Re-read after rendering and
436
+ // after storing so a concurrent PATCH cannot publish a card from the old
437
+ // visibility/title/description snapshot. Explicit BYO siblings carry no
438
+ // generated marker and continue through the ordinary object route below.
439
+ if (segments.length === 2 && path.endsWith('.png')) {
440
+ let existingCover = await env.BUCKET.get(path);
441
+ if (!existingCover || (existingCover.customMetadata && existingCover.customMetadata['og-generated'] === 'true')) {
442
+ const pagePath = path.slice(0, -4);
443
+ const viewer = await resolveViewer(request, env, url);
444
+ if (viewer.redirect) return viewer.redirect;
445
+ for (let attempt = 0; attempt < 3; attempt += 1) {
446
+ const page = await env.BUCKET.get(pagePath);
447
+ if (!page) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
448
+ const pageVisibility = (page.customMetadata && page.customMetadata.visibility) || 'public';
449
+ if (viewer.error && isIdentityGated(pageVisibility)) return viewer.error;
450
+ const denied = gateVisibility(url, env, page, viewer.identity || null);
451
+ if (denied) return denied;
452
+
453
+ if (existingCover && existingCover.customMetadata['og-source-etag'] === page.etag) {
454
+ const current = await env.BUCKET.get(pagePath);
455
+ if (!current || current.etag !== page.etag) { existingCover = null; continue; }
456
+ const currentDenied = gateVisibility(url, env, current, viewer.identity || null);
457
+ if (currentDenied) return currentDenied;
458
+ return new Response(request.method === 'HEAD' ? null : existingCover.body, {
459
+ status: 200,
460
+ headers: managedCoverHeaders(pageVisibility),
461
+ });
462
+ }
463
+
464
+ const pageHtml = await page.text();
465
+ const meta = page.customMetadata || {};
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
+ }
482
+ const beforeStore = await env.BUCKET.get(pagePath);
483
+ if (!beforeStore || beforeStore.etag !== page.etag) { existingCover = null; continue; }
484
+ await env.BUCKET.put(path, png, {
485
+ httpMetadata: { contentType: 'image/png' },
486
+ customMetadata: { ...meta, 'og-generated': 'true', 'og-source-etag': page.etag },
487
+ });
488
+ const beforeServe = await env.BUCKET.get(pagePath);
489
+ if (!beforeServe || beforeServe.etag !== page.etag) {
490
+ const justStored = await env.BUCKET.get(path);
491
+ if (justStored && justStored.customMetadata && justStored.customMetadata['og-source-etag'] === page.etag) {
492
+ await env.BUCKET.delete(path);
493
+ }
494
+ existingCover = null;
495
+ continue;
496
+ }
497
+ return new Response(request.method === 'HEAD' ? null : png, {
498
+ status: 200,
499
+ headers: managedCoverHeaders(pageVisibility),
500
+ });
501
+ }
502
+ return new Response('cover changed during rendering; retry', { status: 503, headers: { 'content-type': 'text/plain', 'retry-after': '1' } });
503
+ }
504
+ }
505
+
386
506
  const obj = await env.BUCKET.get(path);
387
507
  if (!obj) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
388
508
  const expiresAt = obj.customMetadata && obj.customMetadata['expires-at'];
@@ -694,7 +814,7 @@ async function renderRevisions(bucket, origin, key, method, identityGated) {
694
814
  // before it ever reaches this Worker; see RESERVED_META_KEYS in publish.ts).
695
815
  // One list, reused both to strip a same-named --meta collision on write and
696
816
  // to split arbitrary --meta entries back out on read.
697
- var RESERVED_METADATA_KEYS = ['expires-at', 'published-at', 'visibility', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'avatar', 'label', 'label-source'];
817
+ var RESERVED_METADATA_KEYS = ['expires-at', 'published-at', 'visibility', 'owner', 'org_domain', 'agent', 'session', 'host', 'repo', 'date', 'avatar', 'label', 'label-source', 'og-title', 'og-description', 'og-generated', 'og-source-etag'];
698
818
  var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'icloud.com', 'me.com'];
699
819
  var SHARE_COOKIE = '__Host-phoenix_share';
700
820
  var SHARE_COOKIE_MAX_AGE = 604800;
@@ -1140,6 +1260,18 @@ function isIdentityGated(visibility) {
1140
1260
  return visibility === 'me' || visibility === 'org';
1141
1261
  }
1142
1262
 
1263
+ function managedCoverHeaders(visibility) {
1264
+ const headers = new Headers({ 'content-type': 'image/png' });
1265
+ if (isIdentityGated(visibility)) {
1266
+ headers.set('cache-control', 'private, no-store');
1267
+ headers.set('X-Robots-Tag', 'noindex');
1268
+ } else {
1269
+ headers.set('cache-control', 'public, max-age=31536000, immutable');
1270
+ if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
1271
+ }
1272
+ return headers;
1273
+ }
1274
+
1143
1275
  // Gate a me/org read given the ALREADY-RESOLVED viewer identity. Pure/sync: the
1144
1276
  // caller resolves the viewer once (it also needs the identity for the ownership
1145
1277
  // check) and both the page GET and the ?revisions=json path share this gate.
@@ -1257,6 +1389,66 @@ async function signShareCookie(identity, env) {
1257
1389
  return SHARE_COOKIE + '=' + value + '; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=' + SHARE_COOKIE_MAX_AGE;
1258
1390
  }
1259
1391
 
1392
+ // A short-lived login ticket, self-signed with the same HMAC secret as the cookie
1393
+ // but DOMAIN-SEPARATED (the signature covers a 'ticket:'-prefixed payload), so a
1394
+ // ticket value can never be replayed as a cookie value or vice versa. It rides in
1395
+ // the ?phoenix_ticket= param for exactly one navigation; resolveViewer verifies it
1396
+ // locally and exchanges it for the __share cookie. TTL is deliberately tiny — it
1397
+ // only has to survive the round-trip from CLI mint to the browser opening the URL.
1398
+ var SELF_TICKET_MAX_AGE = 120;
1399
+
1400
+ async function signSelfTicket(identity, env) {
1401
+ const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
1402
+ if (!secret || !identity || !identity.userId) return '';
1403
+ const exp = Math.floor(Date.now() / 1000) + SELF_TICKET_MAX_AGE;
1404
+ const payload = identity.userId + '|' + (identity.email || '') + '|' + exp;
1405
+ const sig = await hmacHex(secret, 'ticket:' + payload);
1406
+ return toB64Url(payload) + '.' + sig;
1407
+ }
1408
+
1409
+ async function verifySelfTicket(ticket, env) {
1410
+ const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
1411
+ if (!secret || !ticket) return null;
1412
+ const dot = ticket.lastIndexOf('.');
1413
+ if (dot < 1) return null;
1414
+ let payload;
1415
+ try {
1416
+ payload = fromB64Url(ticket.slice(0, dot));
1417
+ } catch {
1418
+ return null;
1419
+ }
1420
+ const sig = ticket.slice(dot + 1);
1421
+ const expected = await hmacHex(secret, 'ticket:' + payload);
1422
+ if (!safeEqual(sig, expected)) return null;
1423
+ const first = payload.indexOf('|');
1424
+ const last = payload.lastIndexOf('|');
1425
+ if (first < 0 || last <= first) return null;
1426
+ const userId = payload.slice(0, first);
1427
+ const email = payload.slice(first + 1, last);
1428
+ const exp = Number(payload.slice(last + 1));
1429
+ if (!userId || !Number.isFinite(exp) || Math.floor(Date.now() / 1000) > exp) return null;
1430
+ return { userId: userId, email: email };
1431
+ }
1432
+
1433
+ // Single-use enforcement for a self-signed ticket: claim its signature the first
1434
+ // time it is redeemed, and reject any later presentation of the same ticket. The
1435
+ // HMAC + short TTL stop forgery and bound the window, but without this a ticket the
1436
+ // CLI printed (--json / --no-open / a browser-open failure) could be replayed by
1437
+ // anyone who captured that output before it expired, minting the owner's 7-day
1438
+ // cookie a second time. Get-then-put mirrors claimHandle's one-time-claim pattern;
1439
+ // the marker rides a __-prefixed key (GET-blocked, excluded from every listing).
1440
+ async function consumeSelfTicket(env, sig) {
1441
+ if (!sig) return false;
1442
+ const key = '__ticket-used/' + sig;
1443
+ const existing = await env.BUCKET.get(key);
1444
+ if (existing) return false;
1445
+ await env.BUCKET.put(key, '1', {
1446
+ httpMetadata: { contentType: 'text/plain' },
1447
+ customMetadata: { visibility: 'unlisted' },
1448
+ });
1449
+ return true;
1450
+ }
1451
+
1260
1452
  async function identityFromCookie(request, env) {
1261
1453
  const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
1262
1454
  if (!secret) return null;
@@ -1316,7 +1508,17 @@ async function resolveViewer(request, env, url) {
1316
1508
  if (cookieId) return { identity: cookieId };
1317
1509
  const ticket = url.searchParams.get('phoenix_ticket');
1318
1510
  if (!ticket) return {};
1319
- const redeemed = await redeemTicket(ticket, env);
1511
+ // Prefer the locally-verifiable self-signed ticket (PHNX-3370, no network), and
1512
+ // enforce single use by claiming its signature on first redemption — a captured
1513
+ // ticket replayed within its TTL finds it already spent. Fall back to the
1514
+ // external identity-server redeem for tickets it issued.
1515
+ let redeemed = await verifySelfTicket(ticket, env);
1516
+ if (redeemed) {
1517
+ const sig = ticket.slice(ticket.lastIndexOf('.') + 1);
1518
+ if (!(await consumeSelfTicket(env, sig))) redeemed = null;
1519
+ } else {
1520
+ redeemed = await redeemTicket(ticket, env);
1521
+ }
1320
1522
  if (!redeemed) return { error: json({ error: 'invalid ticket' }, 401) };
1321
1523
  const cookie = await signShareCookie(redeemed, env);
1322
1524
  if (!cookie) return { error: json({ error: 'phoenix login is not configured' }, 401) };
@@ -1327,6 +1529,61 @@ async function resolveViewer(request, env, url) {
1327
1529
  return { redirect: new Response(null, { status: 302, headers: headers }) };
1328
1530
  }
1329
1531
 
1532
+ function extractHtmlMeta(html, field) {
1533
+ const pattern = field === 'title'
1534
+ ? /<title[^>]*>([\\s\\S]*?)<\\/title>/i
1535
+ : /<meta[^>]+(?:name|property)=["'](?:description|og:description)["'][^>]+content=["']([^"']*)["'][^>]*>/i;
1536
+ const match = pattern.exec(html);
1537
+ return match ? match[1].replace(/<[^>]*>/g, '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').trim() : '';
1538
+ }
1539
+
1540
+ let rendererReady;
1541
+ async function renderOgCard(input) {
1542
+ if (!rendererReady) {
1543
+ rendererReady = Promise.all([
1544
+ initYoga(yogaWasm).then(function (yoga) { initSatori(yoga); }),
1545
+ initResvg(resvgWasm),
1546
+ ]);
1547
+ }
1548
+ await rendererReady;
1549
+ const visibilityLabels = {
1550
+ public: 'PUBLIC',
1551
+ unlisted: 'UNLISTED',
1552
+ me: 'ONLY YOU',
1553
+ org: input.orgDomain ? 'ANYONE AT ' + input.orgDomain.toUpperCase() : 'ORGANIZATION',
1554
+ };
1555
+ const node = {
1556
+ type: 'div',
1557
+ props: {
1558
+ style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#0a0a0a', color: '#f5f5f5', padding: '68px 76px 58px', fontFamily: 'Inter' },
1559
+ children: [
1560
+ { type: 'div', props: { style: { display: 'flex', color: '#a3e635', fontFamily: 'JetBrains Mono', fontSize: 25, fontWeight: 600, letterSpacing: '-0.5px' }, children: 'AGI · agents-cli.sh' } },
1561
+ { type: 'div', props: { style: { display: 'flex', flexDirection: 'column', flexGrow: 1, justifyContent: 'center', maxWidth: 1050 }, children: [
1562
+ { type: 'div', props: { style: { display: 'flex', fontSize: 66, lineHeight: 1.06, fontWeight: 700, letterSpacing: '-2.8px', maxHeight: 218, overflow: 'hidden' }, children: input.title || 'Shared artifact' } },
1563
+ input.description ? { type: 'div', props: { style: { display: 'flex', marginTop: 24, color: '#a3a3a3', fontSize: 27, lineHeight: 1.35, maxHeight: 74, overflow: 'hidden' }, children: input.description } } : null,
1564
+ ].filter(Boolean) } },
1565
+ { type: 'div', props: { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderTop: '1px solid #262626', paddingTop: 25, fontSize: 21, color: '#a3a3a3' }, children: [
1566
+ { type: 'div', props: { style: { display: 'flex' }, children: [
1567
+ { type: 'span', props: { style: { color: '#737373' }, children: 'shared by ' } },
1568
+ { type: 'span', props: { style: { marginLeft: 7, color: '#f5f5f5', fontWeight: 700 }, children: '@' + input.handle } },
1569
+ ] } },
1570
+ { type: 'div', props: { style: { display: 'flex', border: '1px solid #3f3f46', borderRadius: 999, padding: '9px 17px', color: '#d4d4d8', fontFamily: 'JetBrains Mono', fontSize: 16, letterSpacing: '1px' }, children: visibilityLabels[input.visibility] || 'PUBLIC' } },
1571
+ ] } },
1572
+ ],
1573
+ },
1574
+ };
1575
+ const svg = await satori(node, {
1576
+ width: 1200,
1577
+ height: 630,
1578
+ fonts: [
1579
+ { name: 'Inter', data: interRegular, weight: 400, style: 'normal' },
1580
+ { name: 'Inter', data: interBold, weight: 700, style: 'normal' },
1581
+ { name: 'JetBrains Mono', data: jetbrainsMono, weight: 600, style: 'normal' },
1582
+ ],
1583
+ });
1584
+ return new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();
1585
+ }
1586
+
1330
1587
  async function defaultVerifyPhoenixToken(request, env) {
1331
1588
  const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
1332
1589
  if (!presented) return null;
@@ -21,7 +21,12 @@ export interface ViewJsonVersion {
21
21
  amount: number;
22
22
  currency: string;
23
23
  } | null;
24
- /** Human-readable usage refresh error, when the live refresh could not populate a snapshot. */
24
+ /**
25
+ * Human-readable reason a usage snapshot is absent: a live-refresh failure, or
26
+ * (without `--refresh`, on a never-cached account) a plain "not collected yet"
27
+ * pending state. Always a full sentence — never the internal `'stale'` cache
28
+ * sentinel, which `usageErrorForDisplay` normalizes away (PHNX-3348).
29
+ */
25
30
  usageError?: string | null;
26
31
  windows: Array<{
27
32
  key: 'session' | 'week' | 'sonnet_week' | 'month';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.54",
3
+ "version": "1.22.56",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -86,8 +86,11 @@
86
86
  "@homebridge/node-pty-prebuilt-multiarch"
87
87
  ],
88
88
  "dependencies": {
89
+ "@fontsource/inter": "^5.3.0",
90
+ "@fontsource/jetbrains-mono": "^5.3.0",
89
91
  "@homebridge/node-pty-prebuilt-multiarch": "0.13.1",
90
92
  "@inquirer/prompts": "8.5.2",
93
+ "@resvg/resvg-wasm": "^2.6.2",
91
94
  "@types/proper-lockfile": "4.1.4",
92
95
  "@xterm/headless": "6.0.0",
93
96
  "@zed-industries/agent-client-protocol": "0.4.5",
@@ -98,17 +101,20 @@
98
101
  "commander": "15.0.0",
99
102
  "croner": "10.0.1",
100
103
  "diff": "9.0.0",
104
+ "esbuild": "^0.28.2",
101
105
  "lru-cache": "11.5.2",
102
106
  "marked": "15.0.12",
103
107
  "marked-terminal": "7.3.0",
104
108
  "ora": "9.4.1",
105
109
  "proper-lockfile": "4.1.2",
110
+ "satori": "0.12.2",
106
111
  "shlex": "3.0.0",
107
112
  "simple-git": "3.36.0",
108
113
  "smol-toml": "1.7.0",
109
114
  "unbash": "4.0.5",
110
115
  "ws": "^8.21.0",
111
- "yaml": "2.9.0"
116
+ "yaml": "2.9.0",
117
+ "yoga-wasm-web": "^0.3.3"
112
118
  },
113
119
  "devDependencies": {
114
120
  "@types/diff": "8.0.0",