@phnx-labs/agents-cli 1.22.75 → 1.22.76

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 (132) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/README.md +20 -8
  3. package/dist/bootstrap.js +7 -7
  4. package/dist/cli/command-registry.js +5 -0
  5. package/dist/commands/artifacts-setup.js +1 -1
  6. package/dist/commands/artifacts.js +1 -1
  7. package/dist/commands/auth.js +7 -1
  8. package/dist/commands/browser.js +104 -10
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/config.js +27 -4
  11. package/dist/commands/cost.js +6 -4
  12. package/dist/commands/doctor.d.ts +6 -5
  13. package/dist/commands/doctor.js +32 -274
  14. package/dist/commands/exec.d.ts +2 -0
  15. package/dist/commands/exec.js +9 -2
  16. package/dist/commands/harness.d.ts +1 -0
  17. package/dist/commands/harness.js +11 -3
  18. package/dist/commands/hooks.js +7 -6
  19. package/dist/commands/mcp.js +7 -6
  20. package/dist/commands/memory.js +7 -7
  21. package/dist/commands/monitors.js +3 -2
  22. package/dist/commands/open.d.ts +25 -12
  23. package/dist/commands/open.js +24 -10
  24. package/dist/commands/permissions.js +7 -6
  25. package/dist/commands/plugins.js +21 -17
  26. package/dist/commands/route.js +33 -16
  27. package/dist/commands/rules.js +7 -12
  28. package/dist/commands/sessions-share.js +1 -1
  29. package/dist/commands/setup-watchdog.js +2 -2
  30. package/dist/commands/setup.js +22 -1
  31. package/dist/commands/share.js +26 -10
  32. package/dist/commands/skills.js +7 -6
  33. package/dist/commands/subagents.js +7 -6
  34. package/dist/commands/sync.js +81 -10
  35. package/dist/commands/view.js +4 -1
  36. package/dist/commands/watchdog.d.ts +1 -1
  37. package/dist/commands/watchdog.js +10 -10
  38. package/dist/commands/webhook.d.ts +4 -0
  39. package/dist/commands/webhook.js +22 -4
  40. package/dist/commands/workflows.js +7 -6
  41. package/dist/lib/accounting/rotate.d.ts +3 -1
  42. package/dist/lib/accounting/rotate.js +8 -4
  43. package/dist/lib/auth-health.d.ts +2 -0
  44. package/dist/lib/auth-health.js +2 -0
  45. package/dist/lib/browser/chrome.d.ts +21 -0
  46. package/dist/lib/browser/chrome.js +60 -3
  47. package/dist/lib/browser/drivers/local.d.ts +21 -0
  48. package/dist/lib/browser/drivers/local.js +102 -9
  49. package/dist/lib/browser/profiles.d.ts +29 -1
  50. package/dist/lib/browser/profiles.js +50 -1
  51. package/dist/lib/browser/types.d.ts +18 -0
  52. package/dist/lib/config-keys.d.ts +7 -2
  53. package/dist/lib/config-keys.js +17 -2
  54. package/dist/lib/daemon/daemon.js +8 -0
  55. package/dist/lib/daemon/session-summarizer-service.d.ts +24 -0
  56. package/dist/lib/daemon/session-summarizer-service.js +39 -0
  57. package/dist/lib/daemon-services.d.ts +1 -1
  58. package/dist/lib/daemon-services.js +5 -0
  59. package/dist/lib/daemon-ticks.d.ts +2 -2
  60. package/dist/lib/daemon-ticks.js +2 -1
  61. package/dist/lib/deeplink/register.js +10 -9
  62. package/dist/lib/deeplink/url.d.ts +4 -4
  63. package/dist/lib/deeplink/url.js +4 -4
  64. package/dist/lib/device-config.js +25 -0
  65. package/dist/lib/devices/doctor-findings.d.ts +4 -4
  66. package/dist/lib/devices/doctor-findings.js +14 -8
  67. package/dist/lib/devices/registry.js +2 -0
  68. package/dist/lib/devices/stats-cache.d.ts +4 -0
  69. package/dist/lib/devices/stats-cache.js +19 -0
  70. package/dist/lib/drift-sync.d.ts +3 -1
  71. package/dist/lib/drift-sync.js +16 -5
  72. package/dist/lib/exec.d.ts +2 -0
  73. package/dist/lib/exec.js +16 -1
  74. package/dist/lib/fleet-shared-state.d.ts +8 -0
  75. package/dist/lib/heal.d.ts +4 -3
  76. package/dist/lib/heal.js +5 -4
  77. package/dist/lib/hosts/ready.d.ts +1 -1
  78. package/dist/lib/hosts/ready.js +16 -4
  79. package/dist/lib/hosts/reconnect.js +4 -2
  80. package/dist/lib/identity/client.d.ts +6 -0
  81. package/dist/lib/identity/index.d.ts +16 -0
  82. package/dist/lib/identity/index.js +25 -1
  83. package/dist/lib/profiles.d.ts +2 -0
  84. package/dist/lib/profiles.js +28 -9
  85. package/dist/lib/reconcile-and-repair.d.ts +109 -0
  86. package/dist/lib/reconcile-and-repair.js +267 -0
  87. package/dist/lib/routers.d.ts +12 -1
  88. package/dist/lib/routers.js +30 -1
  89. package/dist/lib/scheduling/routines.js +8 -2
  90. package/dist/lib/session/active.d.ts +13 -0
  91. package/dist/lib/session/db.d.ts +47 -7
  92. package/dist/lib/session/db.js +114 -12
  93. package/dist/lib/session/mirror.js +58 -0
  94. package/dist/lib/session/remote/watch.js +22 -2
  95. package/dist/lib/session/session-cache.d.ts +19 -0
  96. package/dist/lib/session/session-cache.js +46 -0
  97. package/dist/lib/session/types.d.ts +34 -0
  98. package/dist/lib/share/backend.d.ts +6 -4
  99. package/dist/lib/share/backend.js +10 -8
  100. package/dist/lib/share/config.d.ts +4 -3
  101. package/dist/lib/share/config.js +10 -1
  102. package/dist/lib/share/delete.d.ts +1 -1
  103. package/dist/lib/share/delete.js +1 -1
  104. package/dist/lib/share/html.d.ts +1 -1
  105. package/dist/lib/share/html.js +1 -1
  106. package/dist/lib/share/provision.d.ts +1 -1
  107. package/dist/lib/share/provision.js +2 -2
  108. package/dist/lib/share/publish.d.ts +23 -7
  109. package/dist/lib/share/publish.js +58 -12
  110. package/dist/lib/share/worker-template.js +221 -60
  111. package/dist/lib/startup/command-registry.js +2 -2
  112. package/dist/lib/state.d.ts +15 -0
  113. package/dist/lib/state.js +29 -7
  114. package/dist/lib/summarizer/config.d.ts +46 -0
  115. package/dist/lib/summarizer/config.js +83 -0
  116. package/dist/lib/summarizer/pass.d.ts +45 -0
  117. package/dist/lib/summarizer/pass.js +112 -0
  118. package/dist/lib/summarizer/summarize.d.ts +68 -0
  119. package/dist/lib/summarizer/summarize.js +120 -0
  120. package/dist/lib/teams/agents.d.ts +4 -3
  121. package/dist/lib/teams/agents.js +12 -4
  122. package/dist/lib/teams/scheduler.d.ts +4 -2
  123. package/dist/lib/teams/scheduler.js +6 -6
  124. package/dist/lib/tmux/session.d.ts +2 -0
  125. package/dist/lib/tmux/session.js +7 -1
  126. package/dist/lib/types.d.ts +20 -0
  127. package/dist/lib/verbs.d.ts +23 -0
  128. package/dist/lib/verbs.js +24 -0
  129. package/dist/lib/view-types.d.ts +4 -0
  130. package/dist/lib/watchdog/rotate.d.ts +1 -1
  131. package/dist/lib/watchdog/rotate.js +1 -1
  132. package/package.json +1 -1
@@ -151,11 +151,22 @@ export default {
151
151
  }
152
152
  const segments = path.split('/').filter(Boolean);
153
153
  if (auth.kind === 'phoenix') {
154
- const expected = phoenixHandle(auth);
154
+ // The caller's handle is normally derived from the email local-part. An
155
+ // explicit x-share-handle (the CLI's --handle, PHNX-3547) lets a Phoenix
156
+ // user choose a DIFFERENT free handle — the escape hatch when their
157
+ // derived handle is taken or they want a vanity namespace. Sanitized to
158
+ // the same [a-z0-9-] shape; claim rules below bind it first-writer-writes,
159
+ // exactly like a derived handle.
160
+ const requestedRaw = request.headers.get('x-share-handle') || '';
161
+ const requested = sanitizeNamespace(requestedRaw);
162
+ if (requestedRaw && (!requested || requested.length > 63)) {
163
+ return json({ error: 'invalid handle', handle: requestedRaw }, 400);
164
+ }
165
+ const expected = requested || phoenixHandle(auth);
155
166
  if (!expected || segments[0] !== expected) {
156
167
  return json({ error: 'namespace mismatch', owner: expected }, 403);
157
168
  }
158
- const claimed = await claimHandle(env.BUCKET, expected, auth.owner);
169
+ const claimed = await claimHandle(env.BUCKET, expected, auth.owner, auth.email || '');
159
170
  if (claimed.error) return claimed.error;
160
171
  }
161
172
  const expiresAt = request.headers.get('x-share-expires-at') || '';
@@ -268,11 +279,8 @@ export default {
268
279
  // publish rate limit, enforced ONLY for a managed Phoenix identity. A BYO
269
280
  // WRITE_TOKEN publish writes to the operator's OWN bucket at their own
270
281
  // cost, so it skips all four — a deliberate, documented policy, NOT a
271
- // silent no-op. The current object is needed for BOTH the revision copy and
272
- // the charge math, so read it once here; a BYO no-revision publish still
273
- // skips the read entirely (nothing consumes it).
274
- const needExisting = !noRevision || auth.kind === 'phoenix';
275
- const existing = needExisting ? await env.BUCKET.get(path) : null;
282
+ // silent no-op.
283
+ //
276
284
  // Enforcement measures the REAL request body, never a client-declared size.
277
285
  // A spoofed-low content-length must NOT (a) slip an oversized body past the
278
286
  // per-file cap, (b) let real bytes exceed the total quota, or — most
@@ -281,8 +289,10 @@ export default {
281
289
  // Phoenix write we buffer the body bounded by the plan's per-file cap and
282
290
  // reject on the REAL size BEFORE any write; only then do we copy the
283
291
  // revision and store the buffered bytes. BYO streams unbuffered (uncapped,
284
- // its own bucket).
292
+ // its own bucket) — which also means a BYO body cannot be re-read for a
293
+ // retry, so BYO gets a single conditional attempt below.
285
294
  let putBody = request.body;
295
+ let realBytes = 0;
286
296
  if (auth.kind === 'phoenix') {
287
297
  const limits = planLimits((await readUsage(env, auth.owner)).usage.plan);
288
298
  // Fast-reject an HONEST oversized content-length without reading the body.
@@ -290,47 +300,101 @@ export default {
290
300
  // read below, which measures the truth.
291
301
  const declaredLen = parseInt(request.headers.get('content-length') || '', 10);
292
302
  if (Number.isFinite(declaredLen) && declaredLen > limits.maxFileBytes) {
293
- return json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: declaredLen }, 413);
303
+ return json({ error: 'file too large: max ' + limits.maxFileBytes + ' bytes', maxBytes: limits.maxFileBytes, gotBytes: declaredLen }, 413);
294
304
  }
295
305
  // readBodyBounded aborts the moment it passes the cap, so a chunked/
296
306
  // streaming body can never buffer more than the cap (+ one chunk).
297
307
  const read = await readBodyBounded(request, limits.maxFileBytes);
298
308
  if (read.oversize) {
299
- return json({ error: 'file too large', maxBytes: limits.maxFileBytes, gotBytes: read.size }, 413);
309
+ return json({ error: 'file too large: max ' + limits.maxFileBytes + ' bytes', maxBytes: limits.maxFileBytes, gotBytes: read.size }, 413);
300
310
  }
301
- const realBytes = read.size;
302
- const existingSize = existing && typeof existing.size === 'number' ? existing.size : 0;
303
- const newCanonical = !existing;
304
- // Keeping a revision retains the old canonical bytes AND adds the new
305
- // ones, so storage grows by the full new size. A no-revision or first
306
- // publish grows by new minus the bytes it replaces (may be negative on a
307
- // shrink; the ledger clamps at >= 0).
308
- const charge = (!noRevision && existing) ? realBytes : realBytes - existingSize;
309
- const charged = await chargeShareWrite(env, auth, {
310
- charge: charge,
311
- newCanonical: newCanonical,
312
- fileBytes: realBytes,
313
- countRate: true,
314
- });
315
- if (charged.error) return charged.error; // rejected BEFORE any destructive write
311
+ realBytes = read.size;
316
312
  putBody = read.bytes;
317
313
  }
318
314
 
319
- if (!noRevision && existing) {
320
- const existingHeaders = new Headers();
321
- if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(existingHeaders);
322
- const existingContentType = existingHeaders.get('content-type');
323
- const revKey = path + '/rev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
324
- await env.BUCKET.put(revKey, existing.body, {
325
- httpMetadata: existingContentType ? { contentType: existingContentType } : undefined,
326
- customMetadata: existing.customMetadata || {},
327
- });
328
- }
315
+ // Revision retention + quota charge + canonical write as a BOUNDED
316
+ // compare-and-swap loop (PHNX-3547). The old code read the current object,
317
+ // archived it, then overwrote the canonical key UNCONDITIONALLY: two
318
+ // concurrent republishers both archived the same old version and the
319
+ // loser's new body ended up neither canonical nor retained — silently
320
+ // discarded. R2 has no transactions, so each attempt re-reads the canonical
321
+ // object, archives it as a revision, and overwrites ONLY while the etag
322
+ // still matches the read (onlyIf.etagMatches — the same CAS primitive the
323
+ // PATCH path at :513 and the usage ledger already rely on; R2 returns null
324
+ // on the mismatch instead of storing). A conflicted attempt therefore loses
325
+ // nothing: it re-reads the winner's body, archives THAT as the revision on
326
+ // the next attempt, and lands its own body canonical — both writers survive.
327
+ // Phoenix bytes are buffered above, so retrying is safe; the rate counter
328
+ // and object count advance once (attempt 0) and a conflicted re-charge only
329
+ // bills the growth beyond what this request already paid. A BYO stream is
330
+ // consumed by the first attempt, so it gets one conditional try and a 409
331
+ // asking the caller to retry the whole publish.
332
+ const MAX_PUT_ATTEMPTS = 3;
333
+ let chargedAlready = 0;
334
+ let chargedNewCanonical = false;
335
+ let putResult = null;
336
+ for (let attempt = 0; attempt < MAX_PUT_ATTEMPTS; attempt++) {
337
+ // The current object is needed for BOTH the revision copy and the charge
338
+ // math, and even a first/no-revision publish must read before its
339
+ // conditional write so two concurrent creates cannot both report 200.
340
+ const existing = await env.BUCKET.get(path);
341
+ if (auth.kind === 'phoenix') {
342
+ const existingSize = existing && typeof existing.size === 'number' ? existing.size : 0;
343
+ const newCanonical = !existing;
344
+ // Keeping a revision retains the old canonical bytes AND adds the new
345
+ // ones, so storage grows by the full new size. A no-revision or first
346
+ // publish grows by new minus the bytes it replaces (may be negative on a
347
+ // shrink; the ledger clamps at >= 0).
348
+ const charge = (!noRevision && existing) ? realBytes : realBytes - existingSize;
349
+ // Conflict retry: only the growth beyond what this request already
350
+ // paid. Exact accounting under a lost race is impossible without
351
+ // reconciling the bucket; this stays the ledger's documented
352
+ // best-effort, same as its >= 0 clamp.
353
+ const bill = Math.max(0, charge - chargedAlready);
354
+ chargedAlready += bill;
355
+ const chargeNewCanonical = newCanonical && attempt === 0;
356
+ const charged = await chargeShareWrite(env, auth, {
357
+ charge: bill,
358
+ newCanonical: chargeNewCanonical,
359
+ fileBytes: realBytes,
360
+ countRate: attempt === 0,
361
+ });
362
+ if (charged.error) return charged.error; // rejected BEFORE any destructive write
363
+ if (chargeNewCanonical) chargedNewCanonical = true;
364
+ }
329
365
 
330
- await env.BUCKET.put(path, putBody, {
331
- httpMetadata: { contentType },
332
- customMetadata,
333
- });
366
+ if (!noRevision && existing) {
367
+ const existingHeaders = new Headers();
368
+ if (typeof existing.writeHttpMetadata === 'function') existing.writeHttpMetadata(existingHeaders);
369
+ const existingContentType = existingHeaders.get('content-type');
370
+ const revKey = path + '/rev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
371
+ await env.BUCKET.put(revKey, existing.body, {
372
+ httpMetadata: existingContentType ? { contentType: existingContentType } : undefined,
373
+ customMetadata: existing.customMetadata || {},
374
+ });
375
+ }
376
+
377
+ const putOpts = { httpMetadata: { contentType }, customMetadata };
378
+ // Existing objects use the bare R2Object#etag for CAS; missing objects
379
+ // use R2's create-only condition so a concurrent first writer wins loud.
380
+ if (existing && existing.etag) putOpts.onlyIf = { etagMatches: existing.etag };
381
+ else putOpts.onlyIf = { etagDoesNotMatch: '*' };
382
+ putResult = await env.BUCKET.put(path, putBody, putOpts);
383
+ if (putResult !== null) break;
384
+ // A failed create-only condition means another first publisher won.
385
+ // Do not turn that loser into a republish: the caller must see the race.
386
+ if (!existing) break;
387
+ if (auth.kind !== 'phoenix') break; // BYO stream is spent — cannot retry
388
+ }
389
+ if (putResult === null) {
390
+ if (auth.kind === 'phoenix') {
391
+ await refundShareWrite(env, auth.owner, {
392
+ refund: chargedAlready,
393
+ freeCanonical: chargedNewCanonical,
394
+ });
395
+ }
396
+ return json({ error: 'publish conflict: another write landed first, retry the publish' }, 409);
397
+ }
334
398
  // A managed republish may change the title/description. Invalidate only
335
399
  // its generated sibling so the next crawler receives a fresh card; BYO
336
400
  // publishes send no OG metadata and keep their explicitly uploaded cover.
@@ -345,15 +409,36 @@ export default {
345
409
  if (segments.length < 2) return json({ error: 'metadata edit requires /<username>/<slug>' }, 400);
346
410
  if (auth.kind === 'phoenix') {
347
411
  const expected = phoenixHandle(auth);
348
- if (!expected || segments[0] !== expected) return json({ error: 'namespace mismatch', owner: expected }, 403);
412
+ const handle = segments[0];
413
+ if (!expected || handle !== expected) {
414
+ // An alternate handle must already have a claim; unlike PUT, PATCH
415
+ // cannot create a new namespace as a side effect.
416
+ const claim = handle ? await env.BUCKET.get('__handles/' + handle) : null;
417
+ if (!claim) return json({ error: 'namespace mismatch', owner: expected }, 403);
418
+ }
419
+ // Use the same ownership path as PUT/DELETE so the same verified email
420
+ // under a new userId transfers the claim and re-stamps old pages before
421
+ // the per-object ownership check below.
422
+ const owned = await assertHandleOwner(env.BUCKET, handle, auth.owner, auth.email || '');
423
+ if (owned.error) return json({ error: 'forbidden' }, 403);
349
424
  }
350
425
  const existing = await env.BUCKET.get(path);
351
426
  if (!existing) return json({ error: 'share not found', key: path }, 404);
352
- const owner = existing.customMetadata && existing.customMetadata.owner;
353
- // WRITE_TOKEN is the endpoint owner/admin credential (the same authority
354
- // the DELETE path grants it). Phoenix must prove ownership — fail closed
355
- // when the object has no owner stamp (handles collide after sanitization).
356
- if (auth.kind === 'phoenix' && (!owner || owner !== auth.owner)) return json({ error: 'forbidden' }, 403);
427
+ // Ownership was settled above, the same way DELETE settles it: WRITE_TOKEN
428
+ // is the endpoint owner/admin credential, and a Phoenix caller has proven
429
+ // the handle claim (or, pre-claim, that no rival userId owns the prefix).
430
+ // There is deliberately NO per-object owner comparison here. Pages in a
431
+ // claimed namespace can carry a stamp that is not the claim holder's
432
+ // userId — a BYO WRITE_TOKEN publish stamps owner = the namespace, a page
433
+ // from the same human's earlier userId that transferHandle never saw, or
434
+ // a page with no stamp at all — and the claim holder could DELETE every
435
+ // one of them yet was refused a visibility change (403 'forbidden'), which
436
+ // left confidential pages public with takedown as the only remedy. The
437
+ // claim is the authority. The stamp itself is deliberately NOT rewritten:
438
+ // the anonymous lazy-expiry path refunds the STAMPED owner's usage ledger
439
+ // (see the GET expiry branch + refundShareWrite), and a fleet/BYO page was
440
+ // never charged to a Phoenix ledger — re-stamping it to the caller would
441
+ // credit her quota with bytes and a slot she never paid for on expiry.
357
442
 
358
443
  let edit;
359
444
  try { edit = await request.json(); } catch { return json({ error: 'PATCH body must be JSON' }, 400); }
@@ -486,7 +571,7 @@ export default {
486
571
  const viewer = await resolveViewer(request, env, url);
487
572
  if (viewer.redirect) return viewer.redirect;
488
573
  if (viewer.error) return viewer.error;
489
- const denied = gateVisibility(url, env, canonical, viewer.identity || null);
574
+ const denied = await gateVisibility(url, env, canonical, viewer.identity || null);
490
575
  if (denied) return denied;
491
576
  }
492
577
  // A private canonical gates its revision list on the viewer key too
@@ -524,7 +609,7 @@ export default {
524
609
  if (!page) return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
525
610
  const pageVisibility = (page.customMetadata && page.customMetadata.visibility) || 'public';
526
611
  if (viewer.error && isIdentityGated(pageVisibility)) return viewer.error;
527
- const denied = gateVisibility(url, env, page, viewer.identity || null);
612
+ const denied = await gateVisibility(url, env, page, viewer.identity || null);
528
613
  if (denied) return denied;
529
614
  // A token-gated page's cover is token-gated too (PHNX-3654): a crawler
530
615
  // fetching <slug>.png without the key gets 404, so no preview leaks.
@@ -534,7 +619,7 @@ export default {
534
619
  if (existingCover && existingCover.customMetadata['og-source-etag'] === page.etag) {
535
620
  const current = await env.BUCKET.get(pagePath);
536
621
  if (!current || current.etag !== page.etag) { existingCover = null; continue; }
537
- const currentDenied = gateVisibility(url, env, current, viewer.identity || null);
622
+ const currentDenied = await gateVisibility(url, env, current, viewer.identity || null);
538
623
  if (currentDenied) return currentDenied;
539
624
  return new Response(request.method === 'HEAD' ? null : existingCover.body, {
540
625
  status: 200,
@@ -619,7 +704,7 @@ export default {
619
704
  if (viewer.redirect) return viewer.redirect;
620
705
  if (viewer.error && isIdentityGated(visibility)) return viewer.error;
621
706
  const identity = viewer.identity || null;
622
- const denied = gateVisibility(url, env, obj, identity);
707
+ const denied = await gateVisibility(url, env, obj, identity);
623
708
  if (denied) return denied;
624
709
  // Token-gated read auth (PHNX-3654): a 'private' page is served only to a
625
710
  // request carrying the matching viewer key (?k= or Bearer), or to its owner.
@@ -718,7 +803,16 @@ export default {
718
803
  if (delSegments[0] && delSegments[0] === uid) {
719
804
  // own leftover UUID prefix
720
805
  } else if (delSegments[0] && delSegments[0] === handle) {
721
- const owned = await assertHandleOwner(env.BUCKET, handle, auth.owner);
806
+ const owned = await assertHandleOwner(env.BUCKET, handle, auth.owner, auth.email || '');
807
+ if (owned.error) return owned.error;
808
+ } else if (delSegments[0]) {
809
+ // An explicitly-chosen handle (the CLI's --handle, PHNX-3547): the
810
+ // caller's derived handle differs from the namespace, so the claim is
811
+ // the authority — assertHandleOwner refuses strangers (409) and
812
+ // recovers a same-email account move. No claim at all is a mismatch.
813
+ const claim = await env.BUCKET.get('__handles/' + delSegments[0]);
814
+ if (!claim) return json({ error: 'namespace mismatch', owner: handle || uid }, 403);
815
+ const owned = await assertHandleOwner(env.BUCKET, delSegments[0], auth.owner, auth.email || '');
722
816
  if (owned.error) return owned.error;
723
817
  } else {
724
818
  return json({ error: 'namespace mismatch', owner: handle || uid }, 403);
@@ -1335,8 +1429,13 @@ function phoenixHandle(auth) {
1335
1429
  }
1336
1430
 
1337
1431
  // First writer of a handle owns it. Later PUTs from the same userId are fine;
1338
- // a different userId whose email local-part collides gets 409, not a silent overwrite.
1339
- async function assertHandleOwner(bucket, handle, userId) {
1432
+ // a different userId whose email local-part collides gets 409, not a silent
1433
+ // overwrite — EXCEPT when the verified Phoenix email matches the claim's
1434
+ // recorded email exactly: that is the same human re-authenticated under a new
1435
+ // userId (an account move), and the claim transfers to them instead of
1436
+ // dead-ending (PHNX-3547). The transfer also re-stamps owner on the old
1437
+ // account's objects so PATCH/DELETE keep working.
1438
+ async function assertHandleOwner(bucket, handle, userId, email) {
1340
1439
  // The __handles/<handle> claim object is the authoritative first-writer record
1341
1440
  // of ownership. When it exists it decides ownership OUTRIGHT: the recorded
1342
1441
  // userId may write, anyone else is refused. Consult it FIRST — a stray page
@@ -1347,8 +1446,17 @@ async function assertHandleOwner(bucket, handle, userId) {
1347
1446
  const key = '__handles/' + handle;
1348
1447
  const existing = await bucket.get(key);
1349
1448
  if (existing) {
1350
- const claimed = existing.customMetadata && existing.customMetadata.userId;
1449
+ const meta = existing.customMetadata || {};
1450
+ const claimed = meta.userId;
1351
1451
  if (claimed && claimed !== userId) {
1452
+ // Same verified email, different userId → account move, not a rival:
1453
+ // rebind the claim and migrate the old owner's objects. Legacy claims
1454
+ // written before the claim recorded an email cannot prove this and keep
1455
+ // the permanent 409.
1456
+ if (email && meta.email && String(meta.email).toLowerCase() === String(email).toLowerCase()) {
1457
+ await transferHandle(bucket, handle, claimed, userId, email);
1458
+ return {};
1459
+ }
1352
1460
  return { error: json({ error: 'handle taken', handle: handle }, 409) };
1353
1461
  }
1354
1462
  return {};
@@ -1369,15 +1477,48 @@ async function assertHandleOwner(bucket, handle, userId) {
1369
1477
  return {};
1370
1478
  }
1371
1479
 
1372
- async function claimHandle(bucket, handle, userId) {
1373
- const owned = await assertHandleOwner(bucket, handle, userId);
1480
+ // Account-move recovery (PHNX-3547): the claim's recorded userId held the handle;
1481
+ // the caller proves the SAME verified email under a NEW userId. Rebind the claim
1482
+ // and re-stamp owner on every object the old userId owned under this prefix, so
1483
+ // the moved account keeps full control of its shares. Objects owned by anyone
1484
+ // else (BYO namespace stamps, a pre-claim stray) are left untouched.
1485
+ async function transferHandle(bucket, handle, oldUserId, newUserId, email) {
1486
+ await bucket.put('__handles/' + handle, JSON.stringify({ userId: newUserId, email: email }), {
1487
+ httpMetadata: { contentType: 'application/json' },
1488
+ customMetadata: { userId: newUserId, email: email, visibility: 'unlisted' },
1489
+ });
1490
+ let cursor;
1491
+ do {
1492
+ const list = await bucket.list({ prefix: handle + '/', cursor: cursor, include: ['customMetadata'] });
1493
+ for (const o of list.objects || []) {
1494
+ const owner = o.customMetadata && o.customMetadata.owner;
1495
+ if (!owner || owner !== oldUserId) continue;
1496
+ const obj = await bucket.get(o.key);
1497
+ if (!obj) continue;
1498
+ const headers = new Headers();
1499
+ if (typeof obj.writeHttpMetadata === 'function') obj.writeHttpMetadata(headers);
1500
+ const customMetadata = { ...(obj.customMetadata || {}), owner: newUserId };
1501
+ await bucket.put(o.key, obj.body, {
1502
+ httpMetadata: headers.get('content-type') ? { contentType: headers.get('content-type') } : undefined,
1503
+ customMetadata: customMetadata,
1504
+ });
1505
+ }
1506
+ cursor = list.truncated ? list.cursor : undefined;
1507
+ } while (cursor);
1508
+ }
1509
+
1510
+ async function claimHandle(bucket, handle, userId, email) {
1511
+ const owned = await assertHandleOwner(bucket, handle, userId, email);
1374
1512
  if (owned.error) return owned;
1375
1513
  const key = '__handles/' + handle;
1376
1514
  // Write (or rewrite) so a same-user republish resets object Age against the
1377
- // bucket's 366-day lifecycle — otherwise the claim can expire while pages stay live.
1378
- await bucket.put(key, JSON.stringify({ userId: userId }), {
1515
+ // bucket's 366-day lifecycle — otherwise the claim can expire while pages stay
1516
+ // live. The claim also records the verified email: it is what lets a future
1517
+ // same-email/different-userId request prove an account move and recover the
1518
+ // handle instead of hitting the permanent 409.
1519
+ await bucket.put(key, JSON.stringify({ userId: userId, email: email }), {
1379
1520
  httpMetadata: { contentType: 'application/json' },
1380
- customMetadata: { userId: userId, visibility: 'unlisted' },
1521
+ customMetadata: { userId: userId, email: email || '', visibility: 'unlisted' },
1381
1522
  });
1382
1523
  return {};
1383
1524
  }
@@ -1423,16 +1564,36 @@ function managedCoverHeaders(visibility) {
1423
1564
  // Gate a me/org read given the ALREADY-RESOLVED viewer identity. Pure/sync: the
1424
1565
  // caller resolves the viewer once (it also needs the identity for the ownership
1425
1566
  // check) and both the page GET and the ?revisions=json path share this gate.
1426
- function gateVisibility(url, env, obj, identity) {
1567
+ async function gateVisibility(url, env, obj, identity) {
1427
1568
  const visibility = (obj.customMetadata && obj.customMetadata.visibility) || 'public';
1428
1569
  if (!isIdentityGated(visibility)) return null;
1429
1570
  if (!identity) return bounceToLogin(url, env);
1430
1571
  if (!viewerMayRead(visibility, obj.customMetadata, identity)) {
1572
+ // A 'me' page reads for its stamped owner (the fast path above) OR for the
1573
+ // holder of the namespace's handle claim — the same authority PATCH and
1574
+ // DELETE use. Without this, the claim holder who takes a fleet/BYO-stamped
1575
+ // or pre-stamp page to 'me' (owner = namespace, or none) would be locked
1576
+ // out of her own page: PATCH says 200, GET says 404. The stamp itself stays
1577
+ // untouched (expiry refunds credit the stamped ledger), so the read gate
1578
+ // has to consult the claim rather than the stamp.
1579
+ const handle = decodeURIComponent(url.pathname).split('/').filter(Boolean)[0] || '';
1580
+ if (visibility === 'me' && handle && (await holdsHandleClaim(env.BUCKET, handle, identity.userId))) return null;
1431
1581
  return new Response('not found', { status: 404, headers: { 'content-type': 'text/plain' } });
1432
1582
  }
1433
1583
  return null;
1434
1584
  }
1435
1585
 
1586
+ // True when userId is the recorded holder of the __handles/<handle> claim.
1587
+ // Read-only: never transfers or writes a claim (that is claimHandle /
1588
+ // assertHandleOwner's job on the write paths).
1589
+ async function holdsHandleClaim(bucket, handle, userId) {
1590
+ if (!handle || !userId) return false;
1591
+ const claim = await bucket.get('__handles/' + handle);
1592
+ if (!claim) return false;
1593
+ const claimed = claim.customMetadata && claim.customMetadata.userId;
1594
+ return !!claimed && claimed === userId;
1595
+ }
1596
+
1436
1597
  // SHA-256 hex of a string — the form a 'private' object's stored
1437
1598
  // 'viewer-token-hash' takes. Both the PUT (hash-on-store) and the read gate use
1438
1599
  // this, so a token minted by the CLI matches byte-for-byte (PHNX-3654).
@@ -1909,7 +2070,7 @@ async function renderOgCard(input) {
1909
2070
  props: {
1910
2071
  style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#0a0a0a', color: '#f5f5f5', padding: '68px 76px 58px', fontFamily: 'Inter' },
1911
2072
  children: [
1912
- { type: 'div', props: { style: { display: 'flex', color: '#a3e635', fontFamily: 'JetBrains Mono', fontSize: 25, fontWeight: 600, letterSpacing: '-0.5px' }, children: 'AGI · agents-cli.sh' } },
2073
+ { type: 'div', props: { style: { display: 'flex', color: '#a3e635', fontFamily: 'JetBrains Mono', fontSize: 25, fontWeight: 600, letterSpacing: '-0.5px' }, children: 'share.getrush.ai' } },
1913
2074
  { type: 'div', props: { style: { display: 'flex', flexDirection: 'column', flexGrow: 1, justifyContent: 'center', maxWidth: 1050 }, children: [
1914
2075
  { 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' } },
1915
2076
  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,
@@ -2,9 +2,9 @@ const LOADED_COMMAND_NAMES = [
2
2
  'accounts', 'auth', 'view', 'inspect', 'feedback', 'commands', 'hooks', 'skills', 'rules', 'memory',
3
3
  'permissions', 'mcp', 'clis', 'subagents', 'plugins', 'workflows', 'add', 'use',
4
4
  'remove', 'rm', 'purge', 'update', 'prune', 'import', 'registry', 'search', 'install', 'packages',
5
- 'routines', 'monitors', 'projects', 'run', 'open', 'reconnect', 'fork', 'config',
5
+ 'routines', 'monitors', 'projects', 'run', '_callback', 'open', 'reconnect', 'fork', 'config',
6
6
  'models', 'modes', 'trash', 'restore', 'doctor',
7
- 'route', 'harness', 'harnesses', 'secrets', 'menubar', 'sync',
7
+ 'route', 'routes', 'harness', 'harnesses', 'secrets', 'menubar', 'sync',
8
8
  'refresh-rules', 'factory', 'insights', 'trace', 'reminders',
9
9
  'pty', 'tmux', 'watchdog', 'browser', 'computer', 'logs', 'events',
10
10
  'ssh', 'devices', 'fleet', 'repos', 'repo', 'setup', 'uninstall', 'upgrade', 'sessions',
@@ -319,6 +319,21 @@ export declare function getRuntimeStateDir(): string;
319
319
  export declare function getCompanionDir(): string;
320
320
  /** Path to browser runtime data — chrome-data, pids (~/.agents/.cache/browser/). */
321
321
  export declare function getBrowserRuntimeDir(): string;
322
+ /**
323
+ * Path to DURABLE browser-profile data (~/.agents/.history/browser-profiles/).
324
+ *
325
+ * This is the persistent home for an attach-only profile's `--user-data-dir` —
326
+ * where a one-time browser sign-in lives. It sits under `.history` (durable),
327
+ * NOT `.cache` (regenerable), for two reasons the ticket (PHNX-3967) named:
328
+ * - `agents browser profiles remove` sweeps `~/.agents/.cache/browser/<name>*`;
329
+ * a durable dir here survives that so logins are not wiped by a routine cleanup.
330
+ * - A cache wipe or the daemon reaper never touches it, so a signed-in Comet
331
+ * survives quit+relaunch.
332
+ * The user's canonical Comet is launched with this as `--user-data-dir`, and the
333
+ * ownership guard in the local driver compares the running instance's
334
+ * `--user-data-dir` against it to reject a foreign port-squatter.
335
+ */
336
+ export declare function getBrowserDurableDir(): string;
322
337
  /** Path to helper subprocess scratch (~/.agents/.cache/helpers/). */
323
338
  export declare function getHelpersDir(): string;
324
339
  /**
package/dist/lib/state.js CHANGED
@@ -642,6 +642,21 @@ export function getRuntimeStateDir() { return process.env.AGENTS_STATE_DIR ?? RU
642
642
  export function getCompanionDir() { return COMPANION_CACHE_DIR; }
643
643
  /** Path to browser runtime data — chrome-data, pids (~/.agents/.cache/browser/). */
644
644
  export function getBrowserRuntimeDir() { return BROWSER_RUNTIME_DIR; }
645
+ /**
646
+ * Path to DURABLE browser-profile data (~/.agents/.history/browser-profiles/).
647
+ *
648
+ * This is the persistent home for an attach-only profile's `--user-data-dir` —
649
+ * where a one-time browser sign-in lives. It sits under `.history` (durable),
650
+ * NOT `.cache` (regenerable), for two reasons the ticket (PHNX-3967) named:
651
+ * - `agents browser profiles remove` sweeps `~/.agents/.cache/browser/<name>*`;
652
+ * a durable dir here survives that so logins are not wiped by a routine cleanup.
653
+ * - A cache wipe or the daemon reaper never touches it, so a signed-in Comet
654
+ * survives quit+relaunch.
655
+ * The user's canonical Comet is launched with this as `--user-data-dir`, and the
656
+ * ownership guard in the local driver compares the running instance's
657
+ * `--user-data-dir` against it to reject a foreign port-squatter.
658
+ */
659
+ export function getBrowserDurableDir() { return path.join(HISTORY_DIR, 'browser-profiles'); }
645
660
  /** Path to helper subprocess scratch (~/.agents/.cache/helpers/). */
646
661
  export function getHelpersDir() { return HELPERS_DIR; }
647
662
  /**
@@ -1136,6 +1151,13 @@ function serializeCentral(central) {
1136
1151
  * read-snapshot-then-separately-lock race that {@link updateMeta} would impose.
1137
1152
  */
1138
1153
  export function writeMetaUnlocked(meta) {
1154
+ const writesDeviceRoutines = Object.prototype.hasOwnProperty.call(meta, 'deviceRoutines');
1155
+ const writesDeviceConfig = Object.prototype.hasOwnProperty.call(meta, 'deviceConfig');
1156
+ const writesDeviceBrowser = Object.prototype.hasOwnProperty.call(meta, 'deviceBrowser');
1157
+ const writesDeviceFleet = Object.prototype.hasOwnProperty.call(meta, 'deviceFleet');
1158
+ const writesDeviceHosts = Object.prototype.hasOwnProperty.call(meta, 'deviceHosts');
1159
+ const writesDeviceAccounts = Object.prototype.hasOwnProperty.call(meta, 'deviceAccounts');
1160
+ const writesProjectRoot = Object.prototype.hasOwnProperty.call(meta, 'projectRoot');
1139
1161
  // INVARIANT: every key destructured here must also be in BESPOKE_DEVICE_KEYS (and
1140
1162
  // vice versa) — a bespoke device key that is classified but NOT pulled out here
1141
1163
  // would fall into `central`, and the generic router skips it (BESPOKE_DEVICE_KEY_SET),
@@ -1191,17 +1213,17 @@ export function writeMetaUnlocked(meta) {
1191
1213
  delete doc.isolatedAgents;
1192
1214
  if (Array.isArray(deviceRoutines))
1193
1215
  doc.routines = deviceRoutines;
1194
- else
1216
+ else if (writesDeviceRoutines)
1195
1217
  delete doc.routines;
1196
1218
  const hasDeviceConfig = !!deviceConfig && Object.keys(deviceConfig).length > 0;
1197
1219
  if (hasDeviceConfig)
1198
1220
  doc.config = deviceConfig;
1199
- else
1221
+ else if (writesDeviceConfig)
1200
1222
  delete doc.config;
1201
1223
  const hasDeviceBrowser = !!deviceBrowser && Object.keys(deviceBrowser).length > 0;
1202
1224
  if (hasDeviceBrowser)
1203
1225
  doc.browser = deviceBrowser;
1204
- else
1226
+ else if (writesDeviceBrowser)
1205
1227
  delete doc.browser;
1206
1228
  // PHNX-3315 device-scoped fleet/hosts/accounts blocks. Each is this box's OWN
1207
1229
  // slice; the effective fleet view is unioned across every device doc at read
@@ -1219,12 +1241,12 @@ export function writeMetaUnlocked(meta) {
1219
1241
  df.ignored = fleetIgnored;
1220
1242
  doc.fleet = df;
1221
1243
  }
1222
- else
1244
+ else if (writesDeviceFleet)
1223
1245
  delete doc.fleet;
1224
1246
  const hasDeviceHosts = !!deviceHosts && Object.keys(deviceHosts).length > 0;
1225
1247
  if (hasDeviceHosts)
1226
1248
  doc.hosts = deviceHosts;
1227
- else
1249
+ else if (writesDeviceHosts)
1228
1250
  delete doc.hosts;
1229
1251
  const accountsNative = deviceAccounts?.native && Object.keys(deviceAccounts.native).length > 0
1230
1252
  ? deviceAccounts.native : undefined;
@@ -1238,12 +1260,12 @@ export function writeMetaUnlocked(meta) {
1238
1260
  da.bindings = accountsBindings;
1239
1261
  doc.accounts = da;
1240
1262
  }
1241
- else
1263
+ else if (writesDeviceAccounts)
1242
1264
  delete doc.accounts;
1243
1265
  const hasProjectRoot = typeof projectRoot === 'string' && projectRoot.length > 0;
1244
1266
  if (hasProjectRoot)
1245
1267
  doc.projectRoot = projectRoot;
1246
- else
1268
+ else if (writesProjectRoot)
1247
1269
  delete doc.projectRoot;
1248
1270
  // Generic device-scoped keys (PHNX-3315): any key left in `central` that this
1249
1271
  // version classifies as device but does NOT bespoke-route round-trips through
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Session-summarizer configuration (PHNX-3939).
3
+ *
4
+ * Resolves the three knobs behind the daemon summarizer — enabled / base URL /
5
+ * model — from `agents config` (`summarizer.*`, user-scope in the central
6
+ * agents.yaml) with a per-process env override
7
+ * (`AGENTS_SUMMARIZER_ENABLED` / `AGENTS_SUMMARIZER_BASEURL` /
8
+ * `AGENTS_SUMMARIZER_MODEL`). Off by default: with no config and no env, the
9
+ * summarizer is disabled and makes zero model calls.
10
+ *
11
+ * `isSummarizerReady()` is memoized on a short TTL because the display merge
12
+ * (the watch-stream projections) calls it once per session row — a fresh
13
+ * agents.yaml read per row would defeat the "blazing fast" requirement.
14
+ */
15
+ export interface SummarizerConfig {
16
+ enabled: boolean;
17
+ /** Anthropic-wire base URL (Ollama/vLLM/LiteLLM), or undefined when unconfigured. */
18
+ baseUrl?: string;
19
+ /** Model id to request, or undefined when unconfigured. */
20
+ model?: string;
21
+ }
22
+ /**
23
+ * Resolve the full summarizer config. Env overrides the stored config key by key;
24
+ * an unset env var falls through to `agents config`, then to the built-in
25
+ * default (disabled). Never throws — a missing/corrupt config reads as unset.
26
+ */
27
+ export declare function resolveSummarizerConfig(env?: NodeJS.ProcessEnv): SummarizerConfig;
28
+ /**
29
+ * True only when the summarizer is enabled AND has a base URL + model to call.
30
+ * A configuration that is `enabled` but missing an endpoint cannot produce a
31
+ * summary, so it is treated as unconfigured (the service no-ops, the merge marks
32
+ * `skipped`) rather than erroring on every tick.
33
+ */
34
+ export declare function isSummarizerRunnable(config: SummarizerConfig): boolean;
35
+ /**
36
+ * Memoized "will a summary actually be produced?" check for the hot merge path.
37
+ * Reflects {@link isSummarizerRunnable} — enabled AND a base URL AND a model —
38
+ * NOT just `enabled`, because an enabled-but-unconfigured summarizer computes
39
+ * nothing, so a row with no cached summary must read `skipped`, not a `pending`
40
+ * that never resolves (the exact case: `summarizer.enabled on` set before the
41
+ * endpoint). TTL keeps a config change visible within a few seconds without a
42
+ * per-row agents.yaml read.
43
+ */
44
+ export declare function isSummarizerReady(nowMs?: number): boolean;
45
+ /** Test seam: drop the memoized ready flag so the next read re-resolves. */
46
+ export declare function resetSummarizerReadyCacheForTest(): void;