@phnx-labs/agents-cli 1.22.53 → 1.22.54

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 (134) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +41 -8
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +219 -173
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +10 -8
  16. package/dist/commands/focus.d.ts +1 -0
  17. package/dist/commands/focus.js +2 -2
  18. package/dist/commands/go.d.ts +5 -4
  19. package/dist/commands/go.js +7 -7
  20. package/dist/commands/insights.js +9 -0
  21. package/dist/commands/monitors.js +85 -30
  22. package/dist/commands/output.js +8 -2
  23. package/dist/commands/repo.js +18 -0
  24. package/dist/commands/secrets.js +33 -14
  25. package/dist/commands/sessions.d.ts +20 -12
  26. package/dist/commands/sessions.js +64 -20
  27. package/dist/commands/setup-accounts.d.ts +8 -0
  28. package/dist/commands/setup-accounts.js +47 -0
  29. package/dist/commands/setup.d.ts +1 -1
  30. package/dist/commands/setup.js +11 -2
  31. package/dist/commands/share.d.ts +52 -3
  32. package/dist/commands/share.js +262 -18
  33. package/dist/commands/ssh.d.ts +7 -0
  34. package/dist/commands/ssh.js +18 -2
  35. package/dist/commands/status.js +14 -0
  36. package/dist/commands/view.d.ts +3 -1
  37. package/dist/commands/view.js +5 -4
  38. package/dist/lib/account-registry.js +15 -3
  39. package/dist/lib/accounting/rotate.d.ts +20 -6
  40. package/dist/lib/accounting/rotate.js +38 -7
  41. package/dist/lib/accounting/usage.d.ts +37 -1
  42. package/dist/lib/accounting/usage.js +71 -6
  43. package/dist/lib/agent-spec/agents.d.ts +5 -2
  44. package/dist/lib/agent-spec/agents.js +25 -7
  45. package/dist/lib/analytics/mix-commands.js +12 -6
  46. package/dist/lib/auth-mint.d.ts +150 -0
  47. package/dist/lib/auth-mint.js +434 -0
  48. package/dist/lib/browser/remote-control.d.ts +9 -7
  49. package/dist/lib/browser/remote-control.js +9 -7
  50. package/dist/lib/claude-account-token.d.ts +10 -0
  51. package/dist/lib/claude-account-token.js +14 -4
  52. package/dist/lib/config-drift.d.ts +37 -0
  53. package/dist/lib/config-drift.js +72 -0
  54. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  55. package/dist/lib/daemon/auth-sync-service.js +34 -0
  56. package/dist/lib/daemon/daemon.js +30 -4
  57. package/dist/lib/daemon-services.d.ts +1 -1
  58. package/dist/lib/daemon-services.js +5 -0
  59. package/dist/lib/device-config.d.ts +3 -3
  60. package/dist/lib/device-config.js +5 -5
  61. package/dist/lib/devices/connect.d.ts +26 -0
  62. package/dist/lib/devices/connect.js +48 -1
  63. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  64. package/dist/lib/devices/doctor-findings.js +19 -1
  65. package/dist/lib/exec.d.ts +28 -0
  66. package/dist/lib/exec.js +73 -7
  67. package/dist/lib/feed/feed.d.ts +1 -1
  68. package/dist/lib/feed/feed.js +23 -1
  69. package/dist/lib/feed-broadcast.js +1 -1
  70. package/dist/lib/fleet/apply.d.ts +11 -0
  71. package/dist/lib/fleet/apply.js +23 -3
  72. package/dist/lib/fleet/auth-sync.js +5 -3
  73. package/dist/lib/help.d.ts +9 -0
  74. package/dist/lib/help.js +29 -1
  75. package/dist/lib/hosts/passthrough.d.ts +1 -10
  76. package/dist/lib/hosts/passthrough.js +1 -13
  77. package/dist/lib/installations/versions.js +9 -1
  78. package/dist/lib/linux-userns.d.ts +58 -0
  79. package/dist/lib/linux-userns.js +116 -0
  80. package/dist/lib/memory.d.ts +26 -0
  81. package/dist/lib/memory.js +80 -1
  82. package/dist/lib/monitors/config.d.ts +11 -0
  83. package/dist/lib/monitors/config.js +8 -0
  84. package/dist/lib/monitors/engine.js +8 -1
  85. package/dist/lib/monitors/state.d.ts +37 -1
  86. package/dist/lib/monitors/state.js +79 -4
  87. package/dist/lib/permissions-registry.d.ts +2 -0
  88. package/dist/lib/permissions-registry.js +116 -14
  89. package/dist/lib/permissions.d.ts +5 -3
  90. package/dist/lib/permissions.js +25 -27
  91. package/dist/lib/profiles.d.ts +8 -7
  92. package/dist/lib/profiles.js +12 -0
  93. package/dist/lib/project-key.d.ts +9 -0
  94. package/dist/lib/project-key.js +11 -0
  95. package/dist/lib/secrets/bundles.d.ts +35 -0
  96. package/dist/lib/secrets/bundles.js +78 -1
  97. package/dist/lib/secrets/push.d.ts +3 -8
  98. package/dist/lib/secrets/push.js +18 -14
  99. package/dist/lib/secrets/remote.d.ts +9 -18
  100. package/dist/lib/secrets/remote.js +11 -26
  101. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  102. package/dist/lib/secrets/reserved-sync.js +129 -0
  103. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  104. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  105. package/dist/lib/self-heal/registry.js +4 -0
  106. package/dist/lib/self-heal/types.d.ts +1 -1
  107. package/dist/lib/session/active.js +1 -4
  108. package/dist/lib/session/db.d.ts +39 -4
  109. package/dist/lib/session/db.js +130 -29
  110. package/dist/lib/session/discover.d.ts +32 -4
  111. package/dist/lib/session/discover.js +119 -25
  112. package/dist/lib/session/insights.d.ts +14 -0
  113. package/dist/lib/session/insights.js +25 -2
  114. package/dist/lib/session/linear.js +1 -1
  115. package/dist/lib/session/shell-programs.d.ts +17 -0
  116. package/dist/lib/session/shell-programs.js +21 -0
  117. package/dist/lib/session/state.js +2 -1
  118. package/dist/lib/session/stream-render.js +2 -1
  119. package/dist/lib/session/tool-calls.js +2 -5
  120. package/dist/lib/session/trajectory-html.js +2 -1
  121. package/dist/lib/session/trajectory.js +3 -12
  122. package/dist/lib/session/types.d.ts +8 -0
  123. package/dist/lib/share/publish.d.ts +53 -5
  124. package/dist/lib/share/publish.js +99 -17
  125. package/dist/lib/share/worker-template.js +582 -57
  126. package/dist/lib/startup/root-command.js +2 -1
  127. package/dist/lib/state.d.ts +16 -0
  128. package/dist/lib/state.js +178 -46
  129. package/dist/lib/sync-status.d.ts +4 -0
  130. package/dist/lib/sync-status.js +3 -0
  131. package/dist/lib/traces/classify.js +24 -19
  132. package/dist/lib/usage-refresh.js +2 -1
  133. package/dist/lib/view-types.d.ts +2 -0
  134. package/package.json +2 -1
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import * as path from 'path';
18
18
  import { isCompletedTodoStatus, SNAPSHOT_TODO_TOOLS, summarizeToolUse } from './parse.js';
19
+ import { isShellExecTool } from './shell-programs.js';
19
20
  /**
20
21
  * Detect per-session rate-limit / usage-limit signals in assistant or error
21
22
  * text (RUSH-1523). Matches the same shapes the ext's prewarm detectBlockingPrompt
@@ -174,7 +175,7 @@ export function extractRecentDirectoriesTouched(events, cwd) {
174
175
  if (['Edit', 'Write', 'edit_file', 'write_file', 'create_file', 'edit', 'write'].includes(tool)) {
175
176
  add(args.file_path ?? args.filePath ?? args.path ?? event.path, true);
176
177
  }
177
- else if (['Bash', 'exec_command', 'exec', 'run_shell_command', 'shell', 'Execute'].includes(tool)) {
178
+ else if (isShellExecTool(tool)) {
178
179
  add(args.cwd ?? args.Cwd ?? args.workdir ?? args.working_directory ?? cwd);
179
180
  }
180
181
  }
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import { truncate } from '../format.js';
3
3
  import { parseClaudeContent, parseCodexContent, sanitizeEvents, summarizeToolUse } from './parse.js';
4
4
  import { linkPath, relativeToCwd } from './render.js';
5
+ import { isShellExecTool } from './shell-programs.js';
5
6
  const LINE_MAX = 120;
6
7
  function timeOf(event) {
7
8
  const d = new Date(event.timestamp);
@@ -11,7 +12,7 @@ function timeOf(event) {
11
12
  }
12
13
  function paintTool(tool) {
13
14
  const label = tool.padEnd(10);
14
- if (tool === 'Bash' || tool === 'exec_command')
15
+ if (isShellExecTool(tool))
15
16
  return chalk.yellow(label);
16
17
  if (tool === 'Edit' || tool === 'Write' || tool === 'Read')
17
18
  return chalk.cyan(label);
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'crypto';
2
2
  import { parse } from 'acorn';
3
3
  import { knownSecretValuesFromEnv, redactSecrets, sanitizeForTerminal } from '../redact.js';
4
- import { extractShellPrograms } from './shell-programs.js';
4
+ import { extractShellPrograms, isShellExecTool } from './shell-programs.js';
5
5
  export const TOOL_INPUT_MAX_BYTES = 16 * 1024;
6
6
  export const TOOL_SUCCESS_OUTPUT_MAX_BYTES = 1024;
7
7
  export const TOOL_ERROR_OUTPUT_MAX_BYTES = 4 * 1024;
@@ -13,9 +13,6 @@ export const TOOL_INDEX_LIMIT_ORDINAL = Number.MAX_SAFE_INTEGER;
13
13
  export const TOOL_TEXT_PROCESSING_MAX_BYTES = 64 * 1024;
14
14
  export const TOOL_SHELL_PARSE_MAX_BYTES = 64 * 1024;
15
15
  export const TOOL_INDEX_VERSION = 7;
16
- const SHELL_TOOLS = new Set([
17
- 'bash', 'exec', 'execute', 'exec_command', 'run_command', 'run_shell_command', 'shell',
18
- ]);
19
16
  const BASE64_BLOCK = /(?:[A-Za-z0-9+/]{256,}={0,2})/g;
20
17
  const SECRET_FIELD = /(?:token|secret|password|authorization|cookie|api[_-]?key|private[_-]?key)$/i;
21
18
  const KNOWN_SECRET_VALUES = knownSecretValuesFromEnv();
@@ -225,7 +222,7 @@ export function commandsFromCodexExec(source) {
225
222
  return commands;
226
223
  }
227
224
  function commandFor(tool, args, command) {
228
- if (!SHELL_TOOLS.has(tool.toLowerCase()))
225
+ if (!isShellExecTool(tool))
229
226
  return undefined;
230
227
  const direct = command
231
228
  ?? (typeof args?.command === 'string' ? args.command : undefined)
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { formatDuration, formatTokenCount } from './render.js';
16
16
  import { escapeHtml } from './share-html.js';
17
+ import { isShellExecTool } from './shell-programs.js';
17
18
  /**
18
19
  * The honest footer prefix for a rendered trace: the redacted label only when
19
20
  * something was actually redacted, else the "local only" disclaimer under
@@ -30,7 +31,7 @@ function toolColor(step) {
30
31
  if (step.kind === 'thinking')
31
32
  return '#3a3a55';
32
33
  const tool = (step.tool ?? '').toLowerCase();
33
- if (tool === 'bash' || tool === 'shell' || tool.includes('exec') || tool === 'run_command')
34
+ if (isShellExecTool(tool))
34
35
  return '#e0b341';
35
36
  if (tool === 'read' || tool === 'grep' || tool === 'glob' || tool === 'search' || tool === 'codebase_search')
36
37
  return '#4a9eff';
@@ -22,22 +22,13 @@
22
22
  */
23
23
  import { redactSecrets } from '../redact.js';
24
24
  import { computeSummaryStats } from './render.js';
25
- import { extractShellPrograms } from './shell-programs.js';
25
+ import { extractShellPrograms, isShellExecTool } from './shell-programs.js';
26
26
  const DEFAULT_IDLE_THRESHOLD_MS = 120_000;
27
27
  const DEFAULT_MAX_STEPS = 5000;
28
28
  const LABEL_MAX = 140;
29
29
  const DETAIL_MAX = 400;
30
30
  /** Tools that spawn an inline sub-agent inside THIS transcript (a `tool_use` row). */
31
31
  const INLINE_TASK_TOOLS = new Set(['Task', 'Agent']);
32
- /**
33
- * Shell tools whose command is worth resolving to an effective program — every
34
- * harness's shell-exec tool, kept in lockstep with the canonical set in
35
- * `state.ts` (`['Bash', 'exec_command', 'exec', 'run_shell_command', 'shell', 'Execute']`)
36
- * so Codex's `exec_command`/`exec` and the rest are covered, not just Claude's `Bash`.
37
- * `exec` is newer Codex (gpt-5.6-sol) whose real command the parser unwraps from a
38
- * JS cell into the event's `command` before this runs.
39
- */
40
- const SHELL_TOOLS = new Set(['Bash', 'exec_command', 'exec', 'run_shell_command', 'shell', 'Execute']);
41
32
  /**
42
33
  * Shell builtins/assignments that are rarely the POINT of a command — the action
43
34
  * is whatever runs after them (`export X=Y; git push` → `git`, `cd dir && bun test`
@@ -141,7 +132,7 @@ function stripLeadingShellNoise(command) {
141
132
  function toolLabel(tool, args, command, redact, knownSecrets) {
142
133
  let raw;
143
134
  const lower = tool.toLowerCase();
144
- if (lower === 'bash' || lower === 'shell' || lower === 'run_command' || lower.includes('exec')) {
135
+ if (isShellExecTool(tool)) {
145
136
  const cmd = command ?? stringArg(args, 'command', 'cmd', 'script');
146
137
  raw = cmd ? stripLeadingShellNoise(cmd) : cmd;
147
138
  }
@@ -247,7 +238,7 @@ export function buildTrajectory(events, meta, options = {}) {
247
238
  label: toolLabel(tool, e.args, e.command, redact, knownSecrets),
248
239
  delegation: INLINE_TASK_TOOLS.has(tool) ? 'inline-task' : undefined,
249
240
  callId: e.callId,
250
- program: SHELL_TOOLS.has(tool) ? effectiveProgram(e.command ?? (typeof e.args?.command === 'string' ? e.args.command : undefined)) : undefined,
241
+ program: isShellExecTool(tool) ? effectiveProgram(e.command ?? (typeof e.args?.command === 'string' ? e.args.command : undefined)) : undefined,
251
242
  exitCode: typeof e.exitCode === 'number' ? e.exitCode : undefined,
252
243
  },
253
244
  eventIndex: i,
@@ -384,6 +384,14 @@ export interface SessionMeta {
384
384
  _matchedTerms?: string[];
385
385
  /** BM25 relevance score from the most recent content-index search */
386
386
  _bm25Score?: number;
387
+ /**
388
+ * A short highlighted excerpt (`**term**`) around the best-matching FTS5
389
+ * column (label/topic/project/user content/assistant answer) for the most
390
+ * recent content-index search. Unlike `_matchedTerms`/`_bm25Score` this is
391
+ * NOT stripped from `--json` output — it's the searchable-context payload a
392
+ * content-search result carries, not internal ranking bookkeeping.
393
+ */
394
+ snippet?: string;
387
395
  }
388
396
  /** Output format for rendering a session's content. */
389
397
  export type ViewMode = 'summary' | 'markdown' | 'json';
@@ -53,6 +53,12 @@ export interface PublishOptions {
53
53
  writeToken?: string;
54
54
  /** DI seam for tests — override `readSession()`. `null` means signed out. */
55
55
  session?: import('../identity/client.js').PhoenixSession | null;
56
+ /**
57
+ * Override the sharer's avatar URL stamped on the object (test seam). When
58
+ * omitted it is derived from the signed-in email via {@link resolveShareAvatar};
59
+ * an empty string suppresses the stamp (initials-only bar).
60
+ */
61
+ avatar?: string;
56
62
  /** Force the BYO Cloudflare path even when signed in. */
57
63
  byo?: boolean;
58
64
  /** DI seam for tests — override the real HTTP PUT. */
@@ -83,6 +89,9 @@ export interface PublishOptions {
83
89
  provenance?: ShareProvenance;
84
90
  }
85
91
  export type ShareVisibility = 'public' | 'unlisted' | 'me' | 'org';
92
+ /** The visibility levels a share may carry — the Worker's own set, used to
93
+ * validate `--visibility` and `share visibility <target> <level>`. */
94
+ export declare const SHARE_VISIBILITY_LEVELS: readonly ShareVisibility[];
86
95
  export interface PublishResult {
87
96
  url: string;
88
97
  /** URL-safe object name, explicit (`--slug`) or deterministically derived. */
@@ -124,7 +133,7 @@ export interface ShareProvenance {
124
133
  * provenance the CLI sets automatically, plus `expires-at` / `visibility` /
125
134
  * `owner` which the Worker stamps itself.
126
135
  */
127
- export declare const RESERVED_META_KEYS: readonly ["expires-at", "visibility", "owner", "agent", "session", "host", "repo", "date", "label", "label-source"];
136
+ export declare const RESERVED_META_KEYS: readonly ["expires-at", "published-at", "visibility", "owner", "org_domain", "agent", "session", "host", "repo", "date", "avatar", "label", "label-source"];
128
137
  /**
129
138
  * Auto-capture publish provenance from the exec env, git, and the local clock.
130
139
  * Every field is present only when the environment genuinely carries it — a
@@ -139,6 +148,21 @@ export declare function resolveShareProvenance(opts?: {
139
148
  dir?: string;
140
149
  now?: Date;
141
150
  }): ShareProvenance;
151
+ /**
152
+ * The sharer's avatar URL, stamped so the share bar can show a real profile
153
+ * picture instead of only the initials circle. We key a Gravatar on the SHA-256
154
+ * of the signed-in user's lowercased email (Gravatar resolves either MD5 or
155
+ * SHA-256), with `d=404` so Gravatar returns 404 for a user who has none — the
156
+ * bar's `<img>` onerror then falls back to the initials circle. Only the hash
157
+ * lands in public metadata, never the raw email. Returns '' when signed out
158
+ * (BYO without a Phoenix session), leaving the bar on the initials circle.
159
+ *
160
+ * `opts.session === null` means "explicitly signed out" (a test seam / BYO) and
161
+ * yields ''; `undefined` reads the real persisted session.
162
+ */
163
+ export declare function resolveShareAvatar(opts?: {
164
+ session?: import('../identity/client.js').PhoenixSession | null;
165
+ }): string;
142
166
  /**
143
167
  * Parse repeated `--meta key=value` CLI args into a validated metadata record.
144
168
  * Keys are lowercase `[a-z0-9-]`, up to 64 characters, and may not collide with
@@ -172,12 +196,36 @@ export declare function sanitizeLabel(text: string): string;
172
196
  * The transliterations above cover what actually shows up; anything else outside
173
197
  * latin1 is dropped, and a value that transliterates to nothing at all (a title
174
198
  * written entirely in a non-latin script) degrades to a marker rather than an
175
- * empty header. Lossy on purpose: carrying full Unicode needs percent-encoding
176
- * here AND a matching decode in the Worker, which every already-deployed Worker
177
- * would render as `%E2%80%A6` until its operator ran `agents artifacts share
178
- * update` — tracked as RUSH-2786.
199
+ * empty header. This value is the **latin1-safe floor** every Worker can read: a
200
+ * pre-Unicode Worker only ever sees `x-share-<field>`, so it MUST stay folded.
201
+ * Full Unicode rides ALONGSIDE it in a percent-encoded companion header
202
+ * (`toPercentHeaderValue` / {@link needsUnicodeCompanion}), which a new Worker
203
+ * opts into via `x-share-encoding: percent` and an old one ignores — so a
204
+ * Japanese/emoji title renders in full on an updated Worker and still folds
205
+ * gracefully everywhere else (PHNX-2786).
179
206
  */
180
207
  export declare function toHeaderValue(text: string): string;
208
+ /**
209
+ * Whether text carries a code point above latin1 (U+00FF) — the meaningful
210
+ * display content `fetch`'s ByteString cannot hold, which {@link toHeaderValue}
211
+ * therefore transliterates or drops. This is the range worth carrying in the
212
+ * percent-encoded companion: an em dash, a curly quote, an emoji, or any
213
+ * CJK/Arabic/Hindi text. A plain accented latin1 name (`José`, é = U+00E9) is
214
+ * NOT lossy and needs no companion.
215
+ *
216
+ * `toHeaderValue` ALSO strips C0/C1 control characters (below U+0020, and
217
+ * U+007F–U+009F), which this deliberately does not flag: a raw ANSI/control
218
+ * sequence is not display text and must not be reconstructed into a page's
219
+ * rendered metadata, so it stays dropped on both the old and new Worker paths.
220
+ */
221
+ export declare function needsUnicodeCompanion(text: string): boolean;
222
+ /**
223
+ * Percent-encode a single-line free-text value for the `x-share-<field>-u`
224
+ * companion header. Whitespace is collapsed first (matching the folded value's
225
+ * single-line shape), then `encodeURIComponent` makes it pure-ASCII and
226
+ * header-safe. The Worker recovers the original with `decodeURIComponent`.
227
+ */
228
+ export declare function toPercentHeaderValue(text: string): string;
181
229
  /**
182
230
  * Best-effort human title when `--label` is omitted: the HTML `<title>`, else a
183
231
  * Markdown frontmatter `title:`, else the filename. Always returns something —
@@ -9,6 +9,8 @@ import { readFileSync } from 'node:fs';
9
9
  import { basename } from 'node:path';
10
10
  import { execFileSync } from 'node:child_process';
11
11
  import { hostname as osHostname } from 'node:os';
12
+ import { createHash } from 'node:crypto';
13
+ import { readSession } from '../identity/client.js';
12
14
  import { readShareConfig } from './config.js';
13
15
  import { resolveGitHubUsername } from '../git.js';
14
16
  import { resolveShareBackend, sanitizeShareNamespace } from './backend.js';
@@ -16,6 +18,9 @@ import { captureCover, OG_WIDTH, OG_HEIGHT, OG_SCALE } from './capture.js';
16
18
  import { deriveMeta, injectOgMeta } from './og.js';
17
19
  import { injectAnalyticsBeacon } from './analytics.js';
18
20
  import { prepareShareHtml } from './html.js';
21
+ /** The visibility levels a share may carry — the Worker's own set, used to
22
+ * validate `--visibility` and `share visibility <target> <level>`. */
23
+ export const SHARE_VISIBILITY_LEVELS = ['public', 'unlisted', 'me', 'org'];
19
24
  /**
20
25
  * `--unlisted` / `{ unlisted: true }` map to `unlisted`; `--visibility me|org`
21
26
  * passes through; otherwise `visibility` (default public).
@@ -35,13 +40,16 @@ export function resolveShareVisibility(opts = {}) {
35
40
  */
36
41
  export const RESERVED_META_KEYS = [
37
42
  'expires-at',
43
+ 'published-at',
38
44
  'visibility',
39
45
  'owner',
46
+ 'org_domain',
40
47
  'agent',
41
48
  'session',
42
49
  'host',
43
50
  'repo',
44
51
  'date',
52
+ 'avatar',
45
53
  'label',
46
54
  'label-source',
47
55
  ];
@@ -64,6 +72,26 @@ export function resolveShareProvenance(opts = {}) {
64
72
  date: (opts.now ?? new Date()).toISOString().slice(0, 10),
65
73
  };
66
74
  }
75
+ /**
76
+ * The sharer's avatar URL, stamped so the share bar can show a real profile
77
+ * picture instead of only the initials circle. We key a Gravatar on the SHA-256
78
+ * of the signed-in user's lowercased email (Gravatar resolves either MD5 or
79
+ * SHA-256), with `d=404` so Gravatar returns 404 for a user who has none — the
80
+ * bar's `<img>` onerror then falls back to the initials circle. Only the hash
81
+ * lands in public metadata, never the raw email. Returns '' when signed out
82
+ * (BYO without a Phoenix session), leaving the bar on the initials circle.
83
+ *
84
+ * `opts.session === null` means "explicitly signed out" (a test seam / BYO) and
85
+ * yields ''; `undefined` reads the real persisted session.
86
+ */
87
+ export function resolveShareAvatar(opts = {}) {
88
+ const session = opts.session !== undefined ? opts.session : readSession();
89
+ const email = session?.email?.trim().toLowerCase();
90
+ if (!email)
91
+ return '';
92
+ const hash = createHash('sha256').update(email).digest('hex');
93
+ return `https://www.gravatar.com/avatar/${hash}?d=404&s=52`;
94
+ }
67
95
  /**
68
96
  * Parse repeated `--meta key=value` CLI args into a validated metadata record.
69
97
  * Keys are lowercase `[a-z0-9-]`, up to 64 characters, and may not collide with
@@ -80,12 +108,12 @@ export function parseMetaEntries(pairs) {
80
108
  }
81
109
  const key = pair.slice(0, eq).trim();
82
110
  const value = pair.slice(eq + 1);
83
- if (!META_KEY_RE.test(key)) {
84
- throw new Error(`Bad --meta key '${key}'. Keys are lowercase letters, digits, and hyphens, up to 64 characters.`);
85
- }
86
111
  if (RESERVED_META_KEYS.includes(key)) {
87
112
  throw new Error(`--meta ${key}=… is reserved (Worker-stamped, or set automatically from your session/git) — pass a different key.`);
88
113
  }
114
+ if (!META_KEY_RE.test(key)) {
115
+ throw new Error(`Bad --meta key '${key}'. Keys are lowercase letters, digits, and hyphens, up to 64 characters.`);
116
+ }
89
117
  meta[key] = value;
90
118
  }
91
119
  return meta;
@@ -142,10 +170,13 @@ const HEADER_TRANSLITERATIONS = [
142
170
  * The transliterations above cover what actually shows up; anything else outside
143
171
  * latin1 is dropped, and a value that transliterates to nothing at all (a title
144
172
  * written entirely in a non-latin script) degrades to a marker rather than an
145
- * empty header. Lossy on purpose: carrying full Unicode needs percent-encoding
146
- * here AND a matching decode in the Worker, which every already-deployed Worker
147
- * would render as `%E2%80%A6` until its operator ran `agents artifacts share
148
- * update` — tracked as RUSH-2786.
173
+ * empty header. This value is the **latin1-safe floor** every Worker can read: a
174
+ * pre-Unicode Worker only ever sees `x-share-<field>`, so it MUST stay folded.
175
+ * Full Unicode rides ALONGSIDE it in a percent-encoded companion header
176
+ * (`toPercentHeaderValue` / {@link needsUnicodeCompanion}), which a new Worker
177
+ * opts into via `x-share-encoding: percent` and an old one ignores — so a
178
+ * Japanese/emoji title renders in full on an updated Worker and still folds
179
+ * gracefully everywhere else (PHNX-2786).
149
180
  */
150
181
  export function toHeaderValue(text) {
151
182
  let safe = text;
@@ -159,6 +190,31 @@ export function toHeaderValue(text) {
159
190
  return safe;
160
191
  return text.trim() ? '(unnamed)' : '';
161
192
  }
193
+ /**
194
+ * Whether text carries a code point above latin1 (U+00FF) — the meaningful
195
+ * display content `fetch`'s ByteString cannot hold, which {@link toHeaderValue}
196
+ * therefore transliterates or drops. This is the range worth carrying in the
197
+ * percent-encoded companion: an em dash, a curly quote, an emoji, or any
198
+ * CJK/Arabic/Hindi text. A plain accented latin1 name (`José`, é = U+00E9) is
199
+ * NOT lossy and needs no companion.
200
+ *
201
+ * `toHeaderValue` ALSO strips C0/C1 control characters (below U+0020, and
202
+ * U+007F–U+009F), which this deliberately does not flag: a raw ANSI/control
203
+ * sequence is not display text and must not be reconstructed into a page's
204
+ * rendered metadata, so it stays dropped on both the old and new Worker paths.
205
+ */
206
+ export function needsUnicodeCompanion(text) {
207
+ return /[^\u0000-\u00ff]/.test(text);
208
+ }
209
+ /**
210
+ * Percent-encode a single-line free-text value for the `x-share-<field>-u`
211
+ * companion header. Whitespace is collapsed first (matching the folded value's
212
+ * single-line shape), then `encodeURIComponent` makes it pure-ASCII and
213
+ * header-safe. The Worker recovers the original with `decodeURIComponent`.
214
+ */
215
+ export function toPercentHeaderValue(text) {
216
+ return encodeURIComponent(text.replace(/\s+/g, ' ').trim());
217
+ }
162
218
  /**
163
219
  * Best-effort human title when `--label` is omitted: the HTML `<title>`, else a
164
220
  * Markdown frontmatter `title:`, else the filename. Always returns something —
@@ -462,6 +518,7 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
462
518
  const unlisted = visibility === 'unlisted';
463
519
  const pageUrl = `${endpoint.baseUrl.replace(/\/+$/, '')}/${key}`;
464
520
  const provenance = opts.provenance ?? resolveShareProvenance();
521
+ const avatarUrl = opts.avatar ?? resolveShareAvatar({ session: opts.session });
465
522
  const meta = opts.meta ?? {};
466
523
  const put = opts.uploader ??
467
524
  (async (u, b, h) => {
@@ -511,35 +568,60 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
511
568
  metadataPreview.repo = provenance.repo;
512
569
  if (provenance.date)
513
570
  metadataPreview.date = provenance.date;
571
+ if (avatarUrl)
572
+ metadataPreview.avatar = avatarUrl;
514
573
  assertMetadataSize(metadataPreview);
515
574
  const authHeaders = (contentType) => {
516
575
  const h = { authorization: `Bearer ${endpoint.token}`, 'content-type': contentType };
517
576
  if (expiresAt)
518
577
  h['x-share-expires-at'] = expiresAt;
519
578
  h['x-share-visibility'] = visibility;
520
- // Every free-text header goes through toHeaderValue: a non-latin1 code point
521
- // anywhere in a label, a repo name, or a --meta value throws inside fetch and
522
- // crashes the publish outright.
579
+ // Two headers per free-text field, backward-compatible by construction
580
+ // (PHNX-2786): `x-share-<field>` always carries the latin1-safe folded value
581
+ // an already-deployed Worker reads verbatim, and — only when the fold is lossy
582
+ // (a curly quote, em dash, emoji, CJK/Arabic/Hindi) — a percent-encoded
583
+ // `x-share-<field>-u` companion carries the full Unicode. A new Worker opts
584
+ // into the companions via `x-share-encoding: percent`; an old one ignores the
585
+ // unknown headers and keeps folding gracefully. The floor also keeps the
586
+ // ByteString crash fixed: a non-latin1 code point never reaches a raw header.
587
+ let unicodeCompanion = false;
588
+ const setText = (name, value) => {
589
+ h[name] = toHeaderValue(value);
590
+ if (needsUnicodeCompanion(value)) {
591
+ h[`${name}-u`] = toPercentHeaderValue(value);
592
+ unicodeCompanion = true;
593
+ }
594
+ };
523
595
  if (provenance.agent)
524
- h['x-share-agent'] = toHeaderValue(provenance.agent);
596
+ setText('x-share-agent', provenance.agent);
525
597
  if (provenance.session)
526
- h['x-share-session'] = toHeaderValue(provenance.session);
598
+ setText('x-share-session', provenance.session);
527
599
  if (provenance.host)
528
- h['x-share-host'] = toHeaderValue(provenance.host);
600
+ setText('x-share-host', provenance.host);
529
601
  if (provenance.repo)
530
- h['x-share-repo'] = toHeaderValue(provenance.repo);
602
+ setText('x-share-repo', provenance.repo);
531
603
  if (provenance.date)
532
- h['x-share-date'] = toHeaderValue(provenance.date);
533
- h['x-share-label'] = toHeaderValue(label);
604
+ setText('x-share-date', provenance.date);
605
+ if (avatarUrl)
606
+ setText('x-share-avatar', avatarUrl);
607
+ setText('x-share-label', label);
534
608
  h['x-share-label-source'] = labelSource;
535
609
  // Per VALUE, before JSON.stringify — folding the serialized form would rewrite
536
610
  // a curly quote inside a value into a bare `"`, which is structural in JSON and
537
611
  // makes the Worker's JSON.parse throw. It swallows that error, so every --meta
538
- // key would silently vanish on a 200.
612
+ // key would silently vanish on a 200. The companion carries the whole raw meta
613
+ // object percent-encoded once, so a new Worker recovers full-Unicode keys AND
614
+ // values in one JSON.parse rather than per-field.
539
615
  if (Object.keys(meta).length > 0) {
540
616
  const headerMeta = Object.fromEntries(Object.entries(meta).map(([k, v]) => [toHeaderValue(k), toHeaderValue(v)]));
541
617
  h['x-share-meta'] = JSON.stringify(headerMeta);
618
+ if (Object.entries(meta).some(([k, v]) => needsUnicodeCompanion(k) || needsUnicodeCompanion(v))) {
619
+ h['x-share-meta-u'] = encodeURIComponent(JSON.stringify(meta));
620
+ unicodeCompanion = true;
621
+ }
542
622
  }
623
+ if (unicodeCompanion)
624
+ h['x-share-encoding'] = 'percent';
543
625
  if (opts.noRevision)
544
626
  h['x-share-no-revision'] = '1';
545
627
  return h;