@phnx-labs/agents-cli 1.22.52 → 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 (180) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/README.md +42 -9
  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 +220 -174
  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 +26 -17
  16. package/dist/commands/fleet-capture.js +7 -0
  17. package/dist/commands/focus.d.ts +1 -0
  18. package/dist/commands/focus.js +4 -2
  19. package/dist/commands/go.d.ts +5 -4
  20. package/dist/commands/go.js +8 -7
  21. package/dist/commands/insights.js +9 -0
  22. package/dist/commands/monitors.js +85 -30
  23. package/dist/commands/output.js +8 -2
  24. package/dist/commands/repo.js +18 -0
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions-inject.js +8 -3
  27. package/dist/commands/sessions-picker.js +2 -1
  28. package/dist/commands/sessions.d.ts +20 -12
  29. package/dist/commands/sessions.js +94 -40
  30. package/dist/commands/setup-accounts.d.ts +8 -0
  31. package/dist/commands/setup-accounts.js +47 -0
  32. package/dist/commands/setup.d.ts +1 -1
  33. package/dist/commands/setup.js +11 -2
  34. package/dist/commands/share.d.ts +52 -3
  35. package/dist/commands/share.js +262 -18
  36. package/dist/commands/ssh.d.ts +7 -0
  37. package/dist/commands/ssh.js +53 -14
  38. package/dist/commands/status.js +14 -0
  39. package/dist/commands/sync.js +44 -0
  40. package/dist/commands/view.d.ts +3 -1
  41. package/dist/commands/view.js +5 -4
  42. package/dist/lib/account-registry.d.ts +15 -5
  43. package/dist/lib/account-registry.js +165 -53
  44. package/dist/lib/accounting/rotate.d.ts +20 -6
  45. package/dist/lib/accounting/rotate.js +38 -7
  46. package/dist/lib/accounting/usage.d.ts +37 -1
  47. package/dist/lib/accounting/usage.js +71 -6
  48. package/dist/lib/agent-spec/agents.d.ts +5 -2
  49. package/dist/lib/agent-spec/agents.js +25 -7
  50. package/dist/lib/analytics/mix-commands.js +12 -6
  51. package/dist/lib/answer-router.js +2 -1
  52. package/dist/lib/auth-mint.d.ts +150 -0
  53. package/dist/lib/auth-mint.js +434 -0
  54. package/dist/lib/browser/profiles.d.ts +18 -0
  55. package/dist/lib/browser/profiles.js +26 -1
  56. package/dist/lib/browser/registry.d.ts +44 -14
  57. package/dist/lib/browser/registry.js +141 -45
  58. package/dist/lib/browser/remote-control.d.ts +9 -7
  59. package/dist/lib/browser/remote-control.js +9 -7
  60. package/dist/lib/claude-account-token.d.ts +10 -0
  61. package/dist/lib/claude-account-token.js +14 -4
  62. package/dist/lib/config-drift.d.ts +37 -0
  63. package/dist/lib/config-drift.js +72 -0
  64. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  65. package/dist/lib/daemon/auth-sync-service.js +34 -0
  66. package/dist/lib/daemon/daemon.js +30 -4
  67. package/dist/lib/daemon/runner.js +10 -2
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/device-config.d.ts +3 -3
  71. package/dist/lib/device-config.js +8 -7
  72. package/dist/lib/devices/config-migration.js +147 -1
  73. package/dist/lib/devices/connect.d.ts +26 -0
  74. package/dist/lib/devices/connect.js +48 -1
  75. package/dist/lib/devices/device-docs.d.ts +35 -0
  76. package/dist/lib/devices/device-docs.js +163 -0
  77. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  78. package/dist/lib/devices/discovery-policy.js +31 -21
  79. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  80. package/dist/lib/devices/doctor-findings.js +19 -1
  81. package/dist/lib/devices/registry.d.ts +11 -5
  82. package/dist/lib/devices/registry.js +46 -18
  83. package/dist/lib/exec.d.ts +88 -30
  84. package/dist/lib/exec.js +138 -34
  85. package/dist/lib/feed/feed.d.ts +10 -2
  86. package/dist/lib/feed/feed.js +35 -2
  87. package/dist/lib/feed-broadcast.js +1 -1
  88. package/dist/lib/fleet/apply.d.ts +11 -0
  89. package/dist/lib/fleet/apply.js +23 -3
  90. package/dist/lib/fleet/auth-sync.js +5 -3
  91. package/dist/lib/help.d.ts +9 -0
  92. package/dist/lib/help.js +29 -1
  93. package/dist/lib/hosts/dispatch.d.ts +4 -3
  94. package/dist/lib/hosts/dispatch.js +12 -8
  95. package/dist/lib/hosts/passthrough.d.ts +1 -10
  96. package/dist/lib/hosts/passthrough.js +1 -13
  97. package/dist/lib/hosts/providers/local.d.ts +9 -3
  98. package/dist/lib/hosts/providers/local.js +23 -12
  99. package/dist/lib/hosts/reconnect.d.ts +7 -4
  100. package/dist/lib/hosts/reconnect.js +29 -25
  101. package/dist/lib/hosts/registry.js +4 -1
  102. package/dist/lib/hosts/remote-os.js +3 -1
  103. package/dist/lib/installations/versions.js +9 -1
  104. package/dist/lib/linux-userns.d.ts +58 -0
  105. package/dist/lib/linux-userns.js +116 -0
  106. package/dist/lib/memory.d.ts +26 -0
  107. package/dist/lib/memory.js +80 -1
  108. package/dist/lib/monitors/config.d.ts +11 -0
  109. package/dist/lib/monitors/config.js +8 -0
  110. package/dist/lib/monitors/engine.js +8 -1
  111. package/dist/lib/monitors/state.d.ts +37 -1
  112. package/dist/lib/monitors/state.js +79 -4
  113. package/dist/lib/permissions-registry.d.ts +2 -0
  114. package/dist/lib/permissions-registry.js +116 -14
  115. package/dist/lib/permissions.d.ts +5 -3
  116. package/dist/lib/permissions.js +25 -27
  117. package/dist/lib/profiles.d.ts +8 -7
  118. package/dist/lib/profiles.js +12 -0
  119. package/dist/lib/project-key.d.ts +9 -0
  120. package/dist/lib/project-key.js +11 -0
  121. package/dist/lib/secrets/bundles.d.ts +35 -0
  122. package/dist/lib/secrets/bundles.js +78 -1
  123. package/dist/lib/secrets/push.d.ts +3 -8
  124. package/dist/lib/secrets/push.js +18 -14
  125. package/dist/lib/secrets/remote.d.ts +9 -18
  126. package/dist/lib/secrets/remote.js +11 -26
  127. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  128. package/dist/lib/secrets/reserved-sync.js +129 -0
  129. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  130. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  131. package/dist/lib/self-heal/registry.js +4 -0
  132. package/dist/lib/self-heal/types.d.ts +1 -1
  133. package/dist/lib/session/active.d.ts +10 -1
  134. package/dist/lib/session/active.js +8 -5
  135. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  136. package/dist/lib/session/actor-sidecar.js +2 -0
  137. package/dist/lib/session/db.d.ts +39 -4
  138. package/dist/lib/session/db.js +168 -31
  139. package/dist/lib/session/discover.d.ts +32 -4
  140. package/dist/lib/session/discover.js +126 -37
  141. package/dist/lib/session/insights.d.ts +14 -0
  142. package/dist/lib/session/insights.js +25 -2
  143. package/dist/lib/session/linear.js +1 -1
  144. package/dist/lib/session/live-metadata.js +1 -0
  145. package/dist/lib/session/pid-registry.d.ts +7 -0
  146. package/dist/lib/session/prompt.d.ts +15 -0
  147. package/dist/lib/session/prompt.js +21 -0
  148. package/dist/lib/session/shell-programs.d.ts +17 -0
  149. package/dist/lib/session/shell-programs.js +21 -0
  150. package/dist/lib/session/state.js +2 -1
  151. package/dist/lib/session/stream-render.js +2 -1
  152. package/dist/lib/session/tool-calls.js +2 -5
  153. package/dist/lib/session/trajectory-html.js +2 -1
  154. package/dist/lib/session/trajectory.js +3 -12
  155. package/dist/lib/session/types.d.ts +25 -0
  156. package/dist/lib/session/types.js +10 -0
  157. package/dist/lib/share/publish.d.ts +53 -5
  158. package/dist/lib/share/publish.js +99 -17
  159. package/dist/lib/share/worker-template.js +594 -64
  160. package/dist/lib/startup/root-command.js +2 -1
  161. package/dist/lib/state.d.ts +24 -0
  162. package/dist/lib/state.js +318 -54
  163. package/dist/lib/sync-status.d.ts +4 -0
  164. package/dist/lib/sync-status.js +3 -0
  165. package/dist/lib/terminal/resolve.d.ts +7 -0
  166. package/dist/lib/terminal/resolve.js +41 -2
  167. package/dist/lib/traces/classify.js +24 -19
  168. package/dist/lib/traces/insights.d.ts +67 -0
  169. package/dist/lib/traces/insights.js +178 -0
  170. package/dist/lib/traces/phenotype.d.ts +67 -0
  171. package/dist/lib/traces/phenotype.js +437 -0
  172. package/dist/lib/traces/segments.d.ts +133 -0
  173. package/dist/lib/traces/segments.js +301 -0
  174. package/dist/lib/traces/sync.d.ts +33 -0
  175. package/dist/lib/traces/sync.js +11 -2
  176. package/dist/lib/types.d.ts +47 -1
  177. package/dist/lib/usage-refresh.js +2 -1
  178. package/dist/lib/view-types.d.ts +2 -0
  179. package/dist/lib/watchdog/runner.js +18 -4
  180. package/package.json +2 -1
@@ -7,11 +7,11 @@
7
7
  // (commands/artifacts-setup.ts), which calls `runShareProvision` below.
8
8
  import { existsSync } from 'node:fs';
9
9
  import { formatBytes } from '../lib/format.js';
10
- import { Option } from 'commander';
10
+ import { Argument, Option } from 'commander';
11
11
  import chalk from 'chalk';
12
12
  import { DEFAULT_BUCKET_NAME, DEFAULT_CF_BUNDLE, DEFAULT_SHARE_DOMAIN, DEFAULT_WORKER_NAME, generateWriteToken, readCloudflareCreds, readShareConfig, readWriteToken, readWriteTokenEnv, readWriteTokenFromBundle, storeWriteToken, writeShareConfig, } from '../lib/share/config.js';
13
13
  import { addCustomDomain, configureBucketLifecycle, createBucket, deployWorker, enableWorkersDev, findZoneId, hashWorkerScript, putWorkerSecret, updateWorker, WORKER_PHOENIX_ID_BASE_SECRET, setWorkerSecret, } from '../lib/share/provision.js';
14
- import { publishFile, resolveShareUsername, parseMetaEntries, resolveShareVisibility, } from '../lib/share/publish.js';
14
+ import { publishFile, resolveShareUsername, parseMetaEntries, sanitizeLabel, resolveShareVisibility, scanShareContent, formatSensitiveContentError, SHARE_VISIBILITY_LEVELS, } from '../lib/share/publish.js';
15
15
  import { deleteShare, resolveDeleteTarget } from '../lib/share/delete.js';
16
16
  import { renderWorkerScript } from '../lib/share/worker-template.js';
17
17
  import { analyticsEnabled } from '../lib/share/analytics.js';
@@ -55,14 +55,63 @@ export function shareTemplateStatus(cfg) {
55
55
  return 'unknown';
56
56
  return cfg.templateHash === hashWorkerScript(renderWorkerScript()) ? 'current' : 'outdated';
57
57
  }
58
+ export async function runShareEdit(target, opts) {
59
+ // Same public-listing gate as publish: label + every metadata value are
60
+ // world-readable in the gallery and `share list --list-json`.
61
+ if (opts.force !== true) {
62
+ const hits = [
63
+ ...(typeof opts.label === 'string' ? scanShareContent(opts.label) : []),
64
+ ...Object.values(opts.meta ?? {}).flatMap((value) => scanShareContent(value)),
65
+ ];
66
+ if (hits.length > 0)
67
+ throw new Error(formatSensitiveContentError(hits));
68
+ }
69
+ const backend = resolveShareBackend({ githubUser: opts.githubUser, config: opts.config, writeToken: opts.writeToken, byo: opts.byo, session: opts.session });
70
+ // me/org are Phoenix-only — surface a crisp login hint here rather than the
71
+ // Worker's generic 400 for a signed-out caller (BYO WRITE_TOKEN can only set
72
+ // public/unlisted).
73
+ if ((opts.visibility === 'me' || opts.visibility === 'org') && backend.kind !== 'managed') {
74
+ throw new Error(`Changing visibility to '${opts.visibility}' requires a Phoenix session. Run 'agents auth login'.`);
75
+ }
76
+ const { key } = await resolveDeleteTarget(target, { githubUser: backend.kind === 'managed' ? backend.namespace : opts.githubUser || backend.namespace });
77
+ const res = await (opts.fetchEdit ?? fetch)(`${backend.baseUrl.replace(/\/+$/, '')}/${key}`, {
78
+ method: 'PATCH',
79
+ headers: { authorization: `Bearer ${backend.token}`, 'content-type': 'application/json' },
80
+ body: JSON.stringify({
81
+ ...(opts.label !== undefined ? { label: opts.label } : {}),
82
+ ...(opts.visibility !== undefined ? { visibility: opts.visibility } : {}),
83
+ meta: opts.meta ?? {}, metaMode: opts.metaMode ?? 'merge', removeMeta: opts.removeMeta ?? [],
84
+ }),
85
+ });
86
+ const body = await res.text();
87
+ if (!res.ok) {
88
+ let detail = body;
89
+ try {
90
+ detail = String(JSON.parse(body).error ?? body);
91
+ }
92
+ catch { /* keep body */ }
93
+ throw new Error(`Share metadata edit failed (${res.status}): ${detail || 'unknown error'}`);
94
+ }
95
+ const result = JSON.parse(body);
96
+ // A Worker template that predates the visibility edit ignores the `visibility`
97
+ // field and 200s without echoing it back — which would otherwise report a
98
+ // silent success while the page's visibility never changed. Fail loud with the
99
+ // update path instead (visibility rode in on RUSH-3135's metadata-edit route).
100
+ if (opts.visibility !== undefined && result.visibility === undefined) {
101
+ throw new Error("This share endpoint's Worker doesn't support in-place visibility changes yet — it predates them. " +
102
+ "Run 'agents artifacts share update' to deploy the current Worker template, then retry " +
103
+ "('agents artifacts share status' shows whether an update is due).");
104
+ }
105
+ return result;
106
+ }
58
107
  /** Shown whenever the deployed Worker has no `?format=json` listing route — an
59
108
  * endpoint provisioned before this feature. Points at the RUSH-2449 update path
60
109
  * (`agents artifacts share update`) instead of letting the caller hit a 404 or an
61
110
  * HTML body and get a confusing parse error. */
62
111
  const OUTDATED_TEMPLATE_HINT = 'Your deployed share Worker has no machine-readable listing route — it predates `agents artifacts share list`. ' +
63
112
  'Run `agents artifacts share update` to deploy the current Worker template, then retry (`agents artifacts share status` shows whether an update is due).';
64
- async function defaultListingFetch(url) {
65
- const res = await fetch(url, { headers: { accept: 'application/json' } });
113
+ async function defaultListingFetch(url, headers) {
114
+ const res = await fetch(url, { headers: { accept: 'application/json', ...(headers || {}) } });
66
115
  return { status: res.status, contentType: res.headers.get('content-type') ?? '', body: await res.text() };
67
116
  }
68
117
  /** Normalize the Worker's `meta` field (arbitrary `--meta key=value` entries,
@@ -108,6 +157,9 @@ export function parseShareListing(user, body) {
108
157
  host: item.host == null ? null : String(item.host),
109
158
  repo: item.repo == null ? null : String(item.repo),
110
159
  revisionCount: typeof item.revisionCount === 'number' ? item.revisionCount : 0,
160
+ visibility: item.visibility === 'unlisted' || item.visibility === 'me' || item.visibility === 'org'
161
+ ? item.visibility
162
+ : 'public',
111
163
  meta: parseMetaField(item.meta),
112
164
  };
113
165
  });
@@ -148,9 +200,21 @@ export async function runShareList(opts = {}) {
148
200
  }
149
201
  }
150
202
  const user = await namespaceForBackend(backend, opts.githubUser);
151
- const listUrl = `${backend.baseUrl.replace(/\/+$/, '')}/${encodeURIComponent(user)}?format=json`;
203
+ const scope = opts.scope ?? 'public';
204
+ const includeHidden = scope !== 'public';
205
+ const query = new URLSearchParams({ format: 'json' });
206
+ if (includeHidden)
207
+ query.set('scope', 'mine');
208
+ const listUrl = `${backend.baseUrl.replace(/\/+$/, '')}/${encodeURIComponent(user)}?${query.toString()}`;
209
+ const listingHeaders = {};
210
+ if (includeHidden) {
211
+ if (!backend.token) {
212
+ throw new Error(`Listing ${scope} shares requires owner authentication. Run 'agents auth login' for the managed endpoint, or ensure SHARE_WRITE_TOKEN / 'agents artifacts share join' is configured for BYO.`);
213
+ }
214
+ listingHeaders.authorization = `Bearer ${backend.token}`;
215
+ }
152
216
  const fetchListing = opts.fetchListing ?? defaultListingFetch;
153
- const res = await fetchListing(listUrl);
217
+ const res = await fetchListing(listUrl, listingHeaders);
154
218
  if (res.status === 404) {
155
219
  // A single-segment path 404s either because the namespace is genuinely empty
156
220
  // (current template — the listing route gates on the namespace holding objects,
@@ -177,14 +241,23 @@ export async function runShareList(opts = {}) {
177
241
  throw new Error(OUTDATED_TEMPLATE_HINT);
178
242
  }
179
243
  const parsed = parseShareListing(user, res.body);
180
- return applyShareListFilters(parsed, opts);
244
+ return applyShareListFilters(parsed, {
245
+ agent: opts.agent,
246
+ session: opts.session,
247
+ label: opts.label,
248
+ meta: opts.meta,
249
+ scope,
250
+ });
181
251
  }
182
252
  /** Client-side filtering over an already-fetched listing (RUSH-2683) — the
183
253
  * Worker has no query surface for this, so it narrows the fetched set instead
184
254
  * of a second round trip. `count` reflects the FILTERED set, matching what the
185
255
  * caller actually sees. */
186
- function applyShareListFilters(result, filters) {
256
+ export function applyShareListFilters(result, filters) {
187
257
  let objects = result.objects;
258
+ if (filters.scope && filters.scope !== 'all') {
259
+ objects = objects.filter((o) => o.visibility === filters.scope);
260
+ }
188
261
  if (filters.agent) {
189
262
  const needle = filters.agent.toLowerCase();
190
263
  objects = objects.filter((o) => (o.agent ?? '').toLowerCase() === needle);
@@ -196,6 +269,9 @@ function applyShareListFilters(result, filters) {
196
269
  const needle = filters.label.toLowerCase();
197
270
  objects = objects.filter((o) => (o.label ?? '').toLowerCase().includes(needle));
198
271
  }
272
+ if (filters.meta) {
273
+ objects = objects.filter((o) => Object.entries(filters.meta).every(([key, value]) => o.meta[key] === value));
274
+ }
199
275
  return { user: result.user, count: objects.length, objects };
200
276
  }
201
277
  /** Human-readable bytes, e.g. `1.2 KB`, `640 B`. */
@@ -219,7 +295,9 @@ export function formatShareList(result, json = false) {
219
295
  chalk.dim(` ${result.count} published ${result.count === 1 ? 'page' : 'pages'}`);
220
296
  const rows = result.objects.map((o) => {
221
297
  const when = o.publishedAt ? o.publishedAt.slice(0, 10) : 'unknown';
222
- const bits = [when, formatBytes(o.size)];
298
+ const visibility = o.visibility ?? 'public';
299
+ const visibilityBit = visibility === 'public' ? chalk.green('public') : chalk.yellow(visibility);
300
+ const bits = [visibilityBit, when, formatBytes(o.size)];
223
301
  if (o.agent)
224
302
  bits.push(o.agent);
225
303
  if (o.revisionCount > 0)
@@ -341,6 +419,18 @@ export function formatShareDeleteResult(result, json = false) {
341
419
  }
342
420
  return lines.join('\n');
343
421
  }
422
+ /** Human summary of an in-place visibility change. */
423
+ function formatShareVisibilityResult(result, requested) {
424
+ const now = result.visibility ?? requested;
425
+ const from = result.previousVisibility && result.previousVisibility !== now
426
+ ? `${chalk.dim(result.previousVisibility)} → `
427
+ : '';
428
+ return [
429
+ `${chalk.green('✓')} visibility ${from}${chalk.bold(now)}`,
430
+ chalk.dim(` ${result.url}`),
431
+ chalk.dim(' body unchanged — no revision created'),
432
+ ].join('\n');
433
+ }
344
434
  /** Shared handler for `agents artifacts share delete <targets...>` and the
345
435
  * nested `agents artifacts unshare <targets...>` alias. Deletes each target independently and
346
436
  * continues past a failed one (rm-style), reporting all results and exiting
@@ -426,6 +516,36 @@ const SHARE_DELETE_NOTES = `
426
516
 
427
517
  agents artifacts share delete === agents artifacts unshare (same command, different name).
428
518
  `;
519
+ const SHARE_VISIBILITY_EXAMPLES = `
520
+ # Take a public page private to just you (Phoenix session required for me/org)
521
+ agents artifacts share visibility https://share.agents-cli.sh/octocat/q3-plan me
522
+
523
+ # Make it public again — by <user>/<slug> or a bare slug in your namespace
524
+ agents artifacts share visibility octocat/q3-plan public
525
+ agents artifacts share visibility q3-plan unlisted
526
+
527
+ # Share with your Phoenix org (rejected on a public-inbox email domain)
528
+ agents artifacts share visibility q3-plan org
529
+
530
+ # Machine-readable result
531
+ agents artifacts share visibility q3-plan me --visibility-json
532
+ `;
533
+ const SHARE_VISIBILITY_NOTES = `
534
+ Changes an ALREADY-published page's visibility in place: the slug — and so the
535
+ URL — is preserved. It re-stamps only the visibility on the stored object; the
536
+ body, provenance, label, and --meta are untouched, so — like 'agents artifacts
537
+ share edit' — this is a metadata-only rewrite and creates no revision.
538
+
539
+ public is listed in the gallery; unlisted is a capability URL (GET still 200,
540
+ X-Robots-Tag: noindex, hidden from gallery/list); me is visible only to the
541
+ signed-in owner; org is visible to members of the same Phoenix organization.
542
+ me and org require a Phoenix session — run 'agents auth login' first — and org
543
+ is refused on a public-inbox email domain (gmail.com, outlook.com, …).
544
+
545
+ This hits the live endpoint via the owner's Phoenix session (or the BYO
546
+ WRITE_TOKEN for public/unlisted). It does not re-run the pre-publish
547
+ sensitive-content scan — the body is unchanged.
548
+ `;
429
549
  /**
430
550
  * Register the `share` subtree under its parent group — `agents artifacts share`
431
551
  * (see commands/artifacts.ts). `agents artifacts unshare` is the nested alias
@@ -436,6 +556,10 @@ const SHARE_DELETE_NOTES = `
436
556
  export function registerShareCommands(artifactsCmd) {
437
557
  const shareCmd = artifactsCmd
438
558
  .command('share')
559
+ // Child task commands intentionally reuse publish vocabulary (`edit --label`,
560
+ // `list --meta`). Keep option ownership positional within this subtree so
561
+ // Commander does not silently resolve those flags to the parent publisher.
562
+ .enablePositionalOptions()
439
563
  .description('Publish an HTML file to a shareable link — managed if signed in, otherwise your Cloudflare R2.')
440
564
  .argument('[file]', 'file to publish (HTML or any static asset)')
441
565
  .option('--slug <slug>', 'URL slug override (default: stable slug of the artifact title, then filename)')
@@ -553,6 +677,43 @@ ${SHARE_DELETE_NOTES}
553
677
  shareDeleteCmd.action(async (targets, opts) => {
554
678
  await runShareDelete(targets, opts);
555
679
  });
680
+ // `agents artifacts share visibility <target> <level>` — change an
681
+ // already-published page's visibility in place. Kept in its own block; it
682
+ // reuses the delete target parser and the same PATCH metadata-edit route as
683
+ // `share edit` (visibility is metadata), so it re-stamps the stored object
684
+ // rather than re-publishing — a re-publish over the PUT path can't express an
685
+ // in-place change (GET injects the attribution bar at serve time and never
686
+ // strips it, and the served body carries no provenance/label/--meta back to
687
+ // re-send). --for-user / --visibility-json (not --github-user / --json): the
688
+ // parent `share <file>` already owns both long names, and commander resolves a
689
+ // child option's long name against the whole ancestor chain, silently dropping
690
+ // a same-named child (RUSH-2687) — the same rename the delete/list blocks make.
691
+ const shareVisibilityCmd = shareCmd
692
+ .command('visibility')
693
+ .description("Change an already-published page's visibility in place (public | unlisted | me | org). The slug/URL is preserved; the body is untouched, so no revision is created.")
694
+ .addArgument(new Argument('<target>', 'the published page: a full URL, <user>/<slug>, or a bare slug in your namespace'))
695
+ .addArgument(new Argument('<level>', 'new visibility').choices([...SHARE_VISIBILITY_LEVELS]))
696
+ .option('--for-user <user>', 'GitHub username for resolving a bare-slug target (default: resolved from gh/git config)')
697
+ .option('--visibility-json', 'emit a machine-readable result')
698
+ .action(async (target, level, opts) => {
699
+ try {
700
+ const visibility = level;
701
+ const result = await runShareEdit(target, { visibility, githubUser: opts.forUser });
702
+ if (opts.visibilityJson) {
703
+ console.log(JSON.stringify(result, null, 2));
704
+ return;
705
+ }
706
+ console.log(formatShareVisibilityResult(result, visibility));
707
+ }
708
+ catch (e) {
709
+ console.error(chalk.red(e.message));
710
+ process.exitCode = 1;
711
+ }
712
+ });
713
+ setHelpSections(shareVisibilityCmd, {
714
+ examples: SHARE_VISIBILITY_EXAMPLES,
715
+ notes: SHARE_VISIBILITY_NOTES,
716
+ });
556
717
  shareCmd
557
718
  .command('join')
558
719
  .description('Use an existing synced share endpoint and write token (no provisioning).')
@@ -664,20 +825,33 @@ ${SHARE_DELETE_NOTES}
664
825
  // share.test.ts). Matches the `revisions` precedent (--for-user/
665
826
  // --revisions-json) and the `delete`/`unshare` precedent (--for-user/
666
827
  // --delete-json) below.
828
+ //
829
+ // `--visibility` is also owned by the parent `share <file>` command (publish),
830
+ // so the listing filter is named `--scope` here; `--all` is the convenience
831
+ // alias for `--scope all`.
667
832
  .option('--for-user <user>', 'GitHub username whose namespace to list (default: resolved from gh/git config)')
833
+ .addOption(new Option('--scope <level>', 'visibility filter: public (default), unlisted, me, org, or all')
834
+ .choices(['public', 'unlisted', 'me', 'org', 'all'])
835
+ .default('public'))
836
+ .option('--all', "list every page including hidden unlisted/me/org shares (alias for --scope all)")
668
837
  .option('--agent <name>', 'filter to shares published by this agent/harness (case-insensitive)')
669
838
  .option('--session <id>', 'filter to shares published from this session id')
670
839
  // Named --label-contains, not --label: `share <file>` (the parent) already owns
671
840
  // `--label`/`--title`, same collision class as --for-user/--list-json above.
672
841
  .option('--label-contains <substr>', 'filter to shares whose label contains this text (case-insensitive)')
673
- .option('--list-json', 'emit the machine-readable listing (slug, url, size, contentType, publishedAt, expiresAt, label, agent, session, host, repo, revisionCount, meta)')
842
+ .option('--meta <key=value>', 'filter by an exact arbitrary metadata entry (repeatable)', (v, p) => [...p, v], [])
843
+ .option('--list-json', 'emit the machine-readable listing (slug, url, size, contentType, publishedAt, expiresAt, label, visibility, agent, session, host, repo, revisionCount, meta)')
674
844
  .action(async (opts) => {
675
845
  try {
846
+ const scope = opts.all ? 'all' : (opts.scope ?? 'public');
847
+ const parentMeta = shareCmd.opts().meta ?? [];
676
848
  const result = await runShareList({
677
849
  githubUser: opts.forUser,
850
+ scope,
678
851
  agent: opts.agent,
679
852
  session: opts.session,
680
853
  label: opts.labelContains,
854
+ meta: parseMetaEntries(opts.meta.length ? opts.meta : parentMeta),
681
855
  });
682
856
  console.log(formatShareList(result, Boolean(opts.listJson)));
683
857
  }
@@ -691,6 +865,11 @@ ${SHARE_DELETE_NOTES}
691
865
  # Everything you've published, newest first
692
866
  agents artifacts share list
693
867
 
868
+ # Include your hidden pages (unlisted / me / org) so you can see everything
869
+ agents artifacts share list --all
870
+ agents artifacts share list --scope me
871
+ agents artifacts share list --scope unlisted
872
+
694
873
  # Machine-readable — e.g. pull every still-public URL with jq
695
874
  agents artifacts share list --list-json | jq -r '.objects[].url'
696
875
 
@@ -700,18 +879,83 @@ ${SHARE_DELETE_NOTES}
700
879
  # Narrow by who/what published it
701
880
  agents artifacts share list --agent claude
702
881
  agents artifacts share list --label-contains "fleet plan"
882
+ agents artifacts share list --meta kind=plan --meta status=final
703
883
  `,
704
884
  notes: `
705
885
  Lists the ACTIVE pages in your namespace — expired links and the sibling .png OG
706
- covers are omitted (it mirrors the public gallery). Signed-in users list the
707
- managed endpoint (share.agents-cli.sh/<handle>); otherwise BYO. It reads the
708
- endpoint's JSON listing route, which ships with the current Worker template. If
709
- a BYO Worker predates this feature the command says so and points you at 'agents
710
- artifacts share update' (RUSH-2449) rather than returning a wrong or empty result
711
- — see 'agents artifacts share status' for whether an update is due.
886
+ covers are omitted. By default this mirrors the public gallery (public pages only).
887
+ Use --scope unlisted|me|org or --all to include your hidden pages; those scopes
888
+ send the owner's bearer and a 'scope=mine' hint to the Worker's JSON listing route,
889
+ which returns hidden pages ONLY after verifying the caller owns the namespace.
890
+ Signed-in users list the managed endpoint (share.agents-cli.sh/<handle>); otherwise
891
+ BYO. It reads the endpoint's JSON listing route, which ships with the current
892
+ Worker template. If a BYO Worker predates this feature the command says so and
893
+ points you at 'agents artifacts share update' (RUSH-2449) rather than returning a
894
+ wrong or empty result — see 'agents artifacts share status' for whether an update
895
+ is due.
896
+
897
+ --agent/--session/--label-contains/--meta filter the fetched listing client-side;
898
+ --list-json's count reflects the filtered set. Each human row shows the page's
899
+ visibility so public vs hidden is obvious at a glance.
900
+ `,
901
+ });
902
+ const shareEditCmd = shareCmd
903
+ .command('edit <target>')
904
+ .description('Edit a published share\'s label or arbitrary metadata without republishing its body.')
905
+ .option('--for-user <user>', 'namespace for resolving a bare slug (default: your namespace)')
906
+ .option('--label <text>', 'set or replace the display label')
907
+ .option('--remove-label', 'remove the display label')
908
+ .option('--meta <key=value>', 'merge a metadata entry (repeatable)', (v, p) => [...p, v], [])
909
+ .option('--replace-meta <key=value>', 'replace all arbitrary metadata with these entries (repeatable)', (v, p) => [...p, v], [])
910
+ .option('--remove-meta <key>', 'remove an arbitrary metadata key (repeatable)', (v, p) => [...p, v], [])
911
+ .option('--edit-json', 'emit the machine-readable edit result')
912
+ .action(async (target, opts) => {
913
+ try {
914
+ const parent = shareCmd.opts();
915
+ const label = opts.label ?? parent.label;
916
+ const mergeMeta = opts.meta.length ? opts.meta : (parent.meta ?? []);
917
+ if (label !== undefined && opts.removeLabel)
918
+ throw new Error('--label and --remove-label are mutually exclusive.');
919
+ if (mergeMeta.length && opts.replaceMeta.length)
920
+ throw new Error('--meta and --replace-meta are mutually exclusive.');
921
+ if (opts.replaceMeta.length && opts.removeMeta.length)
922
+ throw new Error('--replace-meta and --remove-meta are mutually exclusive.');
923
+ if (label === undefined && !opts.removeLabel && !mergeMeta.length && !opts.replaceMeta.length && !opts.removeMeta.length)
924
+ throw new Error('Nothing to edit. Pass --label, --remove-label, --meta, --replace-meta, or --remove-meta.');
925
+ const removeMeta = opts.removeMeta.map((key) => Object.keys(parseMetaEntries([`${key}=x`]))[0]);
926
+ const result = await runShareEdit(target, {
927
+ githubUser: opts.forUser,
928
+ label: opts.removeLabel ? null : label === undefined ? undefined : sanitizeLabel(label),
929
+ meta: parseMetaEntries(opts.replaceMeta.length ? opts.replaceMeta : mergeMeta),
930
+ metaMode: opts.replaceMeta.length ? 'replace' : 'merge',
931
+ removeMeta,
932
+ // Parent `share --force` (RUSH-2687: a child --force is silently dropped).
933
+ force: parent.force,
934
+ });
935
+ console.log(opts.editJson ? JSON.stringify(result, null, 2) : chalk.green(`updated ${result.url}`));
936
+ }
937
+ catch (e) {
938
+ console.error(chalk.red(e.message));
939
+ process.exitCode = 1;
940
+ }
941
+ });
942
+ setHelpSections(shareEditCmd, {
943
+ examples: `
944
+ # Change only the gallery/list title
945
+ agents artifacts share edit q3-plan --label "Q3 fleet plan"
712
946
 
713
- --agent/--session/--label-contains filter the fetched listing client-side;
714
- --list-json's count reflects the filtered set.
947
+ # Merge metadata, or deliberately replace/remove it
948
+ agents artifacts share edit q3-plan --meta status=final --meta ticket=PHNX-3278
949
+ agents artifacts share edit q3-plan --replace-meta kind=plan --replace-meta status=final
950
+ agents artifacts share edit q3-plan --remove-meta status
951
+ `,
952
+ notes: `
953
+ Metadata edits preserve the exact published body, content type/HTTP metadata,
954
+ publication time, visibility, expiry, provenance, cover, and retained revisions. --meta merges;
955
+ --replace-meta replaces all arbitrary metadata; --remove-meta deletes named keys.
956
+ Reserved Worker/provenance keys cannot be changed through metadata flags.
957
+ Edited labels and metadata values are scanned for emails and credential-shaped
958
+ strings the same way publish is — pass --force to write them anyway.
715
959
  `,
716
960
  });
717
961
  const shareRevisionsCmd = shareCmd
@@ -48,6 +48,13 @@ export declare function showLeasedBoxesSection(opts: {
48
48
  all?: boolean;
49
49
  stats?: boolean;
50
50
  }): boolean;
51
+ /**
52
+ * Remote argv for `agents ssh <slug>` into a leased crabbox. Crabbox ssh does
53
+ * not go through {@link buildSshInvocation}, so this stamps the same
54
+ * AGENTS_FLEET_REMOTE consent marker a registered-device ssh would (PHNX-3065).
55
+ * Exported so the branch is unit-testable without a live crabbox.
56
+ */
57
+ export declare function leasedBoxRemoteCmd(cmd: string[]): string[];
51
58
  /**
52
59
  * Race `fanOut` against an overall wall-clock deadline (RUSH-2041).
53
60
  *
@@ -34,7 +34,7 @@ import { clearPendingSentinel } from '../lib/devices/pending.js';
34
34
  import { getDeviceDiscoveryStatus, setDeviceDiscoveryStatus } from '../lib/devices/discovery-policy.js';
35
35
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
36
36
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
37
- import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, deviceIdentityArgs, fleetDialTarget, writeAskpassShim, } from '../lib/devices/connect.js';
37
+ import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, deviceIdentityArgs, fleetDialTarget, isAgentsBrowserDrive, markFleetRemote, writeAskpassShim, } from '../lib/devices/connect.js';
38
38
  import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
39
39
  import { shouldSyncTerminfo, syncTerminfoToDevice, terminfoHostKey } from '../lib/devices/terminfo.js';
40
40
  import { fanOutDevices, fleetHealthSkip, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
@@ -351,9 +351,21 @@ function trySshLeasedBox(name, cmd) {
351
351
  console.error(chalk.red(`Leased box '${name}' is not reachable yet (status: ${boxStatus(box)}).`));
352
352
  process.exit(1);
353
353
  }
354
- const res = spawnSync(sshArgv[0], [...sshArgv.slice(1), ...cmd], { stdio: 'inherit' });
354
+ // Crabbox ssh does not go through buildSshInvocation; stamp the same
355
+ // consent marker so a browser drive on a leased box is gated too.
356
+ const remoteCmd = leasedBoxRemoteCmd(cmd);
357
+ const res = spawnSync(sshArgv[0], [...sshArgv.slice(1), ...remoteCmd], { stdio: 'inherit' });
355
358
  process.exit(res.status ?? 1);
356
359
  }
360
+ /**
361
+ * Remote argv for `agents ssh <slug>` into a leased crabbox. Crabbox ssh does
362
+ * not go through {@link buildSshInvocation}, so this stamps the same
363
+ * AGENTS_FLEET_REMOTE consent marker a registered-device ssh would (PHNX-3065).
364
+ * Exported so the branch is unit-testable without a live crabbox.
365
+ */
366
+ export function leasedBoxRemoteCmd(cmd) {
367
+ return isAgentsBrowserDrive(cmd) ? markFleetRemote(cmd, { shell: 'posix' }) : cmd;
368
+ }
357
369
  /** Resolve a device or exit with a clear error. */
358
370
  async function mustGetDevice(name) {
359
371
  const d = await getDevice(name);
@@ -1136,7 +1148,7 @@ function registerDevicesCommands(program) {
1136
1148
  registerFleetApplyAlias(devicesCmd);
1137
1149
  devicesCmd
1138
1150
  .command('register <name>')
1139
- .description('Register a discovered node and sync the approval through agents.yaml fleet.discovery.')
1151
+ .description("Register a discovered node and record the approval in this box's device doc (fleet.discovery), unioned fleet-wide.")
1140
1152
  .action(async (name) => {
1141
1153
  try {
1142
1154
  const nodes = parseTailscaleStatus(tailscaleStatusJson());
@@ -1145,11 +1157,20 @@ function registerDevicesCommands(program) {
1145
1157
  console.error(chalk.red(`'${name}' is not a current tailscale node. See 'agents devices sync'.`));
1146
1158
  process.exit(1);
1147
1159
  }
1148
- await removeIgnored(name); // a re-registered node is no longer dismissed
1160
+ await removeIgnored(name); // clear THIS box's dismissal, if any
1149
1161
  const d = await upsertDevice(name, nodeToDeviceInput(node));
1150
1162
  setDeviceDiscoveryStatus(name, 'approved');
1151
1163
  clearPendingSentinel(name); // drop the notification immediately
1152
1164
  console.log(chalk.green(`Registered '${name}'`) + chalk.gray(` (${d.platform})`));
1165
+ // A dismissal on another box still wins the union (ignored beats
1166
+ // approved), so approving here does not un-dismiss it fleet-wide — say so
1167
+ // rather than imply the node is now discoverable everywhere (PHNX-3315).
1168
+ const otherDismissals = loadIgnoredEntries().filter((e) => e.name === name);
1169
+ if (otherDismissals.length > 0 || getDeviceDiscoveryStatus(name) === 'ignored') {
1170
+ const boxes = [...new Set(otherDismissals.map((e) => e.ignoredOn))].sort().join(', ') || 'another box';
1171
+ console.error(chalk.yellow(`Note: '${name}' is still dismissed on: ${boxes}, so it stays ignored fleet-wide`) +
1172
+ chalk.gray(` until you run \`agents devices unignore ${name}\` there.`));
1173
+ }
1153
1174
  }
1154
1175
  catch (err) {
1155
1176
  console.error(chalk.red(err.message));
@@ -1158,7 +1179,7 @@ function registerDevicesCommands(program) {
1158
1179
  });
1159
1180
  devicesCmd
1160
1181
  .command('ignore <name>')
1161
- .description('Dismiss a node and sync the decision through agents.yaml fleet.discovery (also removes it locally).')
1182
+ .description("Dismiss a node and record the decision in this box's device doc (fleet.ignored), unioned fleet-wide (also removes it locally).")
1162
1183
  .action(async (name) => {
1163
1184
  try {
1164
1185
  await removeDevice(name);
@@ -1176,13 +1197,25 @@ function registerDevicesCommands(program) {
1176
1197
  .command('unignore <name>')
1177
1198
  .description('Undo `ignore`: allow a node to be discovered and registered again.')
1178
1199
  .action(async (name) => {
1179
- const portableWasIgnored = getDeviceDiscoveryStatus(name) === 'ignored';
1180
- const ok = await removeIgnored(name);
1181
- if (!ok && !portableWasIgnored) {
1200
+ const wasIgnored = getDeviceDiscoveryStatus(name) === 'ignored' || loadIgnoredEntries().some((e) => e.name === name);
1201
+ // A box can edit only its OWN device doc (PHNX-3315), so this clears just
1202
+ // this box's dismissal/approval decision.
1203
+ await removeIgnored(name);
1204
+ setDeviceDiscoveryStatus(name, undefined);
1205
+ // Re-evaluate the cross-box union AFTER clearing our slice: another box's
1206
+ // dismissal keeps the node ignored fleet-wide (ignored beats approved), so
1207
+ // never report a false "no longer ignoring" — name the box(es) to clear.
1208
+ const remaining = loadIgnoredEntries().filter((e) => e.name === name);
1209
+ if (remaining.length > 0 || getDeviceDiscoveryStatus(name) === 'ignored') {
1210
+ const boxes = [...new Set(remaining.map((e) => e.ignoredOn))].sort().join(', ') || 'another box';
1211
+ console.error(chalk.yellow(`Cleared this box's decision, but '${name}' is still dismissed on: ${boxes}.`) +
1212
+ chalk.gray(`\nRun \`agents devices unignore ${name}\` there too to clear it fleet-wide.`));
1213
+ return;
1214
+ }
1215
+ if (!wasIgnored) {
1182
1216
  console.error(chalk.gray(`'${name}' was not ignored.`));
1183
1217
  return;
1184
1218
  }
1185
- setDeviceDiscoveryStatus(name, undefined);
1186
1219
  console.log(chalk.green(`No longer ignoring '${name}'`) + chalk.gray(' — run `agents devices sync` to register it.'));
1187
1220
  });
1188
1221
  const ignoredCmd = devicesCmd
@@ -1219,11 +1252,13 @@ function registerDevicesCommands(program) {
1219
1252
  agents devices unignore old-laptop # undo a dismissal
1220
1253
  `,
1221
1254
  notes: `
1222
- Dismissals live in the tracked central agents.yaml (fleet.ignored) and
1223
- sync with 'agents repo push/pull', so a node dismissed on one box stays
1224
- dismissed everywhere. An ignored node is not a device — it never enters
1225
- the registry, so 'agents devices list' never shows it; this command is
1226
- where dismissals are visible.
1255
+ Dismissals live in each box's tracked device doc
1256
+ (devices/<host>/agents.yaml fleet.ignored) and sync with 'agents repo
1257
+ push/pull'; the effective list is their cross-box union, so a node
1258
+ dismissed on one box stays dismissed everywhere without the boxes ever
1259
+ rewriting one shared file. An ignored node is not a device — it never
1260
+ enters the registry, so 'agents devices list' never shows it; this command
1261
+ is where dismissals are visible.
1227
1262
  `,
1228
1263
  });
1229
1264
  // ─── devices config (unified settings surface) ────────────────────────────
@@ -2389,6 +2424,10 @@ An interactive login with no command mirrors the home-relative directory you
2389
2424
  launched from — 'agents ssh yosemite-s0' from ~/src/app lands in ~/src/app on
2390
2425
  the target when it exists, else the remote home. Same portable-cwd rule as
2391
2426
  'agents run --device'. Passing a command keeps the remote home.
2427
+
2428
+ An 'agents browser …', 'ag browser …', or standalone 'browser …' command is
2429
+ stamped AGENTS_FLEET_REMOTE so the target's browser.remote-control consent
2430
+ gate applies, same as 'agents browser <verb> --device <name>'.
2392
2431
  `)
2393
2432
  .action(async (name, cmd) => {
2394
2433
  // Hidden askpass bridge: ssh execs the shim, which re-invokes us here.
@@ -97,6 +97,20 @@ export function registerStatusCommand(syncCmd) {
97
97
  if (status.totals.orphan > 0) {
98
98
  console.log(chalk.gray(` (${status.totals.orphan} orphan${status.totals.orphan === 1 ? '' : 's'} — run \`agents prune cleanup\`)`));
99
99
  }
100
+ // Config drift is its OWN class, not a per-agent "N missing": a box that has
101
+ // not folded its device-scoped state still carries a stale top-level header or
102
+ // a lingering central fleet/hosts/accounts/browser block. Surface it distinctly
103
+ // so an un-drained box is SEEN here instead of via a mystery pull conflict
104
+ // (PHNX-3315).
105
+ if (status.config.staleHeader || status.config.centralLeaks.length > 0) {
106
+ console.log(` ${'config (device-scoped)'.padEnd(28)} ${chalk.yellow('not drained — folds automatically on normal `agents` use here (idempotent)')}`);
107
+ if (status.config.staleHeader) {
108
+ console.log(chalk.gray(' · top-level agents.yaml header is stale (pre-rename)'));
109
+ }
110
+ for (const leak of status.config.centralLeaks) {
111
+ console.log(chalk.gray(` · central ${leak} should be device-scoped`));
112
+ }
113
+ }
100
114
  // Hand off to the shared interactive/apply flow (summary already printed above).
101
115
  await promptDriftSync({ cwd, yes: opts.yes, status, quiet: true });
102
116
  });