@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
@@ -0,0 +1,96 @@
1
+ import chalk from 'chalk';
2
+ import { setHelpSections } from '../lib/help.js';
3
+ import { readStdinSync, runOrDie } from '../lib/format.js';
4
+ import { isInteractiveTerminal } from './utils.js';
5
+ import { getMintFlow, listMintableHarnesses, mintAndSeed, } from '../lib/auth-mint.js';
6
+ function collect(value, previous) {
7
+ return [...previous, value];
8
+ }
9
+ function printResult(result, json) {
10
+ if (json) {
11
+ console.log(JSON.stringify({
12
+ harness: result.harness,
13
+ account: result.account,
14
+ email: result.email,
15
+ authBundleKey: result.authBundleKey,
16
+ rotated: result.rotated,
17
+ fleet: result.fleet,
18
+ }, null, 2));
19
+ return;
20
+ }
21
+ const verb = result.rotated ? 'Rotated' : 'Minted';
22
+ console.log(chalk.green(`${verb} ${result.harness} setup-token for ${result.email} into account '${result.account}' and reserved auth bundle key ${result.authBundleKey}.`));
23
+ console.log(chalk.gray('The token is never printed. Native OAuth was not copied.'));
24
+ console.log(chalk.gray(`Run: agents run ${result.harness} --account ${result.account}`));
25
+ if (result.fleet.length) {
26
+ for (const row of result.fleet) {
27
+ console.log(chalk.green(` synced ${row.device} (${row.message})`));
28
+ }
29
+ }
30
+ }
31
+ export function registerMintCommand(parent, testHooks) {
32
+ const mint = parent
33
+ .command('mint <harness>')
34
+ .description('Mint a long-lived setup-token and seed it as a named account')
35
+ .option('--account <name-or-email>', 'Named account to create/rotate, or the account email')
36
+ .option('--email <email>', 'Email that keys the reserved auth bundle when --account is a name')
37
+ .option('--token-stdin', 'Seed an already-minted token from stdin (skip the PTY drive)')
38
+ .option('--code <code>', 'Authorization code to paste into the setup-token prompt')
39
+ .option('--no-open', 'Print the authorize URL instead of opening a browser')
40
+ .option('--fleet', 'Sync the minted account and reserved auth bundle to every other registered device')
41
+ .option('--device <name>', 'Sync to this device (repeatable)', collect, [])
42
+ .option('--json', 'Machine-readable result (never includes the token)')
43
+ .action((harness, o, command) => {
44
+ const json = !!o.json || !!command.optsWithGlobals().json;
45
+ return runOrDie(async () => {
46
+ getMintFlow(harness);
47
+ let token;
48
+ if (o.tokenStdin) {
49
+ token = readStdinSync();
50
+ if (!token)
51
+ throw new Error('No value received on stdin. Pass the setup-token on stdin with --token-stdin.');
52
+ }
53
+ else if (!isInteractiveTerminal() && !o.code) {
54
+ throw new Error(`Minting ${harness} interactively needs a TTY (or --token-stdin / --code). Supported harnesses: ${listMintableHarnesses().join(', ')}.`);
55
+ }
56
+ const result = await mintAndSeed({
57
+ harness,
58
+ account: o.account,
59
+ email: o.email,
60
+ token,
61
+ code: o.code,
62
+ open: o.open,
63
+ fleet: o.fleet,
64
+ devices: o.device,
65
+ json,
66
+ hooks: {
67
+ ...testHooks,
68
+ ...(o.tokenStdin || o.code || !isInteractiveTerminal()
69
+ ? {}
70
+ : {
71
+ readCode: async () => {
72
+ const { input } = await import('@inquirer/prompts');
73
+ return input({ message: 'Paste the authorization code from the browser' });
74
+ },
75
+ }),
76
+ },
77
+ });
78
+ printResult(result, json);
79
+ }, { json });
80
+ });
81
+ setHelpSections(mint, {
82
+ examples: `agents accounts mint claude
83
+ agents accounts mint claude --account ada@example.com
84
+ agents accounts mint claude --account work --email ada@example.com
85
+ agents accounts mint claude --token-stdin < token.txt
86
+ agents accounts mint claude --code AUTHCODE --json
87
+ agents accounts mint claude --fleet
88
+ agents auth mint claude --account ada@example.com --json`,
89
+ notes: `Claude is the only harness with an interactive long-lived setup-token (\`claude setup-token\`, ~1 year, shareable across machines). The command drives that flow in a PTY, opens the authorize URL, captures a well-formed sk-ant-oat01- token (refusing the #1767 TTY-banner capture), and seeds:
90
+ 1. a named provider account (policy never) for \`agents run --account\` / \`agents accounts sync\`
91
+ 2. the reserved FILE-BASED auth bundle keyed per email, which usage/probe reads
92
+ Native rotating OAuth is never copied. Other harnesses fail loud: device-code login is \`agents fleet login\`; API keys are \`agents accounts add\`.
93
+ Already have a token? \`--token-stdin\` skips the browser dance. \`agents auth mint\` is the same command as \`agents accounts mint\`.`,
94
+ });
95
+ return mint;
96
+ }
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import { PhoenixApiError, clearSession, createSpace, createSpaceInvite, fetchWhoAmI, listSpaceMembers, listSpaces, pollDeviceToken, readSession, removeSpaceMember, resolveMemberFromList, resolveSpaceFromList, slugify, startDeviceAuthorization, updateSpaceMemberRole, writeSession, } from '../lib/identity/index.js';
3
3
  import { setHelpSections } from '../lib/help.js';
4
4
  import { runOrDie } from '../lib/format.js';
5
+ import { registerMintCommand } from './auth-mint.js';
5
6
  /**
6
7
  * `agents auth` — sign in to Phoenix ID, the account layer behind team spaces.
7
8
  * Signing in is optional: every local feature works with no account. Everything
@@ -95,13 +96,15 @@ export function registerAuthCommand(program) {
95
96
  setHelpSections(auth, {
96
97
  examples: `agents auth login # device-code sign-in via your browser
97
98
  agents auth whoami # who this machine is signed in as
99
+ agents auth mint claude # mint a Claude setup-token into a named account
98
100
  agents auth space create "Design Team" # start a space
99
101
  agents auth space invite ada@example.com # add a teammate
100
102
  agents auth logout # clear this machine only`,
101
103
  notes: `Signing in is optional — every local feature works with no account; team spaces are what it unlocks.
102
104
  Sign-in is Google-only and opens a Phoenix-branded page; the CLI never sees a password.
103
105
  The session lives in this machine's agents state dir, so logging out here signs out nothing else.
104
- Point at a different backend with PHOENIX_ID_BASE (defaults to the production service).`,
106
+ Point at a different backend with PHOENIX_ID_BASE (defaults to the production service).
107
+ \`agents auth mint\` is the harness setup-token mint (same command as \`agents accounts mint\`); it does not sign you into Phoenix ID.`,
105
108
  });
106
109
  auth
107
110
  .command('login')
@@ -115,6 +118,7 @@ Point at a different backend with PHOENIX_ID_BASE (defaults to the production se
115
118
  const json = !!o.json || !!command.optsWithGlobals().json;
116
119
  return runOrDie(() => whoami(json), { json });
117
120
  });
121
+ registerMintCommand(auth);
118
122
  auth
119
123
  .command('logout')
120
124
  .description("Clear this machine's session (no other device is affected)")
@@ -1308,7 +1308,7 @@ function registerTaskCommands(browser) {
1308
1308
  });
1309
1309
  browser
1310
1310
  .command('remote-control [state]')
1311
- .description("Allow or deny other fleet machines driving THIS machine's browser over `browser --device`. " +
1311
+ .description("Allow or deny other fleet machines driving THIS machine's browser over `browser --device` or `agents ssh …` (`agents browser` / `ag browser` / standalone `browser`). " +
1312
1312
  '`on`/`off` to set (device-local, never synced); no argument prints the current value. Default off.')
1313
1313
  .option('--json', 'Output as JSON')
1314
1314
  .action((state, opts) => {
@@ -21,8 +21,14 @@ Examples:
21
21
 
22
22
  Cost is computed offline from a versioned per-model price table (${PRICING_VERSION}).
23
23
  `)
24
- .action(async (options) => {
25
- await costAction(options);
24
+ // Read opts via optsWithGlobals(): `--json`/`--since`/`--by` collide by name
25
+ // with the `insights` parent's own options, so commander binds them to the
26
+ // parent at parse time and the leaf's plain opts() never sees them. Merging
27
+ // ancestor opts is what the sibling `insights mix` recipes already do
28
+ // (mix-commands.ts) — without it every flag on this command is silently
29
+ // dropped (e.g. `agents insights cost --json` printed the human table).
30
+ .action(async (_options, command) => {
31
+ await costAction(command.optsWithGlobals());
26
32
  });
27
33
  }
28
34
  /** Map the --by flag to a rollup group, rejecting unknown values. */
@@ -886,7 +886,7 @@ export function registerDaemonCommand(program) {
886
886
  always. Use 'agents routines stats' for per-routine failure detail.
887
887
 
888
888
  'disable' is a persisted kill switch: it stops routines/add,
889
- routines/start, routines/catchup, and webhook triggers from auto-starting
889
+ routines/start, routines/catchup, monitors/add, and webhook triggers from auto-starting
890
890
  the daemon (daemon.enabled: false in ~/.agents/devices/<host>/agents.yaml).
891
891
  'agents daemon start' still starts it explicitly, same as
892
892
  'systemctl start' on a disabled unit.
@@ -974,7 +974,7 @@ export function registerDaemonCommand(program) {
974
974
  .description('Clear the daemon.enabled kill switch. Does not start the daemon by itself.')
975
975
  .action(() => {
976
976
  setConfigValue('daemon.enabled', true);
977
- console.log(chalk.green('daemon.enabled: true') + chalk.gray(' — auto-start surfaces (routines add/start/catchup, webhooks) may bring the daemon up again'));
977
+ console.log(chalk.green('daemon.enabled: true') + chalk.gray(' — auto-start surfaces (routines add/start/catchup, monitors add, webhooks) may bring the daemon up again'));
978
978
  });
979
979
  cmd.command('disable')
980
980
  .description('Persist daemon.enabled: false — nothing auto-starts the daemon until re-enabled. Does not stop a running daemon.')
@@ -24,6 +24,7 @@ import { resolveAgentTargets, AgentSpecError } from '../lib/agent-spec/index.js'
24
24
  import { loadManifest, isStale } from '../lib/staleness/index.js';
25
25
  import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
26
26
  import { checkVersionHookWiring, inspectDuplicateVersionHooks, registerHooksToSettings, repairManagedHookRuntimeArtifacts } from '../lib/hooks/install.js';
27
+ import { inspectReservedAuthBundle } from '../lib/secrets/bundles.js';
27
28
  import { isVersionIsolated } from '../lib/installations/versions.js';
28
29
  import { computeDrift, checkSyncStatus, countOrphans, computeSourceBehind } from '../lib/drift.js';
29
30
  import { readAuthHealthCache, summarizeHostAuth } from '../lib/auth-health.js';
@@ -118,13 +119,15 @@ function printWrappedLine(prefix, text) {
118
119
  * file and line. Run `agents doctor` on that box for the specifics — the
119
120
  * message says so.
120
121
  */
121
- const REMOTE_FORWARDED_KINDS = ['rc-secret-export', 'env-secret-export', 'ssh-key-enrollment'];
122
+ const REMOTE_FORWARDED_KINDS = ['rc-secret-export', 'env-secret-export', 'auth-bundle-wrong-backend', 'ssh-key-enrollment'];
122
123
  /** Canonical, locally-authored text for a forwarded kind. Never the remote's. */
123
124
  const REMOTE_SECRET_MESSAGE = {
124
125
  'rc-secret-export': 'a credential-shaped export was found in this box\'s shell rc files'
125
126
  + ' — run `agents doctor` there for the file and line',
126
127
  'env-secret-export': 'AGENTS_SECRETS_PASSPHRASE is set in this box\'s process environment'
127
128
  + ' — run `agents doctor` there for detail',
129
+ 'auth-bundle-wrong-backend': "reserved secrets bundle 'auth' exists but is not file-backed"
130
+ + ' — run `agents doctor` there to recreate it',
128
131
  'ssh-key-enrollment': 'Windows OpenSSH key enrollment is invalid'
129
132
  + ' — run `agents doctor` on this box for the effective path or ACL failure',
130
133
  };
@@ -442,6 +445,7 @@ async function runDevicesDoctor(opts) {
442
445
  hostClis: toHostCliInput(listCliStatus(cwd)),
443
446
  rcSecrets: scanUserRcFiles(),
444
447
  masterPassphraseInEnv: masterPassphraseInEnv(),
448
+ authBundleWrongBackend: !inspectReservedAuthBundle().ok,
445
449
  execPolicy: process.platform === 'win32'
446
450
  ? { platform: process.platform, policy: getEffectiveExecutionPolicy() }
447
451
  : undefined,
@@ -1680,6 +1684,7 @@ export function registerDoctorCommand(program) {
1680
1684
  hostClis: toHostCliInput(hostClis),
1681
1685
  rcSecrets: scanUserRcFiles(),
1682
1686
  masterPassphraseInEnv: masterPassphraseInEnv(),
1687
+ authBundleWrongBackend: !inspectReservedAuthBundle().ok,
1683
1688
  // getEffectiveExecutionPolicy spawns powershell — a doomed process on
1684
1689
  // POSIX, where the advisory never applies. Probe only on Windows.
1685
1690
  execPolicy: process.platform === 'win32'
@@ -654,13 +654,13 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
654
654
  Interactive plan (omit the prompt) works everywhere it is listed.
655
655
 
656
656
  Run strategy (set via --strategy or run.<agent>.strategy in agents.yaml):
657
- pinned use the workspace/global pinned version
657
+ pinned use the workspace/global pinned version; if that version is logged out on this device, pick a signed-in sibling instead of dying (an explicit @version pin is unchanged)
658
658
  available use pinned if it can run right now; otherwise switch to another signed-in version
659
659
  balanced distribute load across healthy accounts by remaining capacity (default)
660
660
  A version/account is skipped when it is rate-limited right now — any usage window (incl. the 5-hour session window) at 100%, matching the 'agents view' badge.
661
661
  --balanced is shorthand for --strategy balanced. Ignored when @version is pinned, when a profile is used, or with --fallback.
662
- Zero healthy accounts under balanced/available exits nonzero naming each
663
- excluded account and the earliest window reset — use --strategy pinned to force.
662
+ Zero healthy accounts under balanced/available (or a logged-out pinned default with no signed-in sibling) exits nonzero naming each
663
+ excluded account and the earliest window reset — use --strategy pinned to force a rate-limited default; a logged-out default is never forced.
664
664
 
665
665
  'auto' harness (agents run auto): picks the host (14d usage affinity,
666
666
  unless --device is given), the harness (installed CLIs weighted by
@@ -1569,15 +1569,19 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1569
1569
  });
1570
1570
  }
1571
1571
  }
1572
- // A network drop kills the local ssh client (exit 255) but the remote
1573
- // agent survives in its detached tmux session. With a known session id
1574
- // (Claude's forced id, a resumed run, or a non-Claude id we just
1575
- // resolved from the remote hook record) and a tmux-hosted run,
1576
- // re-attach the live pane automatically instead of exiting — the user
1577
- // never has to notice the drop and `agents sessions focus` by hand.
1578
- // `raw` runs aren't tmux wrapped, so there is nothing to reconnect to.
1579
- // For `run auto` prefer the join-resolved id (the harness the remote
1580
- // ACTUALLY picked) over the explicit --session-id only claude adopts.
1572
+ // A network drop kills the local ssh client (exit 255). What the
1573
+ // remote agent does next depends on the peer's tmux.enabled
1574
+ // (PHNX-3316): wrapped, it survives in its detached pane and the
1575
+ // reconnect rejoins it; bare (the default), the drop SIGHUPs it and
1576
+ // the reconnect resumes the session in place from disk. Either way,
1577
+ // with a known session id (Claude's forced id, a resumed run, or a
1578
+ // non-Claude id we just resolved from the remote hook record) we
1579
+ // reconnect automatically instead of exiting — the user never has
1580
+ // to notice the drop and `agents sessions focus` by hand.
1581
+ // `raw` runs opted out of all of this, so there is nothing to
1582
+ // reconnect to. For `run auto` prefer the join-resolved id (the
1583
+ // harness the remote ACTUALLY picked) over the explicit
1584
+ // --session-id only claude adopts.
1581
1585
  const { pickReconnectTarget, reconnectInteractiveSession, afterInteractiveRemoteExit, SSH_CONN_FAILURE } = await import('../lib/hosts/reconnect.js');
1582
1586
  const reconnectTarget = pickReconnectTarget({
1583
1587
  agent: runAgent,
@@ -1814,6 +1818,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1814
1818
  let accountConfigVersion;
1815
1819
  let profileProvider;
1816
1820
  let fromProfile = false;
1821
+ let profileName;
1817
1822
  let profileFallbackModel;
1818
1823
  let workflowModel;
1819
1824
  // WORKFLOW.md capability scoping, translated to Claude headless flags below.
@@ -1897,6 +1902,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1897
1902
  profileProvider = readProfile(rawAgent).provider;
1898
1903
  profileFallbackModel = resolved.fallbackModel;
1899
1904
  fromProfile = true;
1905
+ profileName = resolved.profileName;
1900
1906
  process.stderr.write(chalk.gray(`Resolved custom harness '${resolved.profileName}' -> ${agent}${version ? `@${version}` : ''}\n`));
1901
1907
  if (resolved.tierNote) {
1902
1908
  process.stderr.write(chalk.gray(`[agents] ${resolved.tierNote}\n`));
@@ -2362,13 +2368,15 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2362
2368
  process.exit(1);
2363
2369
  }
2364
2370
  const strategy = options.balanced ? 'balanced' : explicitStrategy ?? configuredStrategy;
2365
- // Strategy only applies to bare agent invocations. Explicit @version and
2366
- // profiles already define their execution target. A --fallback chain does
2367
- // NOT pin the primary: it only names where to cascade on a rate limit, so
2368
- // the bare primary still resolves through the strategy — otherwise every
2371
+ // Strategy applies to bare (unpinned) invocations. Explicit @version and
2372
+ // profiles already name the target. A --fallback chain does NOT pin the
2373
+ // primary: it only names where to cascade on a rate limit, so the bare
2374
+ // primary still resolves through the strategy — otherwise every
2369
2375
  // `agents run claude --fallback codex` run lands on the pinned default
2370
2376
  // account and account rotation silently stops (the gh-monitor heal bug).
2371
- if (!accountPickerRequested && !configuredAccount && (strategy !== 'pinned' || options.balanced || explicitStrategy)) {
2377
+ // `pinned` still resolves so a logged-out default can yield to a signed-in
2378
+ // sibling on this device (PHNX-2685); an explicit @version pin is unchanged.
2379
+ if (!accountPickerRequested && !configuredAccount && (!version || strategy !== 'pinned' || options.balanced || explicitStrategy)) {
2372
2380
  if (version) {
2373
2381
  process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: version ${version} is pinned\n`));
2374
2382
  }
@@ -2745,6 +2753,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2745
2753
  }
2746
2754
  const execOptions = {
2747
2755
  agent,
2756
+ harnessName: profileName,
2748
2757
  version,
2749
2758
  configVersion: accountConfigVersion,
2750
2759
  prompt,
@@ -71,6 +71,13 @@ async function runCapture(opts) {
71
71
  secretsBundles: listBundles().map((b) => b.name),
72
72
  routines: listJobs().map((j) => j.name),
73
73
  };
74
+ // Discovery decisions and dismissals are device-scoped now (PHNX-3315): they
75
+ // live in each box's device doc and sync via the repo, so `capture` must NOT
76
+ // hoist the cross-box union back into the shared `agents.yaml` `fleet:` block
77
+ // (that would recreate the N-boxes-rewrite-one-map conflict Task A removed and
78
+ // re-arm the fold-then-delete migration on every capture). `meta.fleet` carries
79
+ // only the roster/defaults; any lingering central-legacy discovery/ignored is
80
+ // carried forward verbatim and drained by the migration.
74
81
  const next = captureFleet(meta.fleet, inputs);
75
82
  if (opts.dryRun) {
76
83
  console.log(chalk.gray('# agents.yaml fleet: block (dry run — not written)'));
@@ -110,6 +110,7 @@ export declare function dedupeSessionsByLogicalId(rows: SessionMeta[], self?: st
110
110
  * peer-owned session without `--device`, matching `resume`/`preview`.
111
111
  */
112
112
  export declare function focusTargetForResolved(poolMatch: SessionMeta | undefined, resolved: SessionMeta): SessionMeta;
113
+ export declare function focusResolvedSession(meta: SessionMeta, liveById: Map<string, ActiveSession>, self: string, fallback: UnreachableFallback, attachOnly: boolean, reconnectReattach?: boolean): Promise<void>;
113
114
  /** Focus one row selected by the shared session browser through the same
114
115
  * attach/recover decision as `agents sessions focus <id>`. */
115
116
  export declare function focusSelectedSession(meta: SessionMeta, active: ActiveSession | undefined, self: string): Promise<void>;
@@ -31,6 +31,7 @@ import { multiItemPicker } from '../lib/picker.js';
31
31
  import { sessionRecoveryRunArgs } from '../lib/session/recovery.js';
32
32
  import { shellQuote, assertValidSshTarget } from '../lib/ssh-exec.js';
33
33
  import { openSurfaces, currentContext, availableBackends, detectCurrentBackend, } from '../lib/terminal/index.js';
34
+ import { addressabilityRecoveryHint } from '../lib/terminal/resolve.js';
34
35
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
35
36
  import { setHelpSections } from '../lib/help.js';
36
37
  const INHERITED_FOCUS_OPTIONS = [
@@ -493,10 +494,10 @@ async function pickFocusCandidates(sessions, liveById, initialSearch) {
493
494
  export function focusTargetForResolved(poolMatch, resolved) {
494
495
  return poolMatch ?? resolved;
495
496
  }
496
- async function focusResolvedSession(meta, liveById, self, fallback, attachOnly, reconnectReattach = false) {
497
+ export async function focusResolvedSession(meta, liveById, self, fallback, attachOnly, reconnectReattach = false) {
497
498
  const active = liveById.get(meta.id);
498
499
  if (active && isAttachableLiveSession(active)) {
499
- await jumpTo(active, self, fallback);
500
+ await jumpTo(active, self, fallback, meta.id);
500
501
  return;
501
502
  }
502
503
  if (attachOnly) {
@@ -534,6 +535,7 @@ export async function focusSelectedSession(meta, active, self) {
534
535
  return;
535
536
  }
536
537
  console.log(chalk.yellow('This live session has no session id or living attach rail to focus.'));
538
+ console.log(chalk.gray(addressabilityRecoveryHint(active, meta.id)));
537
539
  process.exitCode = 1;
538
540
  return;
539
541
  }
@@ -104,9 +104,10 @@ export declare function describeWhere(s: ActiveSession, self: string): Where;
104
104
  /**
105
105
  * What to do when a session can't be *attached* (no tmux/Ghostty rail). `go`
106
106
  * refuses; `focus` opens a new tab and resumes. `remote` is the peer name when
107
- * the session lives on another machine, else undefined.
107
+ * the session lives on another machine, else undefined. `fallbackId` is the
108
+ * indexed session id when the live row has not registered one yet (PHNX-3356).
108
109
  */
109
- export type UnreachableFallback = (s: ActiveSession, remote: string | undefined) => void | Promise<void>;
110
+ export type UnreachableFallback = (s: ActiveSession, remote: string | undefined, fallbackId?: string) => void | Promise<void>;
110
111
  export type AttachRailLiveness = {
111
112
  state: 'alive';
112
113
  } | {
@@ -121,5 +122,5 @@ export type AttachRailLiveness = {
121
122
  export declare function probeAttachRail(s: ActiveSession, self: string): Promise<AttachRailLiveness>;
122
123
  /** Strict attach-only fallback: no pane means no attach. Never open a shell or
123
124
  * start recovery, because both would violate the caller's no-fork intent. */
124
- export declare function refuseFallback(s: ActiveSession, remote: string | undefined): Promise<void>;
125
- export declare function jumpTo(s: ActiveSession, self: string, fallback?: UnreachableFallback): Promise<void>;
125
+ export declare function refuseFallback(s: ActiveSession, remote: string | undefined, fallbackId?: string): Promise<void>;
126
+ export declare function jumpTo(s: ActiveSession, self: string, fallback?: UnreachableFallback, fallbackId?: string): Promise<void>;
@@ -34,6 +34,7 @@ import { getDefaultSocketPath } from '../lib/tmux/paths.js';
34
34
  import { sshExec, sshStream, assertValidSshTarget, shellQuote, SSH_CONN_FAILURE_CODE } from '../lib/ssh-exec.js';
35
35
  import { connectionEndedNotice } from '../lib/hosts/reconnect.js';
36
36
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
37
+ import { addressabilityRecoveryHint } from '../lib/terminal/resolve.js';
37
38
  const execFileAsync = promisify(execFile);
38
39
  export function registerGoCommand(program) {
39
40
  program
@@ -311,17 +312,17 @@ export async function probeAttachRail(s, self) {
311
312
  }
312
313
  /** Strict attach-only fallback: no pane means no attach. Never open a shell or
313
314
  * start recovery, because both would violate the caller's no-fork intent. */
314
- export async function refuseFallback(s, remote) {
315
+ export async function refuseFallback(s, remote, fallbackId) {
315
316
  if (remote) {
316
317
  console.log(chalk.yellow(`Can't attach ${shortId(s)} on ${remote} — it has no living tmux pane.`));
317
318
  process.exitCode = 1;
318
319
  return;
319
320
  }
320
321
  console.log(chalk.yellow(`Can't jump to ${shortId(s)} — it's in ${s.host ?? 'an unknown terminal'} with no attach rail (not tmux/Ghostty).`) +
321
- chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
322
+ chalk.gray(`\n${addressabilityRecoveryHint(s, fallbackId)}`));
322
323
  process.exitCode = 1;
323
324
  }
324
- export async function jumpTo(s, self, fallback = refuseFallback) {
325
+ export async function jumpTo(s, self, fallback = refuseFallback, fallbackId) {
325
326
  const remote = sessionProcessHost(s, self);
326
327
  const mux = s.provenance?.mux;
327
328
  // Path C: remote tmux — ssh in and attach, resolving the pane's session on the remote.
@@ -329,7 +330,7 @@ export async function jumpTo(s, self, fallback = refuseFallback) {
329
330
  if (mux?.kind === 'tmux' && mux.pane) {
330
331
  const liveness = await probeAttachRail(s, self);
331
332
  if (liveness.state !== 'alive') {
332
- await fallback(s, remote);
333
+ await fallback(s, remote, fallbackId);
333
334
  return;
334
335
  }
335
336
  assertValidSshTarget(remote);
@@ -348,14 +349,14 @@ export async function jumpTo(s, self, fallback = refuseFallback) {
348
349
  process.exit(code);
349
350
  }
350
351
  // Remote, not in tmux → hand off to the fallback (go: shell; focus: resume in a tab).
351
- await fallback(s, remote);
352
+ await fallback(s, remote, fallbackId);
352
353
  return;
353
354
  }
354
355
  // Path B: local tmux — attach (or switch-client if we're already inside tmux).
355
356
  if (mux?.kind === 'tmux' && mux.pane) {
356
357
  const liveness = await probeAttachRail(s, self);
357
358
  if (liveness.state !== 'alive') {
358
- await fallback(s, undefined);
359
+ await fallback(s, undefined, fallbackId);
359
360
  return;
360
361
  }
361
362
  const socket = mux.socket ?? getDefaultSocketPath();
@@ -402,7 +403,7 @@ export async function jumpTo(s, self, fallback = refuseFallback) {
402
403
  return;
403
404
  }
404
405
  // Path D: no attach rail (headless / plain terminal) → hand off to the fallback.
405
- await fallback(s, undefined);
406
+ await fallback(s, undefined, fallbackId);
406
407
  }
407
408
  /** Resolve a local tmux pane id to its session name + window index. */
408
409
  async function resolveLocalPane(socket, pane) {
@@ -246,6 +246,9 @@ function renderReport(groups, dim, meta, actions, harnesses) {
246
246
  for (const g of groups)
247
247
  mergeFacets(all, g.facets);
248
248
  renderCounts('Top tools', topEntries(all.toolCounts, 8), out);
249
+ // The `Bash`/`exec` bar above says a shell ran; this says WHICH binary — git, gh,
250
+ // agents, find, ssh→git — so the tool mix is actionable rather than one lump.
251
+ renderCounts('Shell commands', topEntries(all.bashCommands, 12), out);
249
252
  renderCounts('Languages', topEntries(all.languages, 6), out);
250
253
  renderCounts('Models', topEntries(all.models, 6), out);
251
254
  // Friction — the section that earns the command.
@@ -281,6 +284,9 @@ function renderReport(groups, dim, meta, actions, harnesses) {
281
284
  out.push(` ${chalk.gray('·')} ${padToWidth(e.name, 16)} ${chalk.gray(String(e.count))}`);
282
285
  }
283
286
  renderCounts('Friction / thrash', topEntries(all.frictionSignals, 10), out);
287
+ // Which shell binary was failing when a `failed tool loop: Bash` fired — makes the
288
+ // loop attributable (`git reconcile`, `find`, `gh`) instead of a bare tool name.
289
+ renderCounts('Shell command failures', topEntries(all.bashCommandFailures, 8), out);
284
290
  renderCounts('Dissatisfaction / corrections', topEntries(all.correctionSignals, 10), out);
285
291
  renderCounts('Automatable repeats', topEntries(all.automationSignals, 10), out);
286
292
  renderCounts('Harness split', harnesses, out);
@@ -297,6 +303,9 @@ function renderReport(groups, dim, meta, actions, harnesses) {
297
303
  out.push(` ${padToWidth(action.priority, 7)} ${padToWidth(action.category, 11)} ` +
298
304
  `${String(action.evidenceCount).padStart(8)} ${padToWidth(action.sampleSessionIds.join(', '), 25)} ${action.action}`);
299
305
  }
306
+ // This report is aggregate by contract (SES-IF-4c); a sample id above drills into
307
+ // one session's timeline via the per-session surface, not a single-session mode here.
308
+ out.push(chalk.gray(' drill into a sample session: agents sessions trace <id>'));
300
309
  }
301
310
  // Output
302
311
  out.push('');