@phnx-labs/agents-cli 1.22.54 → 1.22.55

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 (51) hide show
  1. package/CHANGELOG.md +18 -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 +85 -0
  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/scheduling/routines.d.ts +47 -0
  42. package/dist/lib/scheduling/routines.js +70 -1
  43. package/dist/lib/session/db.d.ts +2 -1
  44. package/dist/lib/session/db.js +2 -1
  45. package/dist/lib/share/capture.js +11 -2
  46. package/dist/lib/share/publish.d.ts +4 -1
  47. package/dist/lib/share/publish.js +27 -1
  48. package/dist/lib/share/worker-template.d.ts +3 -12
  49. package/dist/lib/share/worker-template.js +279 -3
  50. package/dist/lib/view-types.d.ts +6 -1
  51. package/package.json +8 -2
@@ -435,7 +435,8 @@ CREATE INDEX IF NOT EXISTS idx_computer_sessions_started ON computer_sessions(st
435
435
  */
436
436
  /** Bump when facet extraction changes so cached rows recompute (shell-command-by-binary v7). */
437
437
  export const INSIGHTS_EXTRACTOR_VERSION = 7;
438
- export const SESSION_TOPIC_EXTRACTOR_VERSION = 1;
438
+ /** Bump when classifyTopic's output changes so cached topics recompute (human task taxonomy v2). */
439
+ export const SESSION_TOPIC_EXTRACTOR_VERSION = 2;
439
440
  const PREVIEW_EXTRACTOR_VERSION = 1;
440
441
  let dbInstance = null;
441
442
  /**
@@ -36,8 +36,17 @@ export function candidateBrowsers() {
36
36
  if (p && fs.existsSync(p) && !out.includes(p))
37
37
  out.push(p);
38
38
  };
39
- // 1) An explicit override always wins.
40
- push(process.env.PUPPETEER_EXECUTABLE_PATH || process.env.AGENTS_SHARE_BROWSER);
39
+ // 1) Explicit overrides are contracts, not hints. A typo must fail at the
40
+ // boundary instead of silently falling through to an unrelated browser.
41
+ for (const name of ['PUPPETEER_EXECUTABLE_PATH', 'AGENTS_SHARE_BROWSER']) {
42
+ const value = process.env[name]?.trim();
43
+ if (!value)
44
+ continue;
45
+ if (!fs.existsSync(value)) {
46
+ throw new Error(`${name} points to a browser that does not exist: ${value}`);
47
+ }
48
+ push(value);
49
+ }
41
50
  // 2) Managed Chromium in the Playwright / Puppeteer caches — purpose-built for
42
51
  // headless, so it's the most reliable capture host when present.
43
52
  for (const bin of scanCaches())
@@ -1,4 +1,5 @@
1
1
  import { type ShareConfig } from './config.js';
2
+ import { type ShareBackendKind } from './backend.js';
2
3
  export type PutFn = (url: string, body: Buffer, headers: Record<string, string>) => Promise<{
3
4
  ok: boolean;
4
5
  status: number;
@@ -9,6 +10,8 @@ export interface PublishEndpoint {
9
10
  token: string;
10
11
  }
11
12
  export interface PublishOptions {
13
+ /** Internal resolved backend; publishFile sets this after authentication. */
14
+ backendKind?: ShareBackendKind;
12
15
  slug?: string;
13
16
  /**
14
17
  * Auto-expire window. Relative (`30d`, `12h`), absolute (`2026-08-01`), or
@@ -133,7 +136,7 @@ export interface ShareProvenance {
133
136
  * provenance the CLI sets automatically, plus `expires-at` / `visibility` /
134
137
  * `owner` which the Worker stamps itself.
135
138
  */
136
- export declare const RESERVED_META_KEYS: readonly ["expires-at", "published-at", "visibility", "owner", "org_domain", "agent", "session", "host", "repo", "date", "avatar", "label", "label-source"];
139
+ export declare const RESERVED_META_KEYS: readonly ["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"];
137
140
  /**
138
141
  * Auto-capture publish provenance from the exec env, git, and the local clock.
139
142
  * Every field is present only when the environment genuinely carries it — a
@@ -52,6 +52,10 @@ export const RESERVED_META_KEYS = [
52
52
  'avatar',
53
53
  'label',
54
54
  'label-source',
55
+ 'og-title',
56
+ 'og-description',
57
+ 'og-generated',
58
+ 'og-source-etag',
55
59
  ];
56
60
  const META_KEY_RE = /^[a-z0-9-]{1,64}$/;
57
61
  /**
@@ -506,6 +510,7 @@ export async function publishFile(filePath, opts = {}) {
506
510
  ...opts,
507
511
  githubUser: username,
508
512
  analyticsToken,
513
+ backendKind: backend.kind,
509
514
  });
510
515
  }
511
516
  export async function publishToEndpoint(filePath, endpoint, opts = {}) {
@@ -536,6 +541,19 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
536
541
  // is only reached when --label is omitted.
537
542
  const label = explicitLabel ? sanitizeLabel(explicitLabel) : deriveLabel(filePath, body);
538
543
  const labelSource = explicitLabel ? 'explicit' : 'derived';
544
+ const ogMeta = isHtml ? deriveMeta(body.toString('utf8')) : undefined;
545
+ // The managed Worker owns deterministic OG generation. Point crawlers at the
546
+ // lazy sibling route without invoking a browser on the publishing machine.
547
+ if (isHtml && opts.cover !== false && opts.backendKind === 'managed' && ogMeta) {
548
+ coverUrl = `${pageUrl}.png`;
549
+ body = Buffer.from(injectOgMeta(body.toString('utf8'), {
550
+ ...ogMeta,
551
+ imageUrl: coverUrl,
552
+ pageUrl,
553
+ imageWidth: OG_WIDTH,
554
+ imageHeight: OG_HEIGHT,
555
+ }), 'utf8');
556
+ }
539
557
  // Pre-publish scan (RUSH-2443/RUSH-2683): refuse emails / credential-shaped
540
558
  // strings unless --force. Runs on the raw file body AND on every piece of
541
559
  // free-text metadata that lands in public customMetadata — --label (explicit
@@ -558,6 +576,10 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
558
576
  // Validate the FULL customMetadata payload before any network call — fail
559
577
  // fast, not mid-upload.
560
578
  const metadataPreview = { ...meta, label, 'label-source': labelSource };
579
+ if (ogMeta && opts.backendKind === 'managed') {
580
+ metadataPreview['og-title'] = ogMeta.title;
581
+ metadataPreview['og-description'] = ogMeta.description;
582
+ }
561
583
  if (provenance.agent)
562
584
  metadataPreview.agent = provenance.agent;
563
585
  if (provenance.session)
@@ -606,6 +628,10 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
606
628
  setText('x-share-avatar', avatarUrl);
607
629
  setText('x-share-label', label);
608
630
  h['x-share-label-source'] = labelSource;
631
+ if (ogMeta && opts.backendKind === 'managed') {
632
+ setText('x-share-og-title', ogMeta.title);
633
+ setText('x-share-og-description', ogMeta.description);
634
+ }
609
635
  // Per VALUE, before JSON.stringify — folding the serialized form would rewrite
610
636
  // a curly quote inside a value into a bare `"`, which is structural in JSON and
611
637
  // makes the Worker's JSON.parse throw. It swallows that error, so every --meta
@@ -633,7 +659,7 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
633
659
  // Cover: screenshot the page's hero → upload <slug>.png → inject og:image meta.
634
660
  // Unlisted pages still get a cover (the direct URL is the capability), but the
635
661
  // cover inherits visibility=unlisted so it is also omitted from the gallery.
636
- if (isHtml && opts.cover !== false) {
662
+ if (isHtml && opts.cover !== false && opts.backendKind !== 'managed') {
637
663
  const res = await attachOgCover(filePath, body, {
638
664
  pngUrl: `${pageUrl}.png`,
639
665
  pageUrl,
@@ -1,13 +1,4 @@
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
- */
1
+ /** Bundle the Worker and its renderer into one uploadable ES module. */
13
2
  export declare function renderWorkerScript(): string;
3
+ /** Unbundled Worker source. Kept separate so esbuild can resolve npm modules. */
4
+ export declare function renderWorkerSource(): string;
@@ -1,3 +1,6 @@
1
+ import { buildSync } from 'esbuild';
2
+ import { dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
1
4
  // The Cloudflare Worker that fronts the R2 share bucket.
2
5
  //
3
6
  // One tiny Worker does both sides:
@@ -68,11 +71,48 @@
68
71
  * until its owner re-runs `agents artifacts share update`. Cosmetic renames are not
69
72
  * worth that; change this text only alongside a real Worker behavior change.
70
73
  */
74
+ let bundledWorkerScript;
75
+ /** Bundle the Worker and its renderer into one uploadable ES module. */
71
76
  export function renderWorkerScript() {
72
- return `// GENERATED by agents-cli agents share setup — do not edit here; edit
77
+ if (bundledWorkerScript)
78
+ return bundledWorkerScript;
79
+ const result = buildSync({
80
+ stdin: {
81
+ contents: renderWorkerSource(),
82
+ loader: 'js',
83
+ resolveDir: dirname(fileURLToPath(import.meta.url)),
84
+ sourcefile: 'agents-share-worker.js',
85
+ },
86
+ bundle: true,
87
+ format: 'esm',
88
+ platform: 'browser',
89
+ target: 'es2022',
90
+ write: false,
91
+ minify: true,
92
+ loader: { '.wasm': 'binary', '.woff': 'binary' },
93
+ });
94
+ const output = result.outputFiles[0];
95
+ if (!output)
96
+ throw new Error('Worker bundling produced no JavaScript output.');
97
+ bundledWorkerScript = output.text;
98
+ return bundledWorkerScript;
99
+ }
100
+ /** Unbundled Worker source. Kept separate so esbuild can resolve npm modules. */
101
+ export function renderWorkerSource() {
102
+ return `import satori, { init as initSatori } from 'satori/wasm';
103
+ import initYoga from 'yoga-wasm-web';
104
+ import { Resvg, initWasm as initResvg } from '@resvg/resvg-wasm';
105
+ import yogaWasm from 'yoga-wasm-web/dist/yoga.wasm';
106
+ import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm';
107
+ import interRegular from '@fontsource/inter/files/inter-latin-400-normal.woff';
108
+ import interBold from '@fontsource/inter/files/inter-latin-700-normal.woff';
109
+ import jetbrainsMono from '@fontsource/jetbrains-mono/files/jetbrains-mono-latin-600-normal.woff';
110
+
111
+ // GENERATED by agents-cli agents share setup — do not edit here; edit
73
112
  // src/lib/share/worker-template.ts and re-run setup.
74
113
  export const hooks = {
75
114
  verifyPhoenixToken: defaultVerifyPhoenixToken,
115
+ renderOgCard: renderOgCard,
76
116
  };
77
117
 
78
118
  export default {
@@ -91,6 +131,21 @@ export default {
91
131
  return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
92
132
  }
93
133
 
134
+ // POST /__ticket — mint a short-lived, single-navigation login ticket for the
135
+ // authenticated owner (PHNX-3370). The CLI (which holds the Phoenix bearer)
136
+ // calls this, then opens the owner's page with ?phoenix_ticket=<t> so the
137
+ // browser trades it for the __share cookie and the inline visibility control
138
+ // goes live. The ticket is SELF-signed with the Worker's own HMAC secret — no
139
+ // external ticket service — and carries the SAME identity authorizeWrite
140
+ // already proved, so it grants nothing the caller didn't already have.
141
+ if (request.method === 'POST' && firstSeg === '__ticket') {
142
+ const auth = await authorizeWrite(request, env);
143
+ if (auth.error) return auth.error;
144
+ const ticket = await signSelfTicket({ userId: auth.owner, email: auth.email || '' }, env);
145
+ if (!ticket) return json({ error: 'ticket minting is not configured' }, 501);
146
+ return json({ ticket: ticket }, 200);
147
+ }
148
+
94
149
  if (request.method === 'PUT') {
95
150
  const auth = await authorizeWrite(request, env);
96
151
  if (auth.error) return auth.error;
@@ -162,6 +217,8 @@ export default {
162
217
  const date = readText('date');
163
218
  const avatar = readText('avatar');
164
219
  const label = readText('label');
220
+ const ogTitle = readText('og-title');
221
+ const ogDescription = readText('og-description');
165
222
  const labelSource = request.headers.get('x-share-label-source') || '';
166
223
  let extraMeta = {};
167
224
  // The -u meta companion carries the whole raw object percent-encoded, so a
@@ -210,6 +267,8 @@ export default {
210
267
  if (avatar) customMetadata['avatar'] = avatar;
211
268
  if (label) customMetadata['label'] = label;
212
269
  if (labelSource) customMetadata['label-source'] = labelSource;
270
+ if (ogTitle) customMetadata['og-title'] = ogTitle;
271
+ if (ogDescription) customMetadata['og-description'] = ogDescription;
213
272
 
214
273
  // Revision retention (RUSH-2683): R2 has no native object versioning, so a
215
274
  // republish over an EXISTING key first copies the current object to
@@ -238,6 +297,10 @@ export default {
238
297
  httpMetadata: { contentType },
239
298
  customMetadata,
240
299
  });
300
+ // A managed republish may change the title/description. Invalidate only
301
+ // its generated sibling so the next crawler receives a fresh card; BYO
302
+ // publishes send no OG metadata and keep their explicitly uploaded cover.
303
+ if (ogTitle) await env.BUCKET.delete(path + '.png');
241
304
  return json({ ok: true, url: url.origin + '/' + path, expiresAt: expiresAt || null, unlisted: visibility === 'unlisted', visibility }, 200);
242
305
  }
243
306
 
@@ -263,6 +326,7 @@ export default {
263
326
  if (!edit || typeof edit !== 'object') return json({ error: 'PATCH body must be an object' }, 400);
264
327
  const metadata = { ...(existing.customMetadata || {}) };
265
328
  const previousVisibility = metadata.visibility || 'public';
329
+ let visibilityChanged = false;
266
330
  if (Object.prototype.hasOwnProperty.call(edit, 'label')) {
267
331
  if (edit.label !== null && typeof edit.label !== 'string') return json({ error: 'label must be a string or null' }, 400);
268
332
  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 +353,7 @@ export default {
289
353
  }
290
354
  }
291
355
  metadata.visibility = visibility;
356
+ visibilityChanged = visibility !== previousVisibility;
292
357
  // org_domain is meaningful only for org — set it there, drop the stale
293
358
  // value on any move away from org so it can never gate a later read.
294
359
  if (visibility === 'org') metadata.org_domain = emailDomain(auth.email);
@@ -327,6 +392,16 @@ export default {
327
392
  if (existing.etag) putOpts.onlyIf = { etagMatches: existing.etag };
328
393
  const putResult = await env.BUCKET.put(path, existing.body, putOpts);
329
394
  if (putResult === null) return json({ error: 'conflict', key: path }, 409);
395
+ // A generated cover copies the canonical visibility gate into its own R2
396
+ // metadata. Invalidate it after a successful visibility rewrite so the
397
+ // next cover GET re-gates and renders from the canonical page. Explicitly
398
+ // uploaded BYO siblings carry no marker and remain independent assets.
399
+ if (visibilityChanged) {
400
+ const cover = await env.BUCKET.get(path + '.png');
401
+ if (cover && cover.customMetadata && cover.customMetadata['og-generated'] === 'true') {
402
+ await env.BUCKET.delete(path + '.png');
403
+ }
404
+ }
330
405
  return json({ ok: true, url: url.origin + '/' + path, label: metadata.label || null, meta: extraMetaOf(metadata), visibility: metadata.visibility || 'public', previousVisibility: previousVisibility }, 200);
331
406
  }
332
407
 
@@ -383,6 +458,70 @@ export default {
383
458
  );
384
459
  }
385
460
 
461
+ // Managed OG cover: generated siblings are caches, never visibility
462
+ // authorities. Gate every request against the current canonical page and
463
+ // bind each cached render to that page's etag. Re-read after rendering and
464
+ // after storing so a concurrent PATCH cannot publish a card from the old
465
+ // visibility/title/description snapshot. Explicit BYO siblings carry no
466
+ // generated marker and continue through the ordinary object route below.
467
+ if (segments.length === 2 && path.endsWith('.png')) {
468
+ let existingCover = await env.BUCKET.get(path);
469
+ if (!existingCover || (existingCover.customMetadata && existingCover.customMetadata['og-generated'] === 'true')) {
470
+ const pagePath = path.slice(0, -4);
471
+ const viewer = await resolveViewer(request, env, url);
472
+ if (viewer.redirect) return viewer.redirect;
473
+ for (let attempt = 0; attempt < 3; attempt += 1) {
474
+ const page = await env.BUCKET.get(pagePath);
475
+ if (!page) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
476
+ const pageVisibility = (page.customMetadata && page.customMetadata.visibility) || 'public';
477
+ if (viewer.error && isIdentityGated(pageVisibility)) return viewer.error;
478
+ const denied = gateVisibility(url, env, page, viewer.identity || null);
479
+ if (denied) return denied;
480
+
481
+ if (existingCover && existingCover.customMetadata['og-source-etag'] === page.etag) {
482
+ const current = await env.BUCKET.get(pagePath);
483
+ if (!current || current.etag !== page.etag) { existingCover = null; continue; }
484
+ const currentDenied = gateVisibility(url, env, current, viewer.identity || null);
485
+ if (currentDenied) return currentDenied;
486
+ return new Response(request.method === 'HEAD' ? null : existingCover.body, {
487
+ status: 200,
488
+ headers: managedCoverHeaders(pageVisibility),
489
+ });
490
+ }
491
+
492
+ const pageHtml = await page.text();
493
+ 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
+ });
501
+ const beforeStore = await env.BUCKET.get(pagePath);
502
+ if (!beforeStore || beforeStore.etag !== page.etag) { existingCover = null; continue; }
503
+ await env.BUCKET.put(path, png, {
504
+ httpMetadata: { contentType: 'image/png' },
505
+ customMetadata: { ...meta, 'og-generated': 'true', 'og-source-etag': page.etag },
506
+ });
507
+ const beforeServe = await env.BUCKET.get(pagePath);
508
+ if (!beforeServe || beforeServe.etag !== page.etag) {
509
+ const justStored = await env.BUCKET.get(path);
510
+ if (justStored && justStored.customMetadata && justStored.customMetadata['og-source-etag'] === page.etag) {
511
+ await env.BUCKET.delete(path);
512
+ }
513
+ existingCover = null;
514
+ continue;
515
+ }
516
+ return new Response(request.method === 'HEAD' ? null : png, {
517
+ status: 200,
518
+ headers: managedCoverHeaders(pageVisibility),
519
+ });
520
+ }
521
+ return new Response('cover changed during rendering; retry', { status: 503, headers: { 'content-type': 'text/plain', 'retry-after': '1' } });
522
+ }
523
+ }
524
+
386
525
  const obj = await env.BUCKET.get(path);
387
526
  if (!obj) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
388
527
  const expiresAt = obj.customMetadata && obj.customMetadata['expires-at'];
@@ -694,7 +833,7 @@ async function renderRevisions(bucket, origin, key, method, identityGated) {
694
833
  // before it ever reaches this Worker; see RESERVED_META_KEYS in publish.ts).
695
834
  // One list, reused both to strip a same-named --meta collision on write and
696
835
  // 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'];
836
+ 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
837
  var PUBLIC_INBOX_DOMAINS = ['gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'icloud.com', 'me.com'];
699
838
  var SHARE_COOKIE = '__Host-phoenix_share';
700
839
  var SHARE_COOKIE_MAX_AGE = 604800;
@@ -1140,6 +1279,18 @@ function isIdentityGated(visibility) {
1140
1279
  return visibility === 'me' || visibility === 'org';
1141
1280
  }
1142
1281
 
1282
+ function managedCoverHeaders(visibility) {
1283
+ const headers = new Headers({ 'content-type': 'image/png' });
1284
+ if (isIdentityGated(visibility)) {
1285
+ headers.set('cache-control', 'private, no-store');
1286
+ headers.set('X-Robots-Tag', 'noindex');
1287
+ } else {
1288
+ headers.set('cache-control', 'public, max-age=31536000, immutable');
1289
+ if (visibility === 'unlisted') headers.set('X-Robots-Tag', 'noindex');
1290
+ }
1291
+ return headers;
1292
+ }
1293
+
1143
1294
  // Gate a me/org read given the ALREADY-RESOLVED viewer identity. Pure/sync: the
1144
1295
  // caller resolves the viewer once (it also needs the identity for the ownership
1145
1296
  // check) and both the page GET and the ?revisions=json path share this gate.
@@ -1257,6 +1408,66 @@ async function signShareCookie(identity, env) {
1257
1408
  return SHARE_COOKIE + '=' + value + '; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=' + SHARE_COOKIE_MAX_AGE;
1258
1409
  }
1259
1410
 
1411
+ // A short-lived login ticket, self-signed with the same HMAC secret as the cookie
1412
+ // but DOMAIN-SEPARATED (the signature covers a 'ticket:'-prefixed payload), so a
1413
+ // ticket value can never be replayed as a cookie value or vice versa. It rides in
1414
+ // the ?phoenix_ticket= param for exactly one navigation; resolveViewer verifies it
1415
+ // locally and exchanges it for the __share cookie. TTL is deliberately tiny — it
1416
+ // only has to survive the round-trip from CLI mint to the browser opening the URL.
1417
+ var SELF_TICKET_MAX_AGE = 120;
1418
+
1419
+ async function signSelfTicket(identity, env) {
1420
+ const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
1421
+ if (!secret || !identity || !identity.userId) return '';
1422
+ const exp = Math.floor(Date.now() / 1000) + SELF_TICKET_MAX_AGE;
1423
+ const payload = identity.userId + '|' + (identity.email || '') + '|' + exp;
1424
+ const sig = await hmacHex(secret, 'ticket:' + payload);
1425
+ return toB64Url(payload) + '.' + sig;
1426
+ }
1427
+
1428
+ async function verifySelfTicket(ticket, env) {
1429
+ const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
1430
+ if (!secret || !ticket) return null;
1431
+ const dot = ticket.lastIndexOf('.');
1432
+ if (dot < 1) return null;
1433
+ let payload;
1434
+ try {
1435
+ payload = fromB64Url(ticket.slice(0, dot));
1436
+ } catch {
1437
+ return null;
1438
+ }
1439
+ const sig = ticket.slice(dot + 1);
1440
+ const expected = await hmacHex(secret, 'ticket:' + payload);
1441
+ if (!safeEqual(sig, expected)) return null;
1442
+ const first = payload.indexOf('|');
1443
+ const last = payload.lastIndexOf('|');
1444
+ if (first < 0 || last <= first) return null;
1445
+ const userId = payload.slice(0, first);
1446
+ const email = payload.slice(first + 1, last);
1447
+ const exp = Number(payload.slice(last + 1));
1448
+ if (!userId || !Number.isFinite(exp) || Math.floor(Date.now() / 1000) > exp) return null;
1449
+ return { userId: userId, email: email };
1450
+ }
1451
+
1452
+ // Single-use enforcement for a self-signed ticket: claim its signature the first
1453
+ // time it is redeemed, and reject any later presentation of the same ticket. The
1454
+ // HMAC + short TTL stop forgery and bound the window, but without this a ticket the
1455
+ // CLI printed (--json / --no-open / a browser-open failure) could be replayed by
1456
+ // anyone who captured that output before it expired, minting the owner's 7-day
1457
+ // cookie a second time. Get-then-put mirrors claimHandle's one-time-claim pattern;
1458
+ // the marker rides a __-prefixed key (GET-blocked, excluded from every listing).
1459
+ async function consumeSelfTicket(env, sig) {
1460
+ if (!sig) return false;
1461
+ const key = '__ticket-used/' + sig;
1462
+ const existing = await env.BUCKET.get(key);
1463
+ if (existing) return false;
1464
+ await env.BUCKET.put(key, '1', {
1465
+ httpMetadata: { contentType: 'text/plain' },
1466
+ customMetadata: { visibility: 'unlisted' },
1467
+ });
1468
+ return true;
1469
+ }
1470
+
1260
1471
  async function identityFromCookie(request, env) {
1261
1472
  const secret = typeof env.WRITE_TOKEN === 'string' ? env.WRITE_TOKEN : '';
1262
1473
  if (!secret) return null;
@@ -1316,7 +1527,17 @@ async function resolveViewer(request, env, url) {
1316
1527
  if (cookieId) return { identity: cookieId };
1317
1528
  const ticket = url.searchParams.get('phoenix_ticket');
1318
1529
  if (!ticket) return {};
1319
- const redeemed = await redeemTicket(ticket, env);
1530
+ // Prefer the locally-verifiable self-signed ticket (PHNX-3370, no network), and
1531
+ // enforce single use by claiming its signature on first redemption — a captured
1532
+ // ticket replayed within its TTL finds it already spent. Fall back to the
1533
+ // external identity-server redeem for tickets it issued.
1534
+ let redeemed = await verifySelfTicket(ticket, env);
1535
+ if (redeemed) {
1536
+ const sig = ticket.slice(ticket.lastIndexOf('.') + 1);
1537
+ if (!(await consumeSelfTicket(env, sig))) redeemed = null;
1538
+ } else {
1539
+ redeemed = await redeemTicket(ticket, env);
1540
+ }
1320
1541
  if (!redeemed) return { error: json({ error: 'invalid ticket' }, 401) };
1321
1542
  const cookie = await signShareCookie(redeemed, env);
1322
1543
  if (!cookie) return { error: json({ error: 'phoenix login is not configured' }, 401) };
@@ -1327,6 +1548,61 @@ async function resolveViewer(request, env, url) {
1327
1548
  return { redirect: new Response(null, { status: 302, headers: headers }) };
1328
1549
  }
1329
1550
 
1551
+ function extractHtmlMeta(html, field) {
1552
+ const pattern = field === 'title'
1553
+ ? /<title[^>]*>([\\s\\S]*?)<\\/title>/i
1554
+ : /<meta[^>]+(?:name|property)=["'](?:description|og:description)["'][^>]+content=["']([^"']*)["'][^>]*>/i;
1555
+ const match = pattern.exec(html);
1556
+ return match ? match[1].replace(/<[^>]*>/g, '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').trim() : '';
1557
+ }
1558
+
1559
+ let rendererReady;
1560
+ async function renderOgCard(input) {
1561
+ if (!rendererReady) {
1562
+ rendererReady = Promise.all([
1563
+ initYoga(yogaWasm).then(function (yoga) { initSatori(yoga); }),
1564
+ initResvg(resvgWasm),
1565
+ ]);
1566
+ }
1567
+ await rendererReady;
1568
+ const visibilityLabels = {
1569
+ public: 'PUBLIC',
1570
+ unlisted: 'UNLISTED',
1571
+ me: 'ONLY YOU',
1572
+ org: input.orgDomain ? 'ANYONE AT ' + input.orgDomain.toUpperCase() : 'ORGANIZATION',
1573
+ };
1574
+ const node = {
1575
+ type: 'div',
1576
+ props: {
1577
+ style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#0a0a0a', color: '#f5f5f5', padding: '68px 76px 58px', fontFamily: 'Inter' },
1578
+ children: [
1579
+ { type: 'div', props: { style: { display: 'flex', color: '#a3e635', fontFamily: 'JetBrains Mono', fontSize: 25, fontWeight: 600, letterSpacing: '-0.5px' }, children: 'AGI · agents-cli.sh' } },
1580
+ { type: 'div', props: { style: { display: 'flex', flexDirection: 'column', flexGrow: 1, justifyContent: 'center', maxWidth: 1050 }, children: [
1581
+ { 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' } },
1582
+ 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,
1583
+ ].filter(Boolean) } },
1584
+ { type: 'div', props: { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderTop: '1px solid #262626', paddingTop: 25, fontSize: 21, color: '#a3a3a3' }, children: [
1585
+ { type: 'div', props: { style: { display: 'flex' }, children: [
1586
+ { type: 'span', props: { style: { color: '#737373' }, children: 'shared by ' } },
1587
+ { type: 'span', props: { style: { marginLeft: 7, color: '#f5f5f5', fontWeight: 700 }, children: '@' + input.handle } },
1588
+ ] } },
1589
+ { 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' } },
1590
+ ] } },
1591
+ ],
1592
+ },
1593
+ };
1594
+ const svg = await satori(node, {
1595
+ width: 1200,
1596
+ height: 630,
1597
+ fonts: [
1598
+ { name: 'Inter', data: interRegular, weight: 400, style: 'normal' },
1599
+ { name: 'Inter', data: interBold, weight: 700, style: 'normal' },
1600
+ { name: 'JetBrains Mono', data: jetbrainsMono, weight: 600, style: 'normal' },
1601
+ ],
1602
+ });
1603
+ return new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();
1604
+ }
1605
+
1330
1606
  async function defaultVerifyPhoenixToken(request, env) {
1331
1607
  const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
1332
1608
  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.55",
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",