@phnx-labs/agents-cli 1.22.74 → 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 (148) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +21 -9
  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/computer.d.ts +1 -0
  11. package/dist/commands/computer.js +26 -7
  12. package/dist/commands/config.js +27 -4
  13. package/dist/commands/cost.js +6 -4
  14. package/dist/commands/doctor.d.ts +6 -5
  15. package/dist/commands/doctor.js +32 -274
  16. package/dist/commands/exec.d.ts +2 -0
  17. package/dist/commands/exec.js +9 -2
  18. package/dist/commands/harness.d.ts +1 -0
  19. package/dist/commands/harness.js +11 -3
  20. package/dist/commands/hooks.js +7 -6
  21. package/dist/commands/mcp.js +7 -6
  22. package/dist/commands/memory.js +7 -7
  23. package/dist/commands/monitors.js +3 -2
  24. package/dist/commands/open.d.ts +25 -12
  25. package/dist/commands/open.js +24 -10
  26. package/dist/commands/permissions.js +7 -6
  27. package/dist/commands/plugins.js +21 -17
  28. package/dist/commands/route.js +33 -16
  29. package/dist/commands/rules.js +7 -12
  30. package/dist/commands/sessions-share.js +1 -1
  31. package/dist/commands/setup-watchdog.js +2 -2
  32. package/dist/commands/setup.js +22 -1
  33. package/dist/commands/share.js +26 -10
  34. package/dist/commands/skills.js +7 -6
  35. package/dist/commands/subagents.js +7 -6
  36. package/dist/commands/sync.js +81 -10
  37. package/dist/commands/view.js +4 -1
  38. package/dist/commands/watchdog.d.ts +1 -1
  39. package/dist/commands/watchdog.js +10 -10
  40. package/dist/commands/webhook.d.ts +4 -0
  41. package/dist/commands/webhook.js +22 -4
  42. package/dist/commands/workflows.js +7 -6
  43. package/dist/lib/account-registry.js +27 -6
  44. package/dist/lib/accounting/rotate.d.ts +3 -1
  45. package/dist/lib/accounting/rotate.js +8 -4
  46. package/dist/lib/auth-health.d.ts +2 -0
  47. package/dist/lib/auth-health.js +2 -0
  48. package/dist/lib/browser/chrome.d.ts +21 -0
  49. package/dist/lib/browser/chrome.js +60 -3
  50. package/dist/lib/browser/drivers/local.d.ts +21 -0
  51. package/dist/lib/browser/drivers/local.js +102 -9
  52. package/dist/lib/browser/profiles.d.ts +29 -1
  53. package/dist/lib/browser/profiles.js +50 -1
  54. package/dist/lib/browser/types.d.ts +18 -0
  55. package/dist/lib/computer/computer-rpc.d.ts +6 -1
  56. package/dist/lib/computer/computer-rpc.js +23 -3
  57. package/dist/lib/computer/des.d.ts +1 -0
  58. package/dist/lib/computer/des.js +114 -0
  59. package/dist/lib/computer/rfb-client.d.ts +53 -0
  60. package/dist/lib/computer/rfb-client.js +562 -0
  61. package/dist/lib/config-keys.d.ts +7 -2
  62. package/dist/lib/config-keys.js +17 -2
  63. package/dist/lib/daemon/auth-sync-service.js +3 -0
  64. package/dist/lib/daemon/daemon.js +17 -10
  65. package/dist/lib/daemon/session-summarizer-service.d.ts +24 -0
  66. package/dist/lib/daemon/session-summarizer-service.js +39 -0
  67. package/dist/lib/daemon/usage-sync-service.js +3 -0
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/daemon-ticks.d.ts +2 -2
  71. package/dist/lib/daemon-ticks.js +2 -1
  72. package/dist/lib/daemon-webhooks.js +15 -2
  73. package/dist/lib/deeplink/register.js +10 -9
  74. package/dist/lib/deeplink/url.d.ts +4 -4
  75. package/dist/lib/deeplink/url.js +4 -4
  76. package/dist/lib/device-config.js +25 -0
  77. package/dist/lib/devices/doctor-findings.d.ts +4 -4
  78. package/dist/lib/devices/doctor-findings.js +14 -8
  79. package/dist/lib/devices/registry.js +2 -0
  80. package/dist/lib/devices/stats-cache.d.ts +4 -0
  81. package/dist/lib/devices/stats-cache.js +19 -0
  82. package/dist/lib/drift-sync.d.ts +3 -1
  83. package/dist/lib/drift-sync.js +16 -5
  84. package/dist/lib/exec.d.ts +2 -0
  85. package/dist/lib/exec.js +16 -1
  86. package/dist/lib/fleet-shared-repo-sync.d.ts +12 -0
  87. package/dist/lib/fleet-shared-repo-sync.js +101 -4
  88. package/dist/lib/fleet-shared-state.d.ts +8 -0
  89. package/dist/lib/heal.d.ts +4 -3
  90. package/dist/lib/heal.js +5 -4
  91. package/dist/lib/hosts/ready.d.ts +1 -1
  92. package/dist/lib/hosts/ready.js +16 -4
  93. package/dist/lib/hosts/reconnect.js +4 -2
  94. package/dist/lib/identity/client.d.ts +6 -0
  95. package/dist/lib/identity/index.d.ts +16 -0
  96. package/dist/lib/identity/index.js +25 -1
  97. package/dist/lib/profiles.d.ts +2 -0
  98. package/dist/lib/profiles.js +28 -9
  99. package/dist/lib/reconcile-and-repair.d.ts +109 -0
  100. package/dist/lib/reconcile-and-repair.js +267 -0
  101. package/dist/lib/routers.d.ts +12 -1
  102. package/dist/lib/routers.js +30 -1
  103. package/dist/lib/scheduling/routines.js +8 -2
  104. package/dist/lib/session/active.d.ts +13 -0
  105. package/dist/lib/session/db.d.ts +47 -7
  106. package/dist/lib/session/db.js +114 -12
  107. package/dist/lib/session/mirror.js +58 -0
  108. package/dist/lib/session/remote/remote-list.d.ts +2 -0
  109. package/dist/lib/session/remote/remote-list.js +4 -0
  110. package/dist/lib/session/remote/watch.js +22 -2
  111. package/dist/lib/session/session-cache.d.ts +19 -0
  112. package/dist/lib/session/session-cache.js +46 -0
  113. package/dist/lib/session/types.d.ts +34 -0
  114. package/dist/lib/share/backend.d.ts +6 -4
  115. package/dist/lib/share/backend.js +10 -8
  116. package/dist/lib/share/config.d.ts +4 -3
  117. package/dist/lib/share/config.js +10 -1
  118. package/dist/lib/share/delete.d.ts +1 -1
  119. package/dist/lib/share/delete.js +1 -1
  120. package/dist/lib/share/html.d.ts +1 -1
  121. package/dist/lib/share/html.js +1 -1
  122. package/dist/lib/share/provision.d.ts +1 -1
  123. package/dist/lib/share/provision.js +2 -2
  124. package/dist/lib/share/publish.d.ts +23 -7
  125. package/dist/lib/share/publish.js +58 -12
  126. package/dist/lib/share/worker-template.js +221 -60
  127. package/dist/lib/startup/command-registry.js +2 -2
  128. package/dist/lib/state.d.ts +15 -0
  129. package/dist/lib/state.js +29 -7
  130. package/dist/lib/summarizer/config.d.ts +46 -0
  131. package/dist/lib/summarizer/config.js +83 -0
  132. package/dist/lib/summarizer/pass.d.ts +45 -0
  133. package/dist/lib/summarizer/pass.js +112 -0
  134. package/dist/lib/summarizer/summarize.d.ts +68 -0
  135. package/dist/lib/summarizer/summarize.js +120 -0
  136. package/dist/lib/teams/agents.d.ts +4 -3
  137. package/dist/lib/teams/agents.js +12 -4
  138. package/dist/lib/teams/scheduler.d.ts +4 -2
  139. package/dist/lib/teams/scheduler.js +6 -6
  140. package/dist/lib/tmux/session.d.ts +2 -0
  141. package/dist/lib/tmux/session.js +7 -1
  142. package/dist/lib/types.d.ts +20 -0
  143. package/dist/lib/verbs.d.ts +23 -0
  144. package/dist/lib/verbs.js +24 -0
  145. package/dist/lib/view-types.d.ts +4 -0
  146. package/dist/lib/watchdog/rotate.d.ts +1 -1
  147. package/dist/lib/watchdog/rotate.js +1 -1
  148. package/package.json +1 -1
@@ -219,7 +219,7 @@ export async function enableWorkersDev(apiToken, accountId, workerName, opts = {
219
219
  /** Resolve a zone id for a domain the token can see, or null if not owned/visible. */
220
220
  export async function findZoneId(apiToken, domain, opts = {}) {
221
221
  const request = opts.request ?? defaultCloudflareRequester;
222
- // Try the exact name, then the registrable parent (share.agents-cli.sh -> agents-cli.sh).
222
+ // Try the exact name, then the registrable parent (share.getrush.ai -> getrush.ai).
223
223
  const candidates = [domain, domain.split('.').slice(-2).join('.')];
224
224
  for (const name of candidates) {
225
225
  const zones = await request({
@@ -232,7 +232,7 @@ export async function findZoneId(apiToken, domain, opts = {}) {
232
232
  }
233
233
  return null;
234
234
  }
235
- /** Map a custom hostname (e.g. `share.agents-cli.sh`) to the Worker via Workers Custom Domains. */
235
+ /** Map a custom hostname (e.g. `share.getrush.ai`) to the Worker via Workers Custom Domains. */
236
236
  export async function addCustomDomain(apiToken, accountId, workerName, zoneId, hostname, opts = {}) {
237
237
  const request = opts.request ?? defaultCloudflareRequester;
238
238
  try {
@@ -69,8 +69,17 @@ export interface PublishOptions {
69
69
  analytics?: boolean;
70
70
  /** Override the analytics token from share config. */
71
71
  analyticsToken?: string;
72
- /** Override the GitHub username used for the URL namespace. */
72
+ /** Override the GitHub username used for the URL namespace (BYO path). */
73
73
  githubUser?: string;
74
+ /**
75
+ * Override the managed public handle (PHNX-3547). Managed publishes normally
76
+ * namespace under the email local-part; when that handle is taken — or a
77
+ * vanity namespace is wanted — pass an alternate. Sanitized to
78
+ * `[a-z0-9-]` (max 63 chars, matching the Worker); the Worker binds it with
79
+ * the same first-writer claim as a derived handle. Ignored on BYO (use
80
+ * `githubUser` there).
81
+ */
82
+ handle?: string;
74
83
  /** DI seam for tests — override the persisted share endpoint config. */
75
84
  config?: ShareConfig;
76
85
  /** DI seam for tests — override the keychain-backed write token. Selects BYO. */
@@ -209,12 +218,15 @@ export declare function resolveShareProvenance(opts?: {
209
218
  }): ShareProvenance;
210
219
  /**
211
220
  * The sharer's avatar URL, stamped so the share bar can show a real profile
212
- * picture instead of only the initials circle. We key a Gravatar on the SHA-256
213
- * of the signed-in user's lowercased email (Gravatar resolves either MD5 or
214
- * SHA-256), with `d=404` so Gravatar returns 404 for a user who has none — the
215
- * bar's `<img>` onerror then falls back to the initials circle. Only the hash
216
- * lands in public metadata, never the raw email. Returns '' when signed out
217
- * (BYO without a Phoenix session), leaving the bar on the initials circle.
221
+ * picture instead of only the initials circle. A hosted OAuth profile image
222
+ * already known to identity (PhoenixSession.avatarUrl — captured at login and
223
+ * refreshed from `/api/v1/auth/me`) wins outright; only when none exists do we
224
+ * fall back to a Gravatar keyed on the SHA-256 of the signed-in user's
225
+ * lowercased email (Gravatar resolves either MD5 or SHA-256), with `d=404` so
226
+ * Gravatar returns 404 for a user who has none — the bar's `<img>` onerror
227
+ * then falls back to the initials circle. Only the hash lands in public
228
+ * metadata, never the raw email. Returns '' when signed out (BYO without a
229
+ * Phoenix session), leaving the bar on the initials circle.
218
230
  *
219
231
  * `opts.session === null` means "explicitly signed out" (a test seam / BYO) and
220
232
  * yields ''; `undefined` reads the real persisted session.
@@ -369,4 +381,8 @@ export declare function resolveShareUsername(opts?: {
369
381
  /** Build the R2 object key from a namespace username and a slug part. */
370
382
  export declare function buildShareKey(username: string, slugPart: string): string;
371
383
  export declare function publishFile(filePath: string, opts?: PublishOptions): Promise<PublishResult>;
384
+ /** Sanitize a caller-chosen managed handle to the Worker's namespace shape.
385
+ * Returns '' when the result is empty or over the Worker's 63-char cap (the
386
+ * caller then falls back to the derived handle / errors). */
387
+ export declare function resolveManagedHandle(handle: string | undefined): string;
372
388
  export declare function publishToEndpoint(filePath: string, endpoint: PublishEndpoint, opts?: PublishOptions): Promise<PublishResult>;
@@ -129,18 +129,24 @@ export function resolveShareProvenance(opts = {}) {
129
129
  }
130
130
  /**
131
131
  * The sharer's avatar URL, stamped so the share bar can show a real profile
132
- * picture instead of only the initials circle. We key a Gravatar on the SHA-256
133
- * of the signed-in user's lowercased email (Gravatar resolves either MD5 or
134
- * SHA-256), with `d=404` so Gravatar returns 404 for a user who has none — the
135
- * bar's `<img>` onerror then falls back to the initials circle. Only the hash
136
- * lands in public metadata, never the raw email. Returns '' when signed out
137
- * (BYO without a Phoenix session), leaving the bar on the initials circle.
132
+ * picture instead of only the initials circle. A hosted OAuth profile image
133
+ * already known to identity (PhoenixSession.avatarUrl — captured at login and
134
+ * refreshed from `/api/v1/auth/me`) wins outright; only when none exists do we
135
+ * fall back to a Gravatar keyed on the SHA-256 of the signed-in user's
136
+ * lowercased email (Gravatar resolves either MD5 or SHA-256), with `d=404` so
137
+ * Gravatar returns 404 for a user who has none — the bar's `<img>` onerror
138
+ * then falls back to the initials circle. Only the hash lands in public
139
+ * metadata, never the raw email. Returns '' when signed out (BYO without a
140
+ * Phoenix session), leaving the bar on the initials circle.
138
141
  *
139
142
  * `opts.session === null` means "explicitly signed out" (a test seam / BYO) and
140
143
  * yields ''; `undefined` reads the real persisted session.
141
144
  */
142
145
  export function resolveShareAvatar(opts = {}) {
143
146
  const session = opts.session !== undefined ? opts.session : readSession();
147
+ const hosted = session?.avatarUrl?.trim();
148
+ if (hosted && /^https:\/\//i.test(hosted))
149
+ return hosted;
144
150
  const email = session?.email?.trim().toLowerCase();
145
151
  if (!email)
146
152
  return '';
@@ -553,8 +559,9 @@ export function buildShareKey(username, slugPart) {
553
559
  }
554
560
  export async function publishFile(filePath, opts = {}) {
555
561
  const backend = resolveShareBackend(opts);
562
+ const managedHandle = backend.kind === 'managed' ? requireManagedHandle(opts.handle) : '';
556
563
  const username = backend.kind === 'managed'
557
- ? backend.namespace
564
+ ? managedHandle || backend.namespace
558
565
  : await resolveShareUsername({ githubUser: opts.githubUser || backend.namespace || undefined });
559
566
  const analyticsToken = opts.analyticsToken ?? (backend.kind === 'byo' ? (opts.config ?? readShareConfig())?.analyticsToken : undefined);
560
567
  return publishToEndpoint(filePath, { baseUrl: backend.baseUrl, token: backend.token }, {
@@ -564,8 +571,28 @@ export async function publishFile(filePath, opts = {}) {
564
571
  backendKind: backend.kind,
565
572
  });
566
573
  }
574
+ /** Sanitize a caller-chosen managed handle to the Worker's namespace shape.
575
+ * Returns '' when the result is empty or over the Worker's 63-char cap (the
576
+ * caller then falls back to the derived handle / errors). */
577
+ export function resolveManagedHandle(handle) {
578
+ if (!handle)
579
+ return '';
580
+ const sanitized = sanitizeShareNamespace(handle);
581
+ return sanitized && sanitized.length <= 63 ? sanitized : '';
582
+ }
583
+ function requireManagedHandle(handle) {
584
+ const resolved = resolveManagedHandle(handle);
585
+ if (handle && !resolved) {
586
+ throw new Error(`Invalid --handle '${handle}': must sanitize to 1-63 [a-z0-9-] characters`);
587
+ }
588
+ return resolved;
589
+ }
567
590
  export async function publishToEndpoint(filePath, endpoint, opts = {}) {
568
- const username = await resolveShareUsername(opts);
591
+ // A managed publish may carry an explicit --handle: it namespaces the URL and
592
+ // rides the x-share-handle header so the Worker binds the claim to it (a
593
+ // derived handle is proven by the email; an alternate one must be declared).
594
+ const managedHandle = opts.backendKind === 'managed' ? requireManagedHandle(opts.handle) : '';
595
+ const username = managedHandle || (await resolveShareUsername(opts));
569
596
  let body = readFileSync(filePath);
570
597
  const expiresAt = resolveExpire(opts.expire);
571
598
  const visibility = resolveShareVisibility(opts);
@@ -666,6 +693,8 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
666
693
  assertMetadataSize(metadataPreview);
667
694
  const authHeaders = (contentType) => {
668
695
  const h = { authorization: `Bearer ${endpoint.token}`, 'content-type': contentType };
696
+ if (managedHandle)
697
+ h['x-share-handle'] = managedHandle;
669
698
  if (expiresAt)
670
699
  h['x-share-expires-at'] = expiresAt;
671
700
  h['x-share-visibility'] = visibility;
@@ -748,11 +777,28 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
748
777
  }
749
778
  const r = await put(pageUrl, body, authHeaders(opts.contentType ?? guessContentType(filePath)));
750
779
  if (!r.ok) {
751
- if (r.status === 409) {
752
- throw new Error(`Handle '${username}' is already claimed by another account. The public URL namespace is the email local-part; two Phoenix users cannot share it.`);
780
+ // 409 has two distinct shapes on the managed Worker: 'handle taken' (the
781
+ // caller's namespace belongs to another account) and 'publish conflict'
782
+ // (a concurrent write won the republish race — retry, per PHNX-3547).
783
+ const httpError = extractShareHttpError({ status: r.status, body: r.body, retryAfter: r.retryAfter });
784
+ if (r.status === 409 && httpError.serverMessage === 'handle taken' && opts.backendKind === 'managed') {
785
+ throw new Error(`Handle '${username}' is already claimed by another account. ` +
786
+ 'If you signed in again and got a new account id, republishing with the same email re-binds your handle automatically; ' +
787
+ 'otherwise pick a different public namespace with --handle <name>.');
788
+ }
789
+ const detail = formatShareHttpErrorDetail(httpError);
790
+ // The write-token/setup advice is only meaningful for an auth failure — a
791
+ // 413 (quota/size) or 429 (rate) rejection has nothing to do with the
792
+ // token, and 'Check the write token' there is plain wrong (PHNX-3579).
793
+ // Managed endpoints carry a Phoenix bearer, so the recovery is re-login.
794
+ let advice = '';
795
+ if (r.status === 401 || r.status === 403) {
796
+ advice =
797
+ opts.backendKind === 'managed'
798
+ ? ". Check that you're signed in — run 'agents auth login'."
799
+ : ". Check the write token, or that 'agents artifacts setup' completed.";
753
800
  }
754
- const detail = formatShareHttpErrorDetail(extractShareHttpError({ status: r.status, body: r.body, retryAfter: r.retryAfter }));
755
- throw new Error(`Publish failed (${r.status}) for ${pageUrl}${detail}. Check the write token, or that 'agents artifacts setup' completed.`);
801
+ throw new Error(`Publish failed (${r.status}) for ${pageUrl}${detail}${advice}`);
756
802
  }
757
803
  // A token-gated page is only reachable WITH its key, so the URL we hand back
758
804
  // (and store nowhere) carries it — https://<host>/<user>/<slug>?k=<token>.
@@ -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',