@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
@@ -13,14 +13,121 @@
13
13
  * `--pane`/`--pty` target a backend directly when the handle is already known.
14
14
  */
15
15
  import chalk from 'chalk';
16
- import { getActiveSessions } from '../lib/session/active.js';
16
+ import { getActiveSessions, shortIdFromName } from '../lib/session/active.js';
17
17
  import { injectIntoTerminal, resolveInjectTargetForSession } from '../lib/terminal/index.js';
18
+ import { sshExec, shellQuote } from '../lib/ssh-exec.js';
19
+ import { resolveHost } from '../lib/hosts/registry.js';
20
+ import { sshTargetFor } from '../lib/hosts/types.js';
18
21
  import { setHelpSections } from '../lib/help.js';
22
+ /**
23
+ * Whether an active session is the one `sessions inject <token>` means. Matches
24
+ * a resolvable session id (exact or unique prefix) AND — for a tmux-hosted row
25
+ * whose full id never resolved (`sessionId` absent) — the `ag-<agent>-<shortid>`
26
+ * tmux name's `shortid` suffix (exact or prefix), the full tmux name, and the
27
+ * pane id. Those are the only selectors an id-less remote tmux row exposes, so
28
+ * without this an operator has no tool-native way to nudge it (PHNX-3688).
29
+ */
30
+ export function matchInjectSelector(session, token) {
31
+ if (!token)
32
+ return false;
33
+ const sid = session.sessionId;
34
+ if (sid && (sid === token || sid.startsWith(token)))
35
+ return true;
36
+ const short = session.tmuxName ? shortIdFromName(session.tmuxName) : undefined;
37
+ if (short && (short === token || short.startsWith(token)))
38
+ return true;
39
+ if (session.tmuxName && session.tmuxName === token)
40
+ return true;
41
+ if (session.paneId && session.paneId === token)
42
+ return true;
43
+ return false;
44
+ }
45
+ /**
46
+ * The `--device` selector, normalized to a single host string. `optsWithGlobals()`
47
+ * merges the parent `sessions` command's variadic `-D, --device <target...>` over
48
+ * this subcommand's scalar `--device`, so a single `--device box` arrives as
49
+ * `['box']` — which flowed straight into `sshExec` and crashed on
50
+ * `host.startsWith` (PHNX-3688). Coerce the array to its one element; fail loud on
51
+ * several, since inject delivers to exactly one terminal (a fan-out spelling is a
52
+ * user error, not a first-of-list guess).
53
+ */
54
+ export function normalizeInjectDevice(value) {
55
+ const list = value == null ? [] : Array.isArray(value) ? value : [value];
56
+ const hosts = list.map((v) => String(v).trim()).filter((v) => v.length > 0);
57
+ if (hosts.length === 0)
58
+ return undefined;
59
+ if (hosts.length > 1) {
60
+ throw new Error(`sessions inject targets a single device, but --device named ${hosts.length}: ${hosts.join(', ')}.`);
61
+ }
62
+ return hosts[0];
63
+ }
64
+ /**
65
+ * The `agents sessions inject` argv to re-run ON a device (its tmux panes live
66
+ * there, so resolution must happen there). Every flag rides along EXCEPT
67
+ * `--device`: the command runs on the device, resolving locally. Pure so the
68
+ * forwarded invocation is asserted without an SSH hop (PHNX-3688).
69
+ */
70
+ export function buildRemoteInjectArgv(sessionId, text, options) {
71
+ const argv = ['agents', 'sessions', 'inject', sessionId, text];
72
+ if (options.enter === false)
73
+ argv.push('--no-enter');
74
+ if (options.combined)
75
+ argv.push('--combined');
76
+ if (options.socket)
77
+ argv.push('--socket', options.socket);
78
+ if (options.pty)
79
+ argv.push('--pty', options.pty);
80
+ if (options.pane)
81
+ argv.push('--pane', options.pane);
82
+ if (options.json)
83
+ argv.push('--json');
84
+ return argv;
85
+ }
86
+ /**
87
+ * Resolve `device` (registry alias or `user@host`) to an ssh target and re-run
88
+ * `agents sessions inject` there, so a bare session id + `--device` resolves on
89
+ * the box that actually holds the session's tmux panes. The tool-native form of
90
+ * the `agents ssh <device> "agents sessions inject <id> …"` workaround (PHNX-3688).
91
+ */
92
+ /**
93
+ * Resolve `--device` to an ssh target. A registered device becomes its
94
+ * `user@dnsName`; a bare unknown name (an ad-hoc `user@host` or ssh_config alias)
95
+ * is handed to ssh verbatim (`resolveHost` returns null for it). A registered
96
+ * device we CANNOT dial — password-auth, addressless — throws its typed error and
97
+ * is NOT degraded to the raw name, which could ssh a coincidentally-matching but
98
+ * unrelated `~/.ssh/config` Host (PHNX-3688 review).
99
+ */
100
+ export async function resolveInjectSshTarget(device) {
101
+ const host = await resolveHost(device);
102
+ return host ? sshTargetFor(host) : device;
103
+ }
104
+ async function injectOnDevice(sessionId, text, options, device) {
105
+ let target;
106
+ try {
107
+ target = await resolveInjectSshTarget(device);
108
+ }
109
+ catch (err) {
110
+ const message = err instanceof Error ? err.message : String(err);
111
+ if (options.json)
112
+ console.log(JSON.stringify({ ok: false, error: message }));
113
+ else
114
+ console.error(chalk.red(message));
115
+ process.exit(1);
116
+ }
117
+ const remoteCmd = buildRemoteInjectArgv(sessionId, text, options).map(shellQuote).join(' ');
118
+ const res = sshExec(target, remoteCmd, { multiplex: true });
119
+ if (res.stdout)
120
+ process.stdout.write(res.stdout);
121
+ if (res.stderr)
122
+ process.stderr.write(res.stderr);
123
+ if (res.code !== 0)
124
+ process.exit(res.code ?? 1);
125
+ }
19
126
  /** Resolve a session id (short or full) to an addressable terminal target, via the
20
127
  * same resolver the watchdog uses so both agree on what is reachable. */
21
128
  async function resolveTarget(sessionId) {
22
129
  const sessions = await getActiveSessions();
23
- const match = sessions.find((s) => s.sessionId === sessionId || (s.sessionId != null && s.sessionId.startsWith(sessionId)));
130
+ const match = sessions.find((s) => matchInjectSelector(s, sessionId));
24
131
  if (!match)
25
132
  return { target: null, reason: `No active session matches "${sessionId}".` };
26
133
  const resolution = resolveInjectTargetForSession(match);
@@ -35,8 +142,21 @@ async function resolveTarget(sessionId) {
35
142
  return { target: resolution.target };
36
143
  }
37
144
  async function runInject(sessionId, text, options) {
145
+ let device;
146
+ try {
147
+ device = normalizeInjectDevice(options.device);
148
+ }
149
+ catch (err) {
150
+ const message = err instanceof Error ? err.message : String(err);
151
+ if (options.json)
152
+ console.log(JSON.stringify({ ok: false, error: message }));
153
+ else
154
+ console.error(chalk.red(message));
155
+ process.exit(1);
156
+ }
38
157
  // Direct-target shortcuts skip the session lookup — the watchdog often already
39
- // holds the pane id or pty session it wants to type into.
158
+ // holds the pane id or pty session it wants to type into. The pane's exact
159
+ // address is known, so it composes with --device (tmux send-keys over SSH).
40
160
  let target = null;
41
161
  if (options.pty) {
42
162
  target = { backend: 'pty', id: options.pty };
@@ -44,6 +164,12 @@ async function runInject(sessionId, text, options) {
44
164
  else if (options.pane) {
45
165
  target = { backend: 'tmux', pane: options.pane, socket: options.socket };
46
166
  }
167
+ else if (device) {
168
+ // A bare session id + a device: the session's tmux panes live ON that device,
169
+ // so getActiveSessions here can't see them. Resolve + deliver THERE by re-running
170
+ // inject over SSH (the same command, minus --device) — PHNX-3688.
171
+ return injectOnDevice(sessionId, text, options, device);
172
+ }
47
173
  else {
48
174
  const resolved = await resolveTarget(sessionId);
49
175
  if (!resolved.target) {
@@ -60,7 +186,7 @@ async function runInject(sessionId, text, options) {
60
186
  enter: options.enter !== false,
61
187
  combined: options.combined,
62
188
  socket: options.socket,
63
- host: options.device,
189
+ host: device,
64
190
  });
65
191
  if (options.json) {
66
192
  console.log(JSON.stringify(res));
@@ -82,7 +208,7 @@ export function registerSessionsInjectCommand(sessionsCmd) {
82
208
  .option('--pane <id>', 'Target a tmux pane id directly (e.g. %3), skipping session lookup')
83
209
  .option('--pty <id>', 'Target an agents-pty session id directly, skipping session lookup')
84
210
  .option('--socket <path>', 'tmux socket path (defaults to the session/shared socket)')
85
- .option('--device <target>', 'Deliver on a remote device over SSH (tmux/AppleScript backends)')
211
+ .option('--device <target>', 'Deliver on a remote device over SSH. With a bare session id, the session is resolved ON that device; with --pane, the pane is addressed there directly.')
86
212
  .option('--no-enter', 'Send only the text, without a trailing Enter')
87
213
  .option('--combined', 'Fuse text + Enter into ONE write (default: two writes, Ink-TUI safe)')
88
214
  .option('--json', 'Output the InjectResult as JSON');
@@ -96,6 +222,12 @@ export function registerSessionsInjectCommand(sessionsCmd) {
96
222
 
97
223
  # Type into an agents-pty session without submitting
98
224
  agents sessions inject _ "ls" --pty $SID --no-enter
225
+
226
+ # Nudge a live session on another box (resolved on the device)
227
+ agents sessions inject 214edaae "continue" --device yosemite-s0
228
+
229
+ # Address a known remote pane directly (skips lookup, sends over SSH)
230
+ agents sessions inject _ "continue" --pane %122 --socket $SOCK --device yosemite-s0
99
231
  `,
100
232
  notes: `
101
233
  - Ink-TUI Enter semantics: by default the text and Enter are two separate
@@ -103,8 +235,12 @@ export function registerSessionsInjectCommand(sessionsCmd) {
103
235
  - A session is addressable by id when it resolves to a precise split —
104
236
  tmux, iTerm, a VSCodium/Cursor/VS Code integrated terminal, or a pty
105
237
  (resolveInjectTargetForSession). Use --pane/--pty for direct targeting.
106
- - Built on the Terminal Engine (src/lib/terminal): --device runs the tmux /
107
- AppleScript spec over the same SSH transport the launch engine uses.
238
+ - The id may be the session id (short or full) OR the '<shortid>' suffix of
239
+ a tmux target (ag-<agent>-<shortid>) — the only selector a live tmux
240
+ session whose id column shows '-' exposes.
241
+ - Built on the Terminal Engine (src/lib/terminal): with --pane, --device
242
+ runs the tmux send-keys spec over SSH; with a bare id, --device re-runs
243
+ the lookup on that box (its tmux panes live there, not here).
108
244
  `,
109
245
  });
110
246
  // The parent `sessions` command also defines --json, so it binds there;
@@ -104,6 +104,7 @@ function sanitizeMeta(s) {
104
104
  version: clean(s.version),
105
105
  account: clean(s.account),
106
106
  topic: clean(s.topic),
107
+ firstUserMessage: clean(s.firstUserMessage),
107
108
  label: clean(s.label),
108
109
  ticketId: clean(s.ticketId),
109
110
  prUrl: clean(s.prUrl),
@@ -11,11 +11,13 @@ 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, sanitizeLabel, resolveShareVisibility, scanShareContent, formatSensitiveContentError, unlistedNotPrivateWarning, SHARE_VISIBILITY_LEVELS, PUBLISH_VISIBILITY_LEVELS, } from '../lib/share/publish.js';
14
+ import { publishFile, resolveShareUsername, parseMetaEntries, sanitizeLabel, scanShareContent, formatSensitiveContentError, unlistedNotPrivateWarning, SHARE_VISIBILITY_LEVELS, PUBLISH_VISIBILITY_LEVELS, } from '../lib/share/publish.js';
15
15
  import { deleteShare, resolveDeleteTarget } from '../lib/share/delete.js';
16
16
  import { renderWorkerBundle } from '../lib/share/worker-template.js';
17
17
  import { analyticsEnabled } from '../lib/share/analytics.js';
18
- import { phoenixIdBaseForDeploy, resolveShareBackend, } from '../lib/share/backend.js';
18
+ import { extractShareHttpError, formatShareHttpErrorDetail } from '../lib/share/http-error.js';
19
+ import { phoenixIdBaseForDeploy, resolveShareBackend, shouldUseManaged, } from '../lib/share/backend.js';
20
+ import { publishVisibility } from '../lib/storage/visibility.js';
19
21
  import { resolveGitHubUsername } from '../lib/git.js';
20
22
  import { setHelpSections } from '../lib/help.js';
21
23
  import { showUrl } from '../lib/open-url.js';
@@ -245,7 +247,8 @@ export async function runShareList(opts = {}) {
245
247
  throw new Error(OUTDATED_TEMPLATE_HINT);
246
248
  }
247
249
  if (res.status !== 200) {
248
- throw new Error(`Listing failed (${res.status}) for ${listUrl}. Check the endpoint is reachable, or that 'agents artifacts setup' / 'agents auth login' completed.`);
250
+ const detail = formatShareHttpErrorDetail(extractShareHttpError({ status: res.status, body: res.body }));
251
+ throw new Error(`Listing failed (${res.status}) for ${listUrl}${detail}. Check the endpoint is reachable, or that 'agents artifacts setup' / 'agents auth login' completed.`);
249
252
  }
250
253
  if (!/application\/json/i.test(res.contentType)) {
251
254
  // A 200 that isn't JSON means the old Worker ignored ?format=json and served
@@ -389,7 +392,8 @@ export async function runShareRevisions(target, opts = {}) {
389
392
  const fetchListing = opts.fetchListing ?? defaultListingFetch;
390
393
  const res = await fetchListing(revUrl);
391
394
  if (res.status !== 200) {
392
- throw new Error(`Revisions lookup failed (${res.status}) for ${revUrl}. Check the endpoint is reachable, or that 'agents artifacts setup' / 'agents auth login' completed.`);
395
+ const detail = formatShareHttpErrorDetail(extractShareHttpError({ status: res.status, body: res.body }));
396
+ throw new Error(`Revisions lookup failed (${res.status}) for ${revUrl}${detail}. Check the endpoint is reachable, or that 'agents artifacts setup' / 'agents auth login' completed.`);
393
397
  }
394
398
  if (!/application\/json/i.test(res.contentType)) {
395
399
  if (backend.kind === 'managed') {
@@ -624,9 +628,8 @@ export function registerShareCommands(artifactsCmd) {
624
628
  .option('--slug <slug>', 'URL slug override (default: stable slug of the artifact title, then filename)')
625
629
  .option('--github-user <user>', 'GitHub username for the share namespace (default: resolved from gh/git config; ignored on the managed endpoint)')
626
630
  .option('--expire <spec>', "auto-expire (default 30d). e.g. 12h, 30d, 2026-08-01, or 'never'")
627
- .addOption(new Option('--visibility <level>', 'public | unlisted | private | me | org (default public). unlisted is an UNAUTHENTICATED capability URL; private is token-gated (404 without the key); me/org require a Phoenix session. All but public are hidden from the gallery')
628
- .choices([...PUBLISH_VISIBILITY_LEVELS])
629
- .default('public'))
631
+ .addOption(new Option('--visibility <level>', 'public | unlisted | private | me | org. Default when signed in: me (owner-only, Phoenix-gated); use org for your whole email domain, public to opt into the gallery. unlisted is an UNAUTHENTICATED capability URL; private is token-gated (404 without the key). All but public are hidden from the gallery')
632
+ .choices([...PUBLISH_VISIBILITY_LEVELS]))
630
633
  .addOption(new Option('--protected', 'token-gated link (= --visibility private): the URL carries a secret key and returns 404 without it — the authenticated alternative to --unlisted'))
631
634
  .addOption(new Option('--unlisted', 'hidden alias of --visibility unlisted (obscurity, NOT authentication)').hideHelp())
632
635
  .addOption(new Option('--private', 'hidden alias of --visibility unlisted (obscurity, NOT authentication — for real read-auth use --protected)').hideHelp())
@@ -650,11 +653,16 @@ export function registerShareCommands(artifactsCmd) {
650
653
  }
651
654
  try {
652
655
  const meta = parseMetaEntries(opts.meta);
653
- const visibility = resolveShareVisibility({
656
+ // Private by default: an explicit flag wins; otherwise the backend-aware
657
+ // product default — `me` (owner-only) when signed in to Phoenix, `public`
658
+ // for a BYO bucket (the Worker refuses `me`/`org` without a Phoenix owner).
659
+ // `shouldUseManaged` reads the same session/BYO signals `publishFile` will
660
+ // resolve, so the default matches the backend the publish actually uses.
661
+ const visibility = publishVisibility({
654
662
  visibility: opts.visibility,
655
663
  unlisted: opts.unlisted === true || opts.private === true,
656
664
  protected: opts.protected === true,
657
- });
665
+ }, shouldUseManaged({}) ? 'managed' : 'byo');
658
666
  // unlisted (incl. its --private alias) is obscurity, not read-auth — warn
659
667
  // loudly before the publish so a "private" link is never mistaken for a
660
668
  // gated one (PHNX-3654). Skipped for --json so machine output stays clean.
@@ -684,15 +692,25 @@ export function registerShareCommands(artifactsCmd) {
684
692
  });
685
693
  setHelpSections(shareCmd, {
686
694
  examples: `
687
- # Signed in? Just publish — no Cloudflare setup (managed endpoint)
695
+ # Signed in? Just publish — no Cloudflare setup (managed endpoint).
696
+ # Default is private: an owner-only 'me' link, Phoenix-gated.
688
697
  agents auth login
689
698
  agents artifacts share ./out/plan.html
690
699
 
700
+ # Share with everyone at your email domain (no org setup — the domain is it)
701
+ agents artifacts share ./out/plan.html --visibility org
702
+
703
+ # Opt into the public gallery with an OG preview card
704
+ agents artifacts share ./out/landing.html --visibility public
705
+
691
706
  # Hide from the public gallery (direct URL still works, noindex) and expire sooner
692
707
  agents artifacts share ./out/report.html --visibility unlisted --expire 12h
693
708
 
709
+ # Token-gated link — GET returns 404 without the secret ?k= key
710
+ agents artifacts share ./out/secret.html --protected
711
+
694
712
  # Permanent public page (the slug is derived from its title)
695
- agents artifacts share ./out/landing.html --expire never
713
+ agents artifacts share ./out/landing.html --visibility public --expire never
696
714
 
697
715
  # Optional slug override
698
716
  agents artifacts share ./out/report.html --slug q3-report --expire 7d
@@ -720,11 +738,21 @@ ${SHARE_DELETE_EXAMPLES}
720
738
  applies (setup / join). HTML is stored as one object: local images are inlined
721
739
  and Chrome-saved file:// TOC links become in-page hashes.
722
740
 
741
+ Private by default: signed in, a publish with no --visibility is 'me' —
742
+ owner-only, Phoenix-gated, not in the gallery. Pass --visibility org to share
743
+ with everyone at your email domain (no org to create — the domain is the whole
744
+ mechanism), or --visibility public to opt into the gallery with an OG card. A
745
+ BYO publish (no Phoenix session) still defaults to public, since me/org need a
746
+ Phoenix owner to gate on.
747
+
723
748
  Default expiry is 30d so an accidental publish decays. Pass --expire never for
724
749
  a permanent link. --visibility unlisted (hidden aliases: --unlisted / --private)
725
750
  hides the page from the public gallery and agents artifacts share list; the
726
751
  direct URL is still world-readable (unlisted, not secret) and GET sends
727
- X-Robots-Tag: noindex. --visibility me requires a Phoenix session and is only
752
+ X-Robots-Tag: noindex. --protected (= --visibility private) is token-gated
753
+ read-auth: the published URL carries a secret ?k= key (Authorization: Bearer
754
+ is accepted too) and GET returns 404 without it — treat the whole link as a
755
+ secret. --visibility me requires a Phoenix session and is only
728
756
  visible to the signed-in owner; --visibility org requires the same and is
729
757
  visible to members of the same Phoenix organization. A pre-publish scan
730
758
  refuses emails and credential-shaped strings unless --force is passed.
@@ -956,7 +984,7 @@ Prefer to change visibility without a browser? Use 'agents artifacts share visib
956
984
  .addOption(new Option('--scope <level>', 'visibility filter: public (default), unlisted, private, me, org, or all')
957
985
  .choices(['public', 'unlisted', 'private', 'me', 'org', 'all'])
958
986
  .default('public'))
959
- .option('--all', "list every page including hidden unlisted/me/org shares (alias for --scope all)")
987
+ .option('--all', "list every page including hidden unlisted/private/me/org shares (alias for --scope all)")
960
988
  .option('--agent <name>', 'filter to shares published by this agent/harness (case-insensitive)')
961
989
  .option('--session <id>', 'filter to shares published from this session id')
962
990
  // Named --label-contains, not --label: `share <file>` (the parent) already owns
@@ -988,10 +1016,11 @@ Prefer to change visibility without a browser? Use 'agents artifacts share visib
988
1016
  # Everything you've published, newest first
989
1017
  agents artifacts share list
990
1018
 
991
- # Include your hidden pages (unlisted / me / org) so you can see everything
1019
+ # Include your hidden pages (unlisted / private / me / org) so you can see everything
992
1020
  agents artifacts share list --all
993
1021
  agents artifacts share list --scope me
994
1022
  agents artifacts share list --scope unlisted
1023
+ agents artifacts share list --scope private
995
1024
 
996
1025
  # Machine-readable — e.g. pull every still-public URL with jq
997
1026
  agents artifacts share list --list-json | jq -r '.objects[].url'
@@ -37,7 +37,7 @@ import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
37
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
- import { fanOutDevices, fleetHealthSkip, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
40
+ import { fanOutDevices, fleetHealthSkip, planFleetTargets, remoteFleetTargets, runFleet, runOnDevice, runLocalCommand, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
41
41
  import { isRolloutSuccess, verifyFleetRollout, } from '../lib/devices/rollout-verify.js';
42
42
  import { fleetCapacity, fmtBytes, headroom, } from '../lib/devices/health.js';
43
43
  import { buildFleetHealthReport, renderFleetMatrix, renderFleetSummary, renderFleetWarnings, } from '../lib/devices/health-report.js';
@@ -61,6 +61,8 @@ import { runFleetLogin } from '../lib/fleet/remote-login.js';
61
61
  import { getConfigValue, listConfig, setConfigValue, unsetConfigValue, configKeySpec, autoPoolMode, configuredDeviceRole, listConfiguredDeviceRoles, setConfiguredDeviceRole, } from '../lib/device-config.js';
62
62
  import { filterAutoPool, listWorkerDevices } from '../lib/devices/pool.js';
63
63
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
64
+ import { isSelfHost } from '../lib/devices/self-host.js';
65
+ import { collectHeldWorktrees, collectHeldWorktreesUnder, summarizeHeld, aggregateHeld, pushStrandedBranch, } from '../lib/worktree/held.js';
64
66
  /** One-line summary of a device for `list`. `isSelf` marks the machine this
65
67
  * command is running on so it stands out from the rest of the tailnet.
66
68
  * `isInteractive` marks the configured interactive host (`devices config <name> interactive.host`). */
@@ -1130,7 +1132,7 @@ function registerDevicesCommands(program) {
1130
1132
  { title: 'Inspect', names: ['list', 'show', 'status', 'ping', 'harnesses', 'accounts', 'snapshot'] },
1131
1133
  { title: 'Disposable devices', names: ['lease'] },
1132
1134
  { title: 'Configure a device', names: ['config', 'describe', 'render'] },
1133
- { title: 'Fleet operations', names: ['update', 'run', 'login', 'capture', 'apply'] },
1135
+ { title: 'Fleet operations', names: ['update', 'run', 'login', 'capture', 'apply', 'worktrees'] },
1134
1136
  ]);
1135
1137
  devicesCmd
1136
1138
  .command('sync')
@@ -2363,6 +2365,207 @@ is reported \`failed\` (exit non-zero), never a stranded \`ok\`.
2363
2365
  .alias('kill')
2364
2366
  .description('Terminate a running dispatched task from this machine (SIGTERM the remote process group; marks it failed/143).')
2365
2367
  .action((id) => doDeviceTaskStop(id));
2368
+ const worktreesCmd = devicesCmd
2369
+ .command('worktrees')
2370
+ .description("Surface the held set of agent worktrees the sweep only counts, broken into buckets: unmerged-commits (real stranded work), uncommitted-changes, undeterminable. Read-only; --push publishes stranded branches.")
2371
+ .option('--json', 'emit the structured held set (device, repo, worktree, branch, reason, age, size) for machine callers / fleet aggregation')
2372
+ .option('--home <dir>', 'search root to discover repos under (default: $HOME)')
2373
+ .option('--repo <path>', 'scope to a single repo root instead of discovering')
2374
+ .option('--bucket <name>', 'show only one bucket: unmerged-commits | uncommitted-changes | undeterminable')
2375
+ .option('--fleet', 'fan out across every online device and aggregate the held set fleet-wide')
2376
+ .option('--push', 'PUBLISH each unmerged-commits branch that is on no remote (the safe recovery action); never deletes')
2377
+ .option('--yes', 'skip the confirmation prompt for --push')
2378
+ .action((opts) => runWorktreesHeld(opts));
2379
+ setHelpSections(worktreesCmd, {
2380
+ examples: `
2381
+ Inspect the residue:
2382
+ agents fleet worktrees # this box, grouped by bucket
2383
+ agents fleet worktrees --bucket unmerged-commits
2384
+ agents fleet worktrees --json # structured, for scripts
2385
+
2386
+ Fleet-wide:
2387
+ agents fleet worktrees --fleet # aggregate every online device
2388
+ agents fleet run 'agents fleet worktrees --json' # per-device composition
2389
+
2390
+ Recover stranded work (never deletes):
2391
+ agents fleet worktrees --push # publish on-no-remote branches
2392
+ `,
2393
+ notes: 'The nightly worktree-sweep (phnx-labs/.agents) reclaims merged worktrees and HOLDS the rest, reporting only a count. This surfaces that held set. unmerged-commits is the one that matters — a branch with commits on no remote is the PHNX-2951/PHNX-2732 stranded-work class; --push makes it visible. --push and --fleet are mutually exclusive (recovery is per-device on purpose).',
2394
+ });
2395
+ }
2396
+ const BUCKET_ORDER = ['unmerged-commits', 'uncommitted-changes', 'undeterminable'];
2397
+ const BUCKET_LABEL = {
2398
+ 'unmerged-commits': 'unmerged-commits (stranded work — push or open a PR)',
2399
+ 'uncommitted-changes': 'uncommitted-changes (dirty tree — needs review)',
2400
+ 'undeterminable': 'undeterminable (broken/locked — re-examine)',
2401
+ };
2402
+ function isHeldBucket(v) {
2403
+ return BUCKET_ORDER.includes(v);
2404
+ }
2405
+ function fmtWtSize(n) {
2406
+ if (n < 0)
2407
+ return '?';
2408
+ if (n < 1024)
2409
+ return `${n}B`;
2410
+ const units = ['K', 'M', 'G', 'T'];
2411
+ let v = n / 1024;
2412
+ let i = 0;
2413
+ while (v >= 1024 && i < units.length - 1) {
2414
+ v /= 1024;
2415
+ i++;
2416
+ }
2417
+ return `${v.toFixed(v < 10 ? 1 : 0)}${units[i]}`;
2418
+ }
2419
+ /**
2420
+ * `agents fleet worktrees` — surface the held set the sweep only counts. Never
2421
+ * removes anything; `--push` publishes on-no-remote stranded branches. This is
2422
+ * the CLI-native surfacing half of PHNX-3520 (the sweep itself lives in
2423
+ * phnx-labs/.agents; it still emits a bare count until a sibling change teaches
2424
+ * it to consume this).
2425
+ */
2426
+ async function runWorktreesHeld(opts) {
2427
+ if (opts.bucket && !isHeldBucket(opts.bucket)) {
2428
+ console.error(chalk.red(`Unknown bucket '${opts.bucket}'. Use one of: ${BUCKET_ORDER.join(', ')}`));
2429
+ process.exitCode = 1;
2430
+ return;
2431
+ }
2432
+ if (opts.fleet && opts.push) {
2433
+ console.error(chalk.red('--push and --fleet are mutually exclusive; run --push on the device that holds the work.'));
2434
+ process.exitCode = 1;
2435
+ return;
2436
+ }
2437
+ if (opts.fleet && opts.repo) {
2438
+ console.error(chalk.red('--repo scopes a single local repo and cannot combine with --fleet; drop one. To scope on each device, run `agents fleet run \'agents fleet worktrees --repo <path> --json\'`.'));
2439
+ process.exitCode = 1;
2440
+ return;
2441
+ }
2442
+ if (opts.fleet) {
2443
+ await runWorktreesHeldFleet(opts);
2444
+ return;
2445
+ }
2446
+ const searchHome = opts.home ?? os.homedir();
2447
+ const held = opts.repo
2448
+ ? await collectHeldWorktrees(path.resolve(opts.repo))
2449
+ : await collectHeldWorktreesUnder(searchHome);
2450
+ if (opts.push) {
2451
+ await runWorktreesPush(held, opts);
2452
+ return;
2453
+ }
2454
+ const shown = opts.bucket ? held.filter((w) => w.bucket === opts.bucket) : held;
2455
+ if (opts.json) {
2456
+ console.log(JSON.stringify(shown, null, 2));
2457
+ return;
2458
+ }
2459
+ const summary = summarizeHeld(shown);
2460
+ if (summary.total === 0) {
2461
+ console.log(chalk.green('No held worktrees — nothing stranded, dirty, or undeterminable.'));
2462
+ return;
2463
+ }
2464
+ for (const bucket of BUCKET_ORDER) {
2465
+ const items = summary.buckets[bucket];
2466
+ if (items.length === 0)
2467
+ continue;
2468
+ const color = bucket === 'unmerged-commits' ? chalk.yellow : chalk.gray;
2469
+ console.log('\n' + color(chalk.bold(`${BUCKET_LABEL[bucket]} — ${items.length}`)));
2470
+ for (const w of items.sort((a, b) => b.unmergedCommits - a.unmergedCommits)) {
2471
+ const detail = bucket === 'unmerged-commits'
2472
+ ? `${w.unmergedCommits} commit${w.unmergedCommits === 1 ? '' : 's'}, ${w.hasRemoteBranch ? 'on remote' : chalk.red('NO remote')}`
2473
+ : bucket === 'uncommitted-changes'
2474
+ ? `${w.dirtyFiles} dirty file${w.dirtyFiles === 1 ? '' : 's'}`
2475
+ : w.reason;
2476
+ console.log(` ${chalk.cyan(w.repoName + '/' + w.name).padEnd(48)} ${chalk.gray((w.branch ?? 'detached').padEnd(28))} ${detail} ${chalk.dim(`${w.ageDays}d · ${fmtWtSize(w.sizeBytes)}`)}`);
2477
+ }
2478
+ }
2479
+ const stranded = summary.buckets['unmerged-commits'].filter((w) => !w.hasRemoteBranch).length;
2480
+ if (stranded > 0) {
2481
+ console.log('\n' + chalk.yellow(`${stranded} branch${stranded === 1 ? '' : 'es'} with unmerged commits on no remote. Recover: agents fleet worktrees --push`));
2482
+ }
2483
+ }
2484
+ /** `--push`: publish every on-no-remote stranded branch. Never deletes. */
2485
+ async function runWorktreesPush(held, opts) {
2486
+ const candidates = held.filter((w) => w.bucket === 'unmerged-commits' && !w.hasRemoteBranch && w.branch);
2487
+ if (candidates.length === 0) {
2488
+ console.log(chalk.green('No stranded branches to publish (nothing with unmerged commits on no remote).'));
2489
+ return;
2490
+ }
2491
+ if (isInteractiveTerminal() && !opts.yes) {
2492
+ console.log(chalk.yellow(`About to push ${candidates.length} stranded branch${candidates.length === 1 ? '' : 'es'} to origin:`));
2493
+ for (const w of candidates)
2494
+ console.log(` ${chalk.cyan(w.repoName + '/' + w.name)} → ${w.branch}`);
2495
+ const { confirm } = await import('@inquirer/prompts');
2496
+ const go = await confirm({ message: 'Push these branches?', default: true }).catch(() => false);
2497
+ if (!go) {
2498
+ console.log(chalk.gray('Cancelled — nothing pushed.'));
2499
+ return;
2500
+ }
2501
+ }
2502
+ let pushed = 0;
2503
+ for (const w of candidates) {
2504
+ const res = await pushStrandedBranch(w.repo, w);
2505
+ if (res.pushed) {
2506
+ pushed++;
2507
+ console.log(chalk.green(` pushed ${w.repoName}/${w.name} (${res.branch})`));
2508
+ }
2509
+ else {
2510
+ console.log(chalk.yellow(` skipped ${w.repoName}/${w.name}: ${res.reason}`));
2511
+ }
2512
+ }
2513
+ console.log('\n' + chalk.green(`Published ${pushed}/${candidates.length} stranded branch${candidates.length === 1 ? '' : 'es'}.`));
2514
+ }
2515
+ /** `--fleet`: run this same read-only surface on every online device, aggregate. */
2516
+ async function runWorktreesHeldFleet(opts) {
2517
+ const reg = await loadDevices();
2518
+ const targets = planFleetTargets(reg);
2519
+ const online = targets.filter((t) => !t.skip);
2520
+ if (online.length === 0) {
2521
+ console.log(chalk.gray("No online devices. Run 'agents devices sync' first."));
2522
+ return;
2523
+ }
2524
+ const self = machineId();
2525
+ const remoteCmd = ['agents', 'fleet', 'worktrees', '--json'];
2526
+ if (opts.home)
2527
+ remoteCmd.push('--home', opts.home);
2528
+ const perDevice = [];
2529
+ for (const t of online) {
2530
+ const name = t.device.name;
2531
+ const isSelf = name === self || isSelfHost(name);
2532
+ const res = isSelf ? runLocalCommand(remoteCmd) : runOnDevice(t.device, remoteCmd);
2533
+ if (res.code !== 0) {
2534
+ console.error(chalk.gray(` ${name}: ${(res.stderr || 'failed').trim().slice(0, 120)}`));
2535
+ continue;
2536
+ }
2537
+ try {
2538
+ perDevice.push({ device: name, held: JSON.parse(res.stdout) });
2539
+ }
2540
+ catch {
2541
+ console.error(chalk.gray(` ${name}: unparseable output (older CLI?)`));
2542
+ }
2543
+ }
2544
+ // Apply the bucket filter to the per-device inputs BEFORE aggregating, so
2545
+ // `total`, per-`devices` totals, and `buckets` all describe the same filtered
2546
+ // set — zeroing buckets after the sum leaves `total` counting rows the output
2547
+ // no longer lists.
2548
+ const scoped = opts.bucket
2549
+ ? perDevice.map((d) => ({ device: d.device, held: d.held.filter((w) => w.bucket === opts.bucket) }))
2550
+ : perDevice;
2551
+ const agg = aggregateHeld(scoped);
2552
+ if (opts.json) {
2553
+ console.log(JSON.stringify(agg, null, 2));
2554
+ return;
2555
+ }
2556
+ console.log(chalk.bold(`Held worktrees across ${perDevice.length} device(s): ${agg.total}`));
2557
+ for (const bucket of BUCKET_ORDER) {
2558
+ const items = agg.buckets[bucket];
2559
+ if (items.length === 0)
2560
+ continue;
2561
+ const color = bucket === 'unmerged-commits' ? chalk.yellow : chalk.gray;
2562
+ console.log('\n' + color(chalk.bold(`${BUCKET_LABEL[bucket]} — ${items.length}`)));
2563
+ for (const w of items) {
2564
+ const dev = w.device ?? '?';
2565
+ const detail = bucket === 'unmerged-commits' ? `${w.unmergedCommits} commits, ${w.hasRemoteBranch ? 'on remote' : chalk.red('NO remote')}` : w.reason;
2566
+ console.log(` ${chalk.magenta(dev.padEnd(14))} ${chalk.cyan((w.repoName + '/' + w.name).padEnd(40))} ${chalk.gray((w.branch ?? 'detached').padEnd(24))} ${detail}`);
2567
+ }
2568
+ }
2366
2569
  }
2367
2570
  /**
2368
2571
  * `agents devices ps` — list tasks dispatched to devices (`agents run --device
@@ -180,8 +180,13 @@ export interface UsageSnapshot {
180
180
  * MUST NEVER read this field — `isUsageVerified`/`hasStaleUsage`/
181
181
  * `hasUsageAvailable`/`deriveUsageStatusFromSnapshot` consult only `windows`,
182
182
  * so a stale number rendered here can never make a stale account read as
183
- * verified or eligible (the RUSH-2858 property). Never serialized to the
184
- * on-disk cache or `--json` (both project `windows` explicitly).
183
+ * verified or eligible (the RUSH-2858 property). Not a serialized key of its
184
+ * own, and dropped from `--json` (which projects `windows` explicitly) — but
185
+ * the READINGS it holds do round-trip through the on-disk cache:
186
+ * `serializeClaudeUsageSnapshot` persists the union of `windows` and
187
+ * `staleWindows`, and `deserializeClaudeUsageSnapshot` re-runs the freshness
188
+ * gate on read to re-partition them (so a collector like Grok that pre-splits
189
+ * an ended-period reading onto `staleWindows` still survives the round-trip).
185
190
  */
186
191
  staleWindows?: UsageWindow[];
187
192
  plan?: string | null;