@phnx-labs/agents-cli 1.22.68 → 1.22.69

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 (78) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +14 -6
  3. package/dist/bootstrap.js +3 -0
  4. package/dist/commands/exec.js +31 -21
  5. package/dist/commands/feed.js +20 -7
  6. package/dist/commands/monitors.js +3 -0
  7. package/dist/commands/projects.d.ts +26 -6
  8. package/dist/commands/projects.js +55 -22
  9. package/dist/commands/send.js +29 -2
  10. package/dist/commands/sessions-inject.d.ts +58 -0
  11. package/dist/commands/sessions-inject.js +143 -7
  12. package/dist/commands/sessions-picker.js +1 -0
  13. package/dist/commands/share.js +43 -14
  14. package/dist/commands/ssh.js +205 -2
  15. package/dist/lib/accounting/usage.d.ts +7 -2
  16. package/dist/lib/accounting/usage.js +142 -10
  17. package/dist/lib/boot-profile.d.ts +14 -0
  18. package/dist/lib/boot-profile.js +66 -0
  19. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  20. package/dist/lib/channels/providers/desktop.js +5 -4
  21. package/dist/lib/claude-account-token.js +108 -4
  22. package/dist/lib/devices/health.d.ts +38 -2
  23. package/dist/lib/devices/health.js +43 -5
  24. package/dist/lib/devices/worker-pick.d.ts +1 -1
  25. package/dist/lib/devices/worker-pick.js +4 -1
  26. package/dist/lib/exec.js +4 -0
  27. package/dist/lib/feed-broadcast.d.ts +64 -5
  28. package/dist/lib/feed-broadcast.js +124 -22
  29. package/dist/lib/monitors/engine.js +18 -0
  30. package/dist/lib/monitors/sources/command.js +13 -3
  31. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  32. package/dist/lib/monitors/sources/failure.js +52 -0
  33. package/dist/lib/monitors/sources/types.d.ts +9 -0
  34. package/dist/lib/owner-message.d.ts +12 -0
  35. package/dist/lib/owner-message.js +44 -0
  36. package/dist/lib/run-trace-sync.d.ts +15 -0
  37. package/dist/lib/run-trace-sync.js +43 -21
  38. package/dist/lib/secrets/filestore.d.ts +4 -0
  39. package/dist/lib/secrets/filestore.js +164 -3
  40. package/dist/lib/session/active.d.ts +10 -0
  41. package/dist/lib/session/active.js +3 -0
  42. package/dist/lib/session/db.d.ts +12 -1
  43. package/dist/lib/session/db.js +20 -1
  44. package/dist/lib/session/discover.js +81 -1
  45. package/dist/lib/session/linear.d.ts +13 -0
  46. package/dist/lib/session/linear.js +44 -0
  47. package/dist/lib/session/live-metadata.js +1 -0
  48. package/dist/lib/session/parse.js +2 -3
  49. package/dist/lib/session/prompt.d.ts +7 -1
  50. package/dist/lib/session/prompt.js +12 -2
  51. package/dist/lib/session/recovery.d.ts +21 -12
  52. package/dist/lib/session/recovery.js +29 -11
  53. package/dist/lib/session/remote/watch.js +5 -2
  54. package/dist/lib/session/state.js +11 -13
  55. package/dist/lib/share/backend.d.ts +2 -2
  56. package/dist/lib/share/backend.js +20 -9
  57. package/dist/lib/share/delete.d.ts +5 -1
  58. package/dist/lib/share/delete.js +7 -2
  59. package/dist/lib/share/http-error.d.ts +52 -0
  60. package/dist/lib/share/http-error.js +65 -0
  61. package/dist/lib/share/publish.d.ts +13 -3
  62. package/dist/lib/share/publish.js +19 -15
  63. package/dist/lib/share/worker-template.js +5 -1
  64. package/dist/lib/smart-launch.js +27 -4
  65. package/dist/lib/storage/index.d.ts +14 -0
  66. package/dist/lib/storage/index.js +14 -0
  67. package/dist/lib/storage/selection.d.ts +48 -0
  68. package/dist/lib/storage/selection.js +39 -0
  69. package/dist/lib/storage/visibility.d.ts +82 -0
  70. package/dist/lib/storage/visibility.js +99 -0
  71. package/dist/lib/teams/agents.js +3 -1
  72. package/dist/lib/teams/placement-probe.js +1 -0
  73. package/dist/lib/teams/scheduler.d.ts +8 -1
  74. package/dist/lib/teams/scheduler.js +4 -1
  75. package/dist/lib/traces/backend.js +13 -2
  76. package/dist/lib/worktree/held.d.ts +166 -0
  77. package/dist/lib/worktree/held.js +368 -0
  78. package/package.json +2 -2
@@ -49,6 +49,7 @@ const SYNTHETIC_USER_MESSAGE_PATTERNS = [
49
49
  /^\s*<(?:apps|plugins|skills)_instructions>/i,
50
50
  /^\s*<recommended_plugins>/i,
51
51
  /^\s*<(?:multi_agent_mode|environment_context)>/i,
52
+ /^\s*<user_info>/i,
52
53
  /^\s*## (?:In-flight in this repo|Host & Fleet)/i,
53
54
  /^\s*Your current session id is\b/i,
54
55
  /^\s*Linear context skipped:/i,
@@ -69,17 +70,26 @@ export function isSyntheticUserMessage(raw) {
69
70
  return false;
70
71
  return SYNTHETIC_USER_MESSAGE_PATTERNS.some(pattern => pattern.test(raw));
71
72
  }
73
+ /**
74
+ * Inner text of a `<user_query>` wrapper, or the original string if none.
75
+ * Same extraction Cursor already applies in `parseCursorUserText`.
76
+ */
77
+ export function unwrapUserQuery(text) {
78
+ const query = text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/);
79
+ return (query?.[1] ?? text).trim();
80
+ }
72
81
  /**
73
82
  * Return one genuine user turn in full.
74
83
  * Synthetic scaffolding is rejected as a whole so an injected Apps/AGENTS
75
84
  * preamble can never become the session's displayed request. Unlike the topic
76
85
  * cleaner, this intentionally preserves internal whitespace and every line of
77
- * the user's actual request.
86
+ * the user's actual request. A Grok/Cursor `<user_query>` wrapper is unwrapped
87
+ * so the stored turn is the originating request, not the harness tags.
78
88
  */
79
89
  export function cleanFirstUserMessage(raw) {
80
90
  if (!raw || isSyntheticUserMessage(raw))
81
91
  return undefined;
82
- const clean = raw.trim();
92
+ const clean = unwrapUserQuery(raw);
83
93
  return clean || undefined;
84
94
  }
85
95
  /** First genuine user message from an already-normalized event stream. */
@@ -4,15 +4,18 @@ import type { SessionAgentId, SessionMeta } from './types.js';
4
4
  /** One capability boundary for every surface that advertises faithful Resume. */
5
5
  export declare function sessionAgentSupportsResume(agent: SessionAgentId): boolean;
6
6
  /**
7
- * The account a recovery should authenticate as. Present only when resume
8
- * rotates AWAY from the session's original login (an account limit) to a
9
- * healthy sibling of the SAME harness. A `providerAccount` is a durable
10
- * setup-token / API-key account (RUSH-3182) injected via the `--account` spawn
11
- * path (`resolveSpawnAccount` → `accountEnv`); it is the only kind that can
7
+ * The account a recovery should authenticate as. Present when resume rotates
8
+ * AWAY from the session's original login (an account limit) to a healthy
9
+ * sibling of the SAME harness. A `providerAccount` is a durable setup-token /
10
+ * API-key account (RUSH-3182) injected via the `--account` spawn path
11
+ * (`resolveSpawnAccount` → `accountEnv`); it is the only kind that can
12
12
  * authenticate a NATIVE resume in the origin version home, because a native
13
- * login lives in its own isolated home and cannot be forwarded. Absent means
14
- * "use the launched version home's own native login" — the healthy-origin happy
15
- * path and every /continue fallback.
13
+ * login lives in its own isolated home and cannot be forwarded. It is also
14
+ * required on `/continue` when the balanced pick is a provider: exec only
15
+ * injects from this field, so omitting it would launch the version home's
16
+ * native login — the exhausted origin in the PHNX-3674 fixture. Absent means
17
+ * "use the launched version home's own native login" (the healthy-origin happy
18
+ * path, or `/continue` on a healthy native sibling).
16
19
  */
17
20
  export interface RecoveryAccount {
18
21
  providerAccount: string;
@@ -30,6 +33,7 @@ export type SessionRecoveryTarget = {
30
33
  mode: 'continue';
31
34
  agent: AgentId;
32
35
  version: string;
36
+ account?: RecoveryAccount;
33
37
  reason: string;
34
38
  };
35
39
  export type NativeResumeInspection = {
@@ -59,10 +63,15 @@ export declare function inspectNativeResumeSession(session: SessionMeta, version
59
63
  /**
60
64
  * Decide how a durable session resumes on the device that owns it.
61
65
  *
62
- * Native resume is legal only in the exact origin version's isolated home and
63
- * only while that account is healthy. Every other successful path stays on the
64
- * same harness and uses `/continue`, whose indexed transcript reader can reach
65
- * retained version trash. No healthy same-harness account is a loud failure.
66
+ * Native resume is legal only in the exact origin version's isolated home,
67
+ * and only while that home owns the indexed transcript AND some injectable
68
+ * credential for this harness is healthy: the origin login itself, or a
69
+ * provider account rotated in when the origin is usage-limited (PHNX-3626).
70
+ * Every other successful path stays on the same harness and uses `/continue`,
71
+ * whose indexed transcript reader can reach retained version trash. A
72
+ * `/continue` pick of a provider account carries RecoveryAccount so exec
73
+ * injects it instead of launching the version home's native login
74
+ * (PHNX-3674). No healthy same-harness account is a loud failure.
66
75
  */
67
76
  export declare function resolveSessionRecoveryFromCandidates(session: SessionMeta, candidates: RotateCandidate[], supportsNative?: (agent: AgentId, version?: string) => boolean, nativeInspection?: NativeResumeInspection): SessionRecoveryTarget;
68
77
  /**
@@ -50,6 +50,16 @@ function sourceReason(session, candidates) {
50
50
  ? `origin ${session.agent}@${session.version} has no native resume form`
51
51
  : `origin ${session.agent}@${session.version} is ${readiness.reason}`;
52
52
  }
53
+ function recoveryAccountFromCandidate(candidate) {
54
+ const providerAccount = candidate.providerAccount;
55
+ if (!providerAccount)
56
+ return undefined;
57
+ return {
58
+ providerAccount,
59
+ label: candidate.accountLabel || providerAccount,
60
+ email: candidate.email,
61
+ };
62
+ }
53
63
  function isPathInside(candidate, dir) {
54
64
  const rel = path.relative(dir, candidate);
55
65
  return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
@@ -146,10 +156,15 @@ export function inspectNativeResumeSession(session, versionHome) {
146
156
  /**
147
157
  * Decide how a durable session resumes on the device that owns it.
148
158
  *
149
- * Native resume is legal only in the exact origin version's isolated home and
150
- * only while that account is healthy. Every other successful path stays on the
151
- * same harness and uses `/continue`, whose indexed transcript reader can reach
152
- * retained version trash. No healthy same-harness account is a loud failure.
159
+ * Native resume is legal only in the exact origin version's isolated home,
160
+ * and only while that home owns the indexed transcript AND some injectable
161
+ * credential for this harness is healthy: the origin login itself, or a
162
+ * provider account rotated in when the origin is usage-limited (PHNX-3626).
163
+ * Every other successful path stays on the same harness and uses `/continue`,
164
+ * whose indexed transcript reader can reach retained version trash. A
165
+ * `/continue` pick of a provider account carries RecoveryAccount so exec
166
+ * injects it instead of launching the version home's native login
167
+ * (PHNX-3674). No healthy same-harness account is a loud failure.
153
168
  */
154
169
  export function resolveSessionRecoveryFromCandidates(session, candidates, supportsNative = nativeResume, nativeInspection) {
155
170
  const agent = runnableSessionAgent(session);
@@ -175,18 +190,17 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
175
190
  ?? inspectNativeResumeSession(session, getVersionHomePath(agent, session.version));
176
191
  if (inspection.available) {
177
192
  const rotated = pickBalancedCandidate(candidates.filter((c) => c.providerAccount && c.accountKey !== source.accountKey));
178
- const providerAccount = rotated?.picked.providerAccount;
179
- if (providerAccount) {
193
+ const account = rotated ? recoveryAccountFromCandidate(rotated.picked) : undefined;
194
+ if (account) {
180
195
  // `originLimited` guarantees the origin is unhealthy with a limit reason.
181
196
  const why = originReadiness.ready ? 'limited' : originReadiness.reason;
182
- const label = rotated.picked.accountLabel || providerAccount;
183
197
  return {
184
198
  mode: 'native',
185
199
  agent,
186
200
  version: session.version,
187
201
  cwd: inspection.cwd,
188
- account: { providerAccount, label, email: rotated.picked.email },
189
- reason: `origin ${agent}@${session.version} account is ${why}; rotated to healthy ${label} and resuming natively in the same home`,
202
+ account,
203
+ reason: `origin ${agent}@${session.version} account is ${why}; rotated to healthy ${account.label} and resuming natively in the same home`,
190
204
  };
191
205
  }
192
206
  }
@@ -203,6 +217,8 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
203
217
  throw new SessionRecoveryError(`Cannot recover session ${session.shortId} on ${device}; origin ${agent}@${session.version ?? 'unknown'}. ${detail}`);
204
218
  }
205
219
  const version = selection.picked.version;
220
+ const account = recoveryAccountFromCandidate(selection.picked);
221
+ const continueWith = account ? `healthy ${account.label}` : `healthy ${agent}@${version}`;
206
222
  // Native resume without an injected RecoveryAccount is valid only for the
207
223
  // exact healthy origin login. A balanced same-version provider selected for
208
224
  // a signed-out/revoked origin must stay on /continue; otherwise we would open
@@ -223,14 +239,16 @@ export function resolveSessionRecoveryFromCandidates(session, candidates, suppor
223
239
  mode: 'continue',
224
240
  agent,
225
241
  version,
226
- reason: `${inspection.reason}; continuing with healthy ${agent}@${version}`,
242
+ ...(account ? { account } : {}),
243
+ reason: `${inspection.reason}; continuing with ${continueWith}`,
227
244
  };
228
245
  }
229
246
  return {
230
247
  mode: 'continue',
231
248
  agent,
232
249
  version,
233
- reason: `${sourceReason(session, candidates)}; continuing with healthy ${agent}@${version}`,
250
+ ...(account ? { account } : {}),
251
+ reason: `${sourceReason(session, candidates)}; continuing with ${continueWith}`,
234
252
  };
235
253
  }
236
254
  /**
@@ -265,9 +265,12 @@ export function readPreviousSessionsForWatch(scope) {
265
265
  try {
266
266
  return querySessions({
267
267
  machine: normalizeHost(scope),
268
- limit: SESSION_WATCH_PREVIOUS_LIMIT,
268
+ agents: ['claude', 'codex', 'muse', 'opencode'],
269
+ sinceMs: Date.now() - 7 * 24 * 60 * 60 * 1000,
269
270
  excludeTeamOrigin: true,
270
- });
271
+ })
272
+ .filter((session) => !session.archived && Boolean(session.filePath))
273
+ .slice(0, SESSION_WATCH_PREVIOUS_LIMIT);
271
274
  }
272
275
  catch {
273
276
  return [];
@@ -19,6 +19,7 @@ import { isCompletedTodoStatus, SNAPSHOT_TODO_TOOLS, summarizeToolUse } from './
19
19
  import { isShellExecTool } from './shell-programs.js';
20
20
  import { classifyFileChanges } from './digest.js';
21
21
  import { extractArtifacts } from './highlights.js';
22
+ import { LINEAR_KEY_DENYLIST, linearIssueKeys } from './linear.js';
22
23
  /**
23
24
  * Detect per-session rate-limit / usage-limit signals in assistant or error
24
25
  * text (RUSH-1523). Matches the same shapes the ext's prewarm detectBlockingPrompt
@@ -187,15 +188,12 @@ export function extractRecentDirectoriesTouched(events, cwd) {
187
188
  const QUESTION_TRAILING = /\?["'”)\]]?\s*$/;
188
189
  const QUESTION_PHRASE = /\b(shall i|should i|do you want|would you like|which (?:one|option|approach|of)|can you (?:confirm|clarify)|please (?:confirm|clarify|advise)|let me know|are you (?:ok|okay|sure)|proceed\?)\b/i;
189
190
  /**
190
- * Linear/Jira-style ref, e.g. RUSH-1234. Team key is letters-only (2–6) so a
191
- * regex snippet like `[A-Z0-9]-\d` in a code discussion can't masquerade as a
192
- * ticket. Uppercase-only so we don't match `utf-8`.
191
+ * Linear/Jira-style ref detection reuses the canonical key matcher +
192
+ * {@link LINEAR_KEY_DENYLIST} from `./linear.js`, so the transcript detector and
193
+ * the owner-ping linkifier agree on what a real key is (no second copy to drift).
193
194
  */
194
- const TICKET_RE = /\b([A-Z]{2,6}-\d{1,6})\b/;
195
195
  /** Lowercase branch form (Linear branch names): muqsit/rush-1234-fix. */
196
196
  const TICKET_BRANCH_RE = /(?:^|[/_-])([a-z]{2,6})-(\d{2,6})(?=[/_-]|$)/;
197
- /** Keys that look like tickets but aren't — avoid false positives from branches. */
198
- const TICKET_DENYLIST = new Set(['UTF', 'SHA', 'ISO', 'RFC', 'IPV', 'X86', 'ARM', 'MP', 'H']);
199
197
  const PR_URL_RE = /https:\/\/github\.com\/[^\s"'()<>]+\/pull\/(\d+)/;
200
198
  // Either separator: a Windows session cwd is `…\.agents\worktrees\<slug>`, and a
201
199
  // forward-slash-only pattern silently derived no slug there (the RUSH-2358
@@ -283,15 +281,15 @@ export function detectWorktree(cwd, branch) {
283
281
  /** Detect a tracker ticket from free text (prompt/topic) then a branch name. */
284
282
  export function detectTicket(text, branch) {
285
283
  if (text) {
286
- const m = text.match(TICKET_RE);
287
- if (m && !TICKET_DENYLIST.has(m[1].split('-')[0]))
288
- return { id: m[1] };
284
+ const key = linearIssueKeys(text)[0];
285
+ if (key)
286
+ return { id: key };
289
287
  }
290
288
  if (branch) {
291
289
  const m = branch.match(TICKET_BRANCH_RE);
292
290
  if (m) {
293
291
  const key = m[1].toUpperCase();
294
- if (!TICKET_DENYLIST.has(key))
292
+ if (!LINEAR_KEY_DENYLIST.has(key))
295
293
  return { id: `${key}-${m[2]}` };
296
294
  }
297
295
  }
@@ -355,9 +353,9 @@ export function isTicketCreateTool(name, command) {
355
353
  export function extractCreatedTicket(text) {
356
354
  if (!text)
357
355
  return undefined;
358
- const lin = text.match(TICKET_RE);
359
- if (lin && !TICKET_DENYLIST.has(lin[1].split('-')[0]))
360
- return lin[1];
356
+ const lin = linearIssueKeys(text)[0];
357
+ if (lin)
358
+ return lin;
361
359
  const gh = text.match(GH_ISSUE_URL_RE);
362
360
  if (gh)
363
361
  return `#${gh[1]}`;
@@ -88,8 +88,8 @@ export declare function phoenixIdBaseForDeploy(opts?: {
88
88
  domain?: string;
89
89
  }): string | undefined;
90
90
  /**
91
- * Pick the principal. Signed-in (`readSession() != null`) AND no explicit BYO
92
- * override → managed; otherwise BYO.
91
+ * Pick the principal via the shared selection policy. Signed-in AND no explicit
92
+ * BYO override → managed; otherwise BYO.
93
93
  */
94
94
  export declare function shouldUseManaged(opts?: ResolveShareBackendOpts): boolean;
95
95
  export declare function resolveShareBackend(opts?: ResolveShareBackendOpts): ShareBackend;
@@ -18,6 +18,7 @@
18
18
  * - `AGENTS_SHARE_BACKEND=byo`
19
19
  */
20
20
  import { PHOENIX_ID_BASE, readSession } from '../identity/client.js';
21
+ import { selectStorageBackendKind } from '../storage/selection.js';
21
22
  import { DEFAULT_SHARE_DOMAIN, readShareConfig, readWriteToken, readWriteTokenEnv, } from './config.js';
22
23
  /** Env var that forces the BYO path. Value must be exactly `byo`. */
23
24
  export const SHARE_BACKEND_ENV = 'AGENTS_SHARE_BACKEND';
@@ -82,18 +83,28 @@ export function phoenixIdBaseForDeploy(opts = {}, cfg) {
82
83
  return base;
83
84
  }
84
85
  /**
85
- * Pick the principal. Signed-in (`readSession() != null`) AND no explicit BYO
86
- * override → managed; otherwise BYO.
86
+ * The share surface's BYO-override signals: an explicit `--byo`, a
87
+ * caller-supplied static write token, or `AGENTS_SHARE_BACKEND=byo`. Detecting
88
+ * WHICH signals count is surface-specific; the managed-vs-BYO decision itself is
89
+ * the shared policy (`selectStorageBackendKind`). A persisted BYO endpoint config
90
+ * is deliberately NOT an override here — a signed-in user with a stale BYO config
91
+ * still publishes to managed unless they opt out explicitly (the product's
92
+ * managed-first contract).
87
93
  */
88
- export function shouldUseManaged(opts = {}) {
94
+ function shareByoOverride(opts) {
89
95
  if (opts.byo === true)
90
- return false;
96
+ return true;
91
97
  if (opts.writeToken)
92
- return false;
93
- if ((process.env[SHARE_BACKEND_ENV] ?? '').trim().toLowerCase() === 'byo')
94
- return false;
95
- const session = opts.session === undefined ? readSession() : opts.session;
96
- return session != null;
98
+ return true;
99
+ return (process.env[SHARE_BACKEND_ENV] ?? '').trim().toLowerCase() === 'byo';
100
+ }
101
+ /**
102
+ * Pick the principal via the shared selection policy. Signed-in AND no explicit
103
+ * BYO override → managed; otherwise BYO.
104
+ */
105
+ export function shouldUseManaged(opts = {}) {
106
+ return (selectStorageBackendKind({ byoOverride: shareByoOverride(opts), session: opts.session }) ===
107
+ 'managed');
97
108
  }
98
109
  export function resolveShareBackend(opts = {}) {
99
110
  if (shouldUseManaged(opts)) {
@@ -1,8 +1,12 @@
1
1
  import { type ShareConfig } from './config.js';
2
- /** DI seam for tests — override the real HTTP DELETE. */
2
+ /** DI seam for tests — override the real HTTP DELETE. `body`/`retryAfter` carry
3
+ * the Worker's error response (read only on `!ok`) so a failed delete surfaces
4
+ * WHY, via the bounded {@link extractShareHttpError}. */
3
5
  export type DeleteFn = (url: string, headers: Record<string, string>) => Promise<{
4
6
  ok: boolean;
5
7
  status: number;
8
+ body?: string;
9
+ retryAfter?: string;
6
10
  }>;
7
11
  /** DI seam for tests — override the real HTTP existence check (HEAD). */
8
12
  export type CheckFn = (url: string) => Promise<{
@@ -9,6 +9,7 @@
9
9
  // the operation is reported as successful.
10
10
  import { resolveShareBackend } from './backend.js';
11
11
  import { buildShareKey, resolveShareUsername } from './publish.js';
12
+ import { extractShareHttpError, formatShareHttpErrorDetail } from './http-error.js';
12
13
  /**
13
14
  * Normalize any of the three accepted target forms to the R2 key that publish
14
15
  * would have written:
@@ -59,7 +60,10 @@ async function defaultCheck(url) {
59
60
  }
60
61
  async function defaultDelete(url, headers) {
61
62
  const res = await fetch(url, { method: 'DELETE', headers });
62
- return { ok: res.ok, status: res.status };
63
+ if (res.ok)
64
+ return { ok: true, status: res.status };
65
+ const body = await res.text().catch(() => undefined);
66
+ return { ok: false, status: res.status, body, retryAfter: res.headers.get('retry-after') ?? undefined };
63
67
  }
64
68
  async function defaultRevisionsFetch(url) {
65
69
  const res = await fetch(url, { headers: { accept: 'application/json' } });
@@ -103,7 +107,8 @@ export async function deleteObject(endpoint, key, opts = {}) {
103
107
  const existedBefore = before.status !== 404;
104
108
  const r = await del(url, { authorization: `Bearer ${endpoint.token}` });
105
109
  if (!r.ok) {
106
- throw new Error(`Delete failed (${r.status}) for ${url}. Check the bearer (Phoenix session or WRITE_TOKEN), or that 'agents artifacts setup' completed.`);
110
+ const detail = formatShareHttpErrorDetail(extractShareHttpError({ status: r.status, body: r.body, retryAfter: r.retryAfter }));
111
+ throw new Error(`Delete failed (${r.status}) for ${url}${detail}. Check the bearer (Phoenix session or WRITE_TOKEN), or that 'agents artifacts setup' completed.`);
107
112
  }
108
113
  const after = await check(url);
109
114
  const verified404 = after.status === 404;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * ONE bounded extractor for the share Worker's error responses.
3
+ *
4
+ * Every share HTTP call (publish PUT, list/revisions GET, delete DELETE)
5
+ * previously threw on `!ok` with only the status code, discarding the JSON error
6
+ * body the Worker actually returns (`{"error":"…"}` for a 400/413/429, plus a
7
+ * `Retry-After` on a 429). The result: a rate-limited or quota-exceeded publish
8
+ * surfaced as a bare "Publish failed (429)" with no reason. This pulls the
9
+ * server's own `error` string and preserves the status + `Retry-After`, so the
10
+ * user sees why.
11
+ *
12
+ * Bounded and safe by construction: it parses at most {@link MAX_ERROR_BODY_BYTES}
13
+ * of the body, only lifts a string `error` field, caps its length, and NEVER
14
+ * surfaces an arbitrary body — a non-JSON or oversized body yields no message,
15
+ * so an HTML error page or a huge payload can't leak into a CLI error string.
16
+ */
17
+ /** The raw pieces a caller's fetch wrapper hands in — status plus the (optional)
18
+ * response body text and `Retry-After` header value. */
19
+ export interface ShareHttpResponse {
20
+ status: number;
21
+ /** The response body text, when the caller read it (only on `!ok` paths). */
22
+ body?: string;
23
+ /** The `Retry-After` header value, when present (a 429 carries it). */
24
+ retryAfter?: string;
25
+ }
26
+ /** The extracted, bounded error facts. `serverMessage` is present only when the
27
+ * body parsed as JSON carrying a non-empty string `error`. */
28
+ export interface ShareHttpError {
29
+ status: number;
30
+ serverMessage?: string;
31
+ retryAfter?: string;
32
+ }
33
+ /** Never parse more than this many chars of a body — a legitimate `{"error":"…"}`
34
+ * is tiny; a larger body is an HTML page or noise we deliberately don't surface. */
35
+ export declare const MAX_ERROR_BODY_BYTES = 4096;
36
+ /** Cap the surfaced message so even a pathological (but valid-JSON) `error`
37
+ * string can't blow up the CLI output. */
38
+ export declare const MAX_ERROR_MESSAGE_CHARS = 300;
39
+ /**
40
+ * Extract the bounded error facts from a share Worker response. Pulls a string
41
+ * `error` field from a small JSON body, preserves status + `Retry-After`, and
42
+ * never surfaces an arbitrary body.
43
+ */
44
+ export declare function extractShareHttpError(res: ShareHttpResponse): ShareHttpError;
45
+ /**
46
+ * A one-line suffix summarizing the server's structured error, or `''` when the
47
+ * response carried nothing extractable. Appended after the status-bearing prefix
48
+ * a caller already builds, e.g.:
49
+ * `Publish failed (429) for <url>` + `formatShareHttpErrorDetail(err)`
50
+ * → `Publish failed (429) for <url> — rate limit exceeded; retry after 37s`
51
+ */
52
+ export declare function formatShareHttpErrorDetail(err: ShareHttpError): string;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * ONE bounded extractor for the share Worker's error responses.
3
+ *
4
+ * Every share HTTP call (publish PUT, list/revisions GET, delete DELETE)
5
+ * previously threw on `!ok` with only the status code, discarding the JSON error
6
+ * body the Worker actually returns (`{"error":"…"}` for a 400/413/429, plus a
7
+ * `Retry-After` on a 429). The result: a rate-limited or quota-exceeded publish
8
+ * surfaced as a bare "Publish failed (429)" with no reason. This pulls the
9
+ * server's own `error` string and preserves the status + `Retry-After`, so the
10
+ * user sees why.
11
+ *
12
+ * Bounded and safe by construction: it parses at most {@link MAX_ERROR_BODY_BYTES}
13
+ * of the body, only lifts a string `error` field, caps its length, and NEVER
14
+ * surfaces an arbitrary body — a non-JSON or oversized body yields no message,
15
+ * so an HTML error page or a huge payload can't leak into a CLI error string.
16
+ */
17
+ /** Never parse more than this many chars of a body — a legitimate `{"error":"…"}`
18
+ * is tiny; a larger body is an HTML page or noise we deliberately don't surface. */
19
+ export const MAX_ERROR_BODY_BYTES = 4096;
20
+ /** Cap the surfaced message so even a pathological (but valid-JSON) `error`
21
+ * string can't blow up the CLI output. */
22
+ export const MAX_ERROR_MESSAGE_CHARS = 300;
23
+ /**
24
+ * Extract the bounded error facts from a share Worker response. Pulls a string
25
+ * `error` field from a small JSON body, preserves status + `Retry-After`, and
26
+ * never surfaces an arbitrary body.
27
+ */
28
+ export function extractShareHttpError(res) {
29
+ const out = { status: res.status };
30
+ const retryAfter = res.retryAfter?.trim();
31
+ if (retryAfter)
32
+ out.retryAfter = retryAfter;
33
+ const raw = res.body;
34
+ if (raw && raw.length <= MAX_ERROR_BODY_BYTES) {
35
+ try {
36
+ const parsed = JSON.parse(raw);
37
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
38
+ const err = parsed.error;
39
+ if (typeof err === 'string' && err.trim()) {
40
+ out.serverMessage = err.trim().slice(0, MAX_ERROR_MESSAGE_CHARS);
41
+ }
42
+ }
43
+ }
44
+ catch {
45
+ // Not JSON (an HTML error page, a proxy body, truncated bytes) — deliberately
46
+ // surface nothing rather than dump an arbitrary body into the CLI error.
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ /**
52
+ * A one-line suffix summarizing the server's structured error, or `''` when the
53
+ * response carried nothing extractable. Appended after the status-bearing prefix
54
+ * a caller already builds, e.g.:
55
+ * `Publish failed (429) for <url>` + `formatShareHttpErrorDetail(err)`
56
+ * → `Publish failed (429) for <url> — rate limit exceeded; retry after 37s`
57
+ */
58
+ export function formatShareHttpErrorDetail(err) {
59
+ const parts = [];
60
+ if (err.serverMessage)
61
+ parts.push(err.serverMessage);
62
+ if (err.retryAfter)
63
+ parts.push(`retry after ${err.retryAfter}s`);
64
+ return parts.length ? ` — ${parts.join('; ')}` : '';
65
+ }
@@ -1,10 +1,20 @@
1
1
  import { type ShareConfig } from './config.js';
2
2
  import { type ShareBackendKind } from './backend.js';
3
- export type PutFn = (url: string, body: Buffer, headers: Record<string, string>) => Promise<{
3
+ import { type ShareVisibility } from '../storage/visibility.js';
4
+ /** The share upload result. `body`/`retryAfter` carry the server's error
5
+ * response so a failed publish can surface WHY (the Worker returns
6
+ * `{"error":"…"}` + a `Retry-After` on a 429); they are read only on `!ok`
7
+ * paths, and only ever fed through the bounded {@link extractShareHttpError}. */
8
+ export type PutResult = {
4
9
  ok: boolean;
5
10
  status: number;
6
11
  url?: string;
7
- }>;
12
+ /** Response body text, present on the `!ok` path so the error can be extracted. */
13
+ body?: string;
14
+ /** `Retry-After` header value, present on a 429. */
15
+ retryAfter?: string;
16
+ };
17
+ export type PutFn = (url: string, body: Buffer, headers: Record<string, string>) => Promise<PutResult>;
8
18
  export interface PublishEndpoint {
9
19
  baseUrl: string;
10
20
  token: string;
@@ -102,7 +112,7 @@ export interface PublishOptions {
102
112
  /** DI seam for tests — override provenance auto-capture (agent/session/host/repo/date). */
103
113
  provenance?: ShareProvenance;
104
114
  }
105
- export type ShareVisibility = 'public' | 'unlisted' | 'private' | 'me' | 'org';
115
+ export { type ShareVisibility } from '../storage/visibility.js';
106
116
  /** The visibility levels a publish `--visibility` may select — the Worker's own
107
117
  * set. `private` (token-gated, PHNX-3654) is settable only at publish time,
108
118
  * since it mints a viewer token the metadata-edit route can't carry, so it is
@@ -16,6 +16,8 @@ import { resolveGitHubUsername } from '../git.js';
16
16
  import { resolveShareBackend, sanitizeShareNamespace } from './backend.js';
17
17
  import { captureCover, OG_WIDTH, OG_HEIGHT, OG_SCALE } from './capture.js';
18
18
  import { deriveMeta, injectOgMeta } from './og.js';
19
+ import { extractShareHttpError, formatShareHttpErrorDetail } from './http-error.js';
20
+ import { PUBLISH_VISIBILITY_LEVELS as STORAGE_PUBLISH_VISIBILITY_LEVELS, EDITABLE_VISIBILITY_LEVELS, resolveVisibility, } from '../storage/visibility.js';
19
21
  import { injectAnalyticsBeacon } from './analytics.js';
20
22
  import { prepareShareHtml } from './html.js';
21
23
  /** The visibility levels a publish `--visibility` may select — the Worker's own
@@ -23,12 +25,12 @@ import { prepareShareHtml } from './html.js';
23
25
  * since it mints a viewer token the metadata-edit route can't carry, so it is
24
26
  * NOT in {@link SHARE_VISIBILITY_LEVELS} (the in-place `share visibility <level>`
25
27
  * set). */
26
- export const PUBLISH_VISIBILITY_LEVELS = ['public', 'unlisted', 'private', 'me', 'org'];
28
+ export const PUBLISH_VISIBILITY_LEVELS = STORAGE_PUBLISH_VISIBILITY_LEVELS;
27
29
  /** The visibility levels an ALREADY-published share may be re-scoped to in place
28
30
  * — the set `share visibility <target> <level>` and the inline owner control
29
31
  * accept. Excludes `private`: re-scoping to token-gated needs a fresh viewer
30
32
  * token, which only the publish path mints. */
31
- export const SHARE_VISIBILITY_LEVELS = ['public', 'unlisted', 'me', 'org'];
33
+ export const SHARE_VISIBILITY_LEVELS = EDITABLE_VISIBILITY_LEVELS;
32
34
  /**
33
35
  * `--protected` / `{ protected: true }` map to `private` (token-gated — it wins
34
36
  * over `--unlisted` when both are set, being the stronger control); `--unlisted`
@@ -36,17 +38,13 @@ export const SHARE_VISIBILITY_LEVELS = ['public', 'unlisted', 'me', 'org'];
36
38
  * otherwise `visibility` (default public).
37
39
  */
38
40
  export function resolveShareVisibility(opts = {}) {
39
- if (opts.protected === true)
40
- return 'private';
41
- if (opts.unlisted === true)
42
- return 'unlisted';
43
- if (opts.visibility === 'unlisted' ||
44
- opts.visibility === 'private' ||
45
- opts.visibility === 'me' ||
46
- opts.visibility === 'org') {
47
- return opts.visibility;
48
- }
49
- return 'public';
41
+ // Delegates to the shared visibility resolver. The library fallback stays
42
+ // `public` so a lib caller that hasn't opted into the managed `me` default
43
+ // (e.g. `sessions share --public`, which expresses "public" as the absence of
44
+ // --unlisted) is never silently flipped. The PRODUCT default (`me` on managed)
45
+ // is applied by the `agents artifacts share` command surface, which knows the
46
+ // resolved backend — see `commands/share.ts`.
47
+ return resolveVisibility(opts);
50
48
  }
51
49
  /** The bytes of viewer-token entropy the `private` mode mints (128-bit; the
52
50
  * base64url form is ~22 URL-safe chars). Well past the 64-bit floor a guessable
@@ -596,7 +594,12 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
596
594
  const put = opts.uploader ??
597
595
  (async (u, b, h) => {
598
596
  const res = await fetch(u, { method: 'PUT', headers: h, body: new Uint8Array(b) });
599
- return { ok: res.ok, status: res.status, url: u };
597
+ // Read the error body only on failure, so the caller can surface the
598
+ // Worker's own `{"error":"…"}` + Retry-After (bounded by extractShareHttpError).
599
+ if (res.ok)
600
+ return { ok: true, status: res.status, url: u };
601
+ const body = await res.text().catch(() => undefined);
602
+ return { ok: false, status: res.status, url: u, body, retryAfter: res.headers.get('retry-after') ?? undefined };
600
603
  });
601
604
  let coverUrl;
602
605
  const isHtml = /\.html?$/i.test(filePath);
@@ -748,7 +751,8 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
748
751
  if (r.status === 409) {
749
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.`);
750
753
  }
751
- throw new Error(`Publish failed (${r.status}) for ${pageUrl}. Check the write token, or that 'agents artifacts setup' completed.`);
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.`);
752
756
  }
753
757
  // A token-gated page is only reachable WITH its key, so the URL we hand back
754
758
  // (and store nowhere) carries it — https://<host>/<user>/<slug>?k=<token>.
@@ -1406,7 +1406,11 @@ function isIdentityGated(visibility) {
1406
1406
 
1407
1407
  function managedCoverHeaders(visibility) {
1408
1408
  const headers = new Headers({ 'content-type': 'image/png' });
1409
- if (isIdentityGated(visibility)) {
1409
+ // Token-gated (private) + identity-gated (me/org) covers must never be
1410
+ // cached by a shared proxy, and never indexed. RFC 9111: public on an
1411
+ // Authorization response authorizes reuse for later unauthenticated
1412
+ // requests keyed on /user/slug.png (PHNX-3676).
1413
+ if (isIdentityGated(visibility) || visibility === 'private') {
1410
1414
  headers.set('cache-control', 'private, no-store');
1411
1415
  headers.set('X-Robots-Tag', 'noindex');
1412
1416
  } else {