@phnx-labs/agents-cli 1.22.53 → 1.22.55

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 (166) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +59 -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 +219 -173
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +10 -8
  16. package/dist/commands/focus.d.ts +1 -0
  17. package/dist/commands/focus.js +2 -2
  18. package/dist/commands/go.d.ts +5 -4
  19. package/dist/commands/go.js +7 -7
  20. package/dist/commands/insights.js +9 -0
  21. package/dist/commands/monitors.js +85 -30
  22. package/dist/commands/output.js +8 -2
  23. package/dist/commands/repo.js +18 -0
  24. package/dist/commands/routines.js +31 -2
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions.d.ts +20 -12
  27. package/dist/commands/sessions.js +64 -20
  28. package/dist/commands/setup-accounts.d.ts +8 -0
  29. package/dist/commands/setup-accounts.js +47 -0
  30. package/dist/commands/setup.d.ts +1 -1
  31. package/dist/commands/setup.js +11 -2
  32. package/dist/commands/share.d.ts +79 -3
  33. package/dist/commands/share.js +347 -18
  34. package/dist/commands/ssh.d.ts +7 -0
  35. package/dist/commands/ssh.js +18 -2
  36. package/dist/commands/status.js +14 -0
  37. package/dist/commands/view.d.ts +11 -1
  38. package/dist/commands/view.js +35 -7
  39. package/dist/lib/account-registry.js +15 -3
  40. package/dist/lib/accounting/rotate.d.ts +20 -6
  41. package/dist/lib/accounting/rotate.js +38 -7
  42. package/dist/lib/accounting/usage.d.ts +68 -1
  43. package/dist/lib/accounting/usage.js +116 -10
  44. package/dist/lib/agent-spec/agents.d.ts +5 -2
  45. package/dist/lib/agent-spec/agents.js +25 -7
  46. package/dist/lib/analytics/mix-commands.js +12 -6
  47. package/dist/lib/auth-mint.d.ts +150 -0
  48. package/dist/lib/auth-mint.js +434 -0
  49. package/dist/lib/browser/cdp.d.ts +1 -1
  50. package/dist/lib/browser/cdp.js +1 -1
  51. package/dist/lib/browser/ffmpeg.d.ts +12 -0
  52. package/dist/lib/browser/ffmpeg.js +184 -0
  53. package/dist/lib/browser/remote-control.d.ts +9 -7
  54. package/dist/lib/browser/remote-control.js +9 -7
  55. package/dist/lib/browser/service.js +119 -25
  56. package/dist/lib/claude-account-token.d.ts +10 -0
  57. package/dist/lib/claude-account-token.js +14 -4
  58. package/dist/lib/config-drift.d.ts +37 -0
  59. package/dist/lib/config-drift.js +72 -0
  60. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  61. package/dist/lib/daemon/auth-sync-service.js +34 -0
  62. package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
  63. package/dist/lib/daemon/browser-task-reap-service.js +26 -0
  64. package/dist/lib/daemon/daemon.js +87 -176
  65. package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
  66. package/dist/lib/daemon/heartbeat-service.js +26 -0
  67. package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
  68. package/dist/lib/daemon/monitor-engine-service.js +15 -7
  69. package/dist/lib/daemon/runner.d.ts +15 -0
  70. package/dist/lib/daemon/runner.js +23 -0
  71. package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
  72. package/dist/lib/daemon/secrets-broker-service.js +17 -32
  73. package/dist/lib/daemon/service.d.ts +2 -2
  74. package/dist/lib/daemon/service.js +1 -1
  75. package/dist/lib/daemon/session-state-service.d.ts +21 -0
  76. package/dist/lib/daemon/session-state-service.js +34 -0
  77. package/dist/lib/daemon/supervisor.d.ts +17 -7
  78. package/dist/lib/daemon/supervisor.js +87 -14
  79. package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
  80. package/dist/lib/daemon/tmux-reap-service.js +28 -0
  81. package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
  82. package/dist/lib/daemon/webhook-receiver-service.js +17 -0
  83. package/dist/lib/daemon-services.d.ts +1 -1
  84. package/dist/lib/daemon-services.js +25 -0
  85. package/dist/lib/device-config.d.ts +3 -3
  86. package/dist/lib/device-config.js +5 -5
  87. package/dist/lib/devices/connect.d.ts +26 -0
  88. package/dist/lib/devices/connect.js +48 -1
  89. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  90. package/dist/lib/devices/doctor-findings.js +19 -1
  91. package/dist/lib/devices/harness-inventory.js +5 -2
  92. package/dist/lib/exec.d.ts +28 -0
  93. package/dist/lib/exec.js +73 -7
  94. package/dist/lib/feed/feed.d.ts +1 -1
  95. package/dist/lib/feed/feed.js +23 -1
  96. package/dist/lib/feed-broadcast.js +1 -1
  97. package/dist/lib/fleet/apply.d.ts +11 -0
  98. package/dist/lib/fleet/apply.js +23 -3
  99. package/dist/lib/fleet/auth-sync.js +5 -3
  100. package/dist/lib/help.d.ts +9 -0
  101. package/dist/lib/help.js +29 -1
  102. package/dist/lib/hosts/passthrough.d.ts +1 -10
  103. package/dist/lib/hosts/passthrough.js +1 -13
  104. package/dist/lib/installations/versions.js +9 -1
  105. package/dist/lib/linux-userns.d.ts +58 -0
  106. package/dist/lib/linux-userns.js +116 -0
  107. package/dist/lib/memory.d.ts +26 -0
  108. package/dist/lib/memory.js +80 -1
  109. package/dist/lib/monitors/config.d.ts +11 -0
  110. package/dist/lib/monitors/config.js +8 -0
  111. package/dist/lib/monitors/engine.d.ts +5 -1
  112. package/dist/lib/monitors/engine.js +13 -4
  113. package/dist/lib/monitors/state.d.ts +37 -1
  114. package/dist/lib/monitors/state.js +79 -4
  115. package/dist/lib/permissions-registry.d.ts +2 -0
  116. package/dist/lib/permissions-registry.js +116 -14
  117. package/dist/lib/permissions.d.ts +5 -3
  118. package/dist/lib/permissions.js +25 -27
  119. package/dist/lib/profiles.d.ts +8 -7
  120. package/dist/lib/profiles.js +12 -0
  121. package/dist/lib/project-key.d.ts +9 -0
  122. package/dist/lib/project-key.js +11 -0
  123. package/dist/lib/scheduling/routines.d.ts +47 -0
  124. package/dist/lib/scheduling/routines.js +70 -1
  125. package/dist/lib/secrets/bundles.d.ts +35 -0
  126. package/dist/lib/secrets/bundles.js +78 -1
  127. package/dist/lib/secrets/push.d.ts +3 -8
  128. package/dist/lib/secrets/push.js +18 -14
  129. package/dist/lib/secrets/remote.d.ts +9 -18
  130. package/dist/lib/secrets/remote.js +11 -26
  131. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  132. package/dist/lib/secrets/reserved-sync.js +129 -0
  133. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  134. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  135. package/dist/lib/self-heal/registry.js +4 -0
  136. package/dist/lib/self-heal/types.d.ts +1 -1
  137. package/dist/lib/session/active.js +1 -4
  138. package/dist/lib/session/db.d.ts +41 -5
  139. package/dist/lib/session/db.js +132 -30
  140. package/dist/lib/session/discover.d.ts +32 -4
  141. package/dist/lib/session/discover.js +119 -25
  142. package/dist/lib/session/insights.d.ts +14 -0
  143. package/dist/lib/session/insights.js +25 -2
  144. package/dist/lib/session/linear.js +1 -1
  145. package/dist/lib/session/shell-programs.d.ts +17 -0
  146. package/dist/lib/session/shell-programs.js +21 -0
  147. package/dist/lib/session/state.js +2 -1
  148. package/dist/lib/session/stream-render.js +2 -1
  149. package/dist/lib/session/tool-calls.js +2 -5
  150. package/dist/lib/session/trajectory-html.js +2 -1
  151. package/dist/lib/session/trajectory.js +3 -12
  152. package/dist/lib/session/types.d.ts +8 -0
  153. package/dist/lib/share/capture.js +11 -2
  154. package/dist/lib/share/publish.d.ts +56 -5
  155. package/dist/lib/share/publish.js +126 -18
  156. package/dist/lib/share/worker-template.d.ts +3 -12
  157. package/dist/lib/share/worker-template.js +860 -59
  158. package/dist/lib/startup/root-command.js +2 -1
  159. package/dist/lib/state.d.ts +16 -0
  160. package/dist/lib/state.js +178 -46
  161. package/dist/lib/sync-status.d.ts +4 -0
  162. package/dist/lib/sync-status.js +3 -0
  163. package/dist/lib/traces/classify.js +24 -19
  164. package/dist/lib/usage-refresh.js +2 -1
  165. package/dist/lib/view-types.d.ts +7 -0
  166. package/package.json +9 -2
@@ -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
@@ -2368,13 +2368,15 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2368
2368
  process.exit(1);
2369
2369
  }
2370
2370
  const strategy = options.balanced ? 'balanced' : explicitStrategy ?? configuredStrategy;
2371
- // Strategy only applies to bare agent invocations. Explicit @version and
2372
- // profiles already define their execution target. A --fallback chain does
2373
- // NOT pin the primary: it only names where to cascade on a rate limit, so
2374
- // 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
2375
2375
  // `agents run claude --fallback codex` run lands on the pinned default
2376
2376
  // account and account rotation silently stops (the gh-monitor heal bug).
2377
- 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)) {
2378
2380
  if (version) {
2379
2381
  process.stderr.write(chalk.yellow(`[agents] strategy ${strategy} ignored: version ${version} is pinned\n`));
2380
2382
  }
@@ -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>;
@@ -494,10 +494,10 @@ async function pickFocusCandidates(sessions, liveById, initialSearch) {
494
494
  export function focusTargetForResolved(poolMatch, resolved) {
495
495
  return poolMatch ?? resolved;
496
496
  }
497
- async function focusResolvedSession(meta, liveById, self, fallback, attachOnly, reconnectReattach = false) {
497
+ export async function focusResolvedSession(meta, liveById, self, fallback, attachOnly, reconnectReattach = false) {
498
498
  const active = liveById.get(meta.id);
499
499
  if (active && isAttachableLiveSession(active)) {
500
- await jumpTo(active, self, fallback);
500
+ await jumpTo(active, self, fallback, meta.id);
501
501
  return;
502
502
  }
503
503
  if (attachOnly) {
@@ -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>;
@@ -312,17 +312,17 @@ export async function probeAttachRail(s, self) {
312
312
  }
313
313
  /** Strict attach-only fallback: no pane means no attach. Never open a shell or
314
314
  * start recovery, because both would violate the caller's no-fork intent. */
315
- export async function refuseFallback(s, remote) {
315
+ export async function refuseFallback(s, remote, fallbackId) {
316
316
  if (remote) {
317
317
  console.log(chalk.yellow(`Can't attach ${shortId(s)} on ${remote} — it has no living tmux pane.`));
318
318
  process.exitCode = 1;
319
319
  return;
320
320
  }
321
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).`) +
322
- chalk.gray(`\n${addressabilityRecoveryHint(s)}`));
322
+ chalk.gray(`\n${addressabilityRecoveryHint(s, fallbackId)}`));
323
323
  process.exitCode = 1;
324
324
  }
325
- export async function jumpTo(s, self, fallback = refuseFallback) {
325
+ export async function jumpTo(s, self, fallback = refuseFallback, fallbackId) {
326
326
  const remote = sessionProcessHost(s, self);
327
327
  const mux = s.provenance?.mux;
328
328
  // Path C: remote tmux — ssh in and attach, resolving the pane's session on the remote.
@@ -330,7 +330,7 @@ export async function jumpTo(s, self, fallback = refuseFallback) {
330
330
  if (mux?.kind === 'tmux' && mux.pane) {
331
331
  const liveness = await probeAttachRail(s, self);
332
332
  if (liveness.state !== 'alive') {
333
- await fallback(s, remote);
333
+ await fallback(s, remote, fallbackId);
334
334
  return;
335
335
  }
336
336
  assertValidSshTarget(remote);
@@ -349,14 +349,14 @@ export async function jumpTo(s, self, fallback = refuseFallback) {
349
349
  process.exit(code);
350
350
  }
351
351
  // Remote, not in tmux → hand off to the fallback (go: shell; focus: resume in a tab).
352
- await fallback(s, remote);
352
+ await fallback(s, remote, fallbackId);
353
353
  return;
354
354
  }
355
355
  // Path B: local tmux — attach (or switch-client if we're already inside tmux).
356
356
  if (mux?.kind === 'tmux' && mux.pane) {
357
357
  const liveness = await probeAttachRail(s, self);
358
358
  if (liveness.state !== 'alive') {
359
- await fallback(s, undefined);
359
+ await fallback(s, undefined, fallbackId);
360
360
  return;
361
361
  }
362
362
  const socket = mux.socket ?? getDefaultSocketPath();
@@ -403,7 +403,7 @@ export async function jumpTo(s, self, fallback = refuseFallback) {
403
403
  return;
404
404
  }
405
405
  // Path D: no attach rail (headless / plain terminal) → hand off to the fallback.
406
- await fallback(s, undefined);
406
+ await fallback(s, undefined, fallbackId);
407
407
  }
408
408
  /** Resolve a local tmux pane id to its session name + window index. */
409
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('');
@@ -24,6 +24,7 @@ import { IS_WINDOWS } from '../lib/platform/index.js';
24
24
  import { safeJoin } from '../lib/paths.js';
25
25
  import { machineId, normalizeHost } from '../lib/machine-id.js';
26
26
  import { loadDevices } from '../lib/devices/registry.js';
27
+ import { assertDaemonEnabled } from '../lib/device-config.js';
27
28
  import { setHelpSections } from '../lib/help.js';
28
29
  import { isInteractiveTerminal, requireInteractiveSelection } from './utils.js';
29
30
  function stdoutJson(payload) {
@@ -52,6 +53,26 @@ function sourceLabel(source) {
52
53
  return source.type;
53
54
  }
54
55
  }
56
+ /** Warn when a run/routine action has no postcondition — completed-exit-0 still records as ok (PHNX-2842). */
57
+ function warnMissingPostcondition(action) {
58
+ if ((action.type === 'run' || action.type === 'routine') && !action.postcondition) {
59
+ stderrLine(chalk.yellow(` Note: ${action.type} has no --postcondition — a completed agent that did nothing will still record as ok. Add --postcondition '<cmd>' that exits 0 when the intended effect happened.`));
60
+ }
61
+ }
62
+ /** Reconciled one-line outcome for a fire (ok / no effect / failed). */
63
+ function fireOutcomeDisplay(name, f) {
64
+ const rec = resolveFireOutcome(name, f);
65
+ if (rec.ok)
66
+ return { label: chalk.green('ok'), note: '' };
67
+ if (rec.effect === 'none') {
68
+ const note = rec.error ? chalk.gray(` ${rec.error}`) : '';
69
+ return { label: chalk.yellow('no effect'), note };
70
+ }
71
+ const corrected = f.ok !== false && rec.runStatus
72
+ ? chalk.yellow(` (run ${rec.runStatus})`)
73
+ : '';
74
+ return { label: chalk.red('failed') + corrected, note: '' };
75
+ }
55
76
  /** A one-line human label for a monitor's action. */
56
77
  function actionLabel(action) {
57
78
  switch (action.type) {
@@ -123,12 +144,26 @@ function livenessLabel(monitor, state, liveness) {
123
144
  }
124
145
  return chalk.gray(`checked ${liveness.checkCount}x · last ${checkedAgo} · no match yet`);
125
146
  }
126
- /** Start or reload the background daemon so a newly-added monitor is watched. */
147
+ /**
148
+ * Start or reload the background daemon so a newly-added monitor is watched.
149
+ * Returns false when auto-start was refused (`daemon.enabled=false`); the add
150
+ * itself already succeeded — the monitor is config and stays valid fleet-wide.
151
+ * The refusal names the setting and the fix. Mirrors `ensureSchedulerRunning`
152
+ * in commands/routines.ts (PHNX-2637).
153
+ */
127
154
  function ensureDaemonRunning() {
155
+ try {
156
+ assertDaemonEnabled();
157
+ }
158
+ catch (err) {
159
+ // Loud stated skip, on stderr so --json stdout stays clean.
160
+ stderrLine(chalk.yellow(err.message));
161
+ return false;
162
+ }
128
163
  if (isDaemonRunning()) {
129
164
  signalDaemonReload();
130
165
  stderrLine(chalk.gray('Daemon reloaded'));
131
- return;
166
+ return true;
132
167
  }
133
168
  const result = startDaemon();
134
169
  if (result.pid) {
@@ -138,6 +173,7 @@ function ensureDaemonRunning() {
138
173
  else {
139
174
  stderrLine(chalk.yellow('Could not start the daemon. Start it manually with: agents routines start'));
140
175
  }
176
+ return true;
141
177
  }
142
178
  /**
143
179
  * Assert the postcondition of `add`: the engine actually picked the monitor up.
@@ -272,10 +308,16 @@ function buildAction(options) {
272
308
  action.effort = options.effort;
273
309
  if (options.actionTimeout)
274
310
  action.timeout = options.actionTimeout;
311
+ if (options.postcondition)
312
+ action.postcondition = options.postcondition;
313
+ chosen.push(action);
314
+ }
315
+ if (options.routine) {
316
+ const action = { type: 'routine', routine: options.routine };
317
+ if (options.postcondition)
318
+ action.postcondition = options.postcondition;
275
319
  chosen.push(action);
276
320
  }
277
- if (options.routine)
278
- chosen.push({ type: 'routine', routine: options.routine });
279
321
  if (options.notify !== undefined) {
280
322
  // --notify may be a bare flag (notify the owner) or carry a channel that
281
323
  // overrides notify.owner.channel. Left unset, the send resolves the owner
@@ -293,6 +335,10 @@ function buildAction(options) {
293
335
  stderrLine(chalk.red(`Exactly one action is allowed; got ${chosen.map((c) => c.type).join(', ')}`));
294
336
  process.exit(1);
295
337
  }
338
+ if (options.postcondition && chosen[0].type !== 'run' && chosen[0].type !== 'routine') {
339
+ stderrLine(chalk.red('--postcondition only applies to --run or --routine'));
340
+ process.exit(1);
341
+ }
296
342
  return chosen[0];
297
343
  }
298
344
  /** Interactive monitor picker. Returns the selected name or null on cancel/empty. */
@@ -379,7 +425,7 @@ async function guardAgainstDuplicateMonitor(config, force) {
379
425
  export function registerMonitorsCommands(program) {
380
426
  const monitorsCmd = program
381
427
  .command('monitors')
382
- .description('Durable event-triggered watchers: watch a source, detect a change, fire an action. The daemon auto-starts on first add.');
428
+ .description('Durable event-triggered watchers: watch a source, detect a change, fire an action. The daemon auto-starts on first add unless daemon.enabled is false.');
383
429
  setHelpSections(monitorsCmd, {
384
430
  examples: `
385
431
  # CI went red → triage it (poll a command, diff, fire an agent)
@@ -388,6 +434,12 @@ export function registerMonitorsCommands(program) {
388
434
  --run claude --prompt 'CI failed on #1119: {event}. Diagnose and fix.' \\
389
435
  --device yosemite-s0
390
436
 
437
+ # Merge-on-green: the fire is ok only if the PR actually merged
438
+ agents monitors add merge-1682 \\
439
+ --poll 'gh pr view 1682 --json state --jq .state' 2m --match OPEN \\
440
+ --run claude --prompt 'Rebase-merge #1682: {event}' \\
441
+ --postcondition 'gh pr view 1682 --json state --jq .state | grep -qx MERGED'
442
+
391
443
  # SSL cert issued → notify (poll an HTTPS endpoint every 8h)
392
444
  agents monitors add cert-issued \\
393
445
  --poll-http 'https://secure.ssl.com/team/.../co-ec1l5dgjofa' 8h \\
@@ -406,10 +458,14 @@ export function registerMonitorsCommands(program) {
406
458
  - SOURCE (--watch, --poll, --poll-http, --ws, --watch-file, --watch-device, --on)
407
459
  - CONDITION (--on-change [default], --match <re>, --every; --dedupe-key)
408
460
  - ACTION (--run <agent> --prompt, --routine, --notify, --webhook-out)
461
+ --postcondition <cmd> on --run/--routine asserts the effect
462
+ happened (exit 0) after the agent settles; otherwise the
463
+ fire records as "no effect", not ok.
409
464
 
410
465
  The fired event is injected into a run/routine prompt as {event}.
411
466
  Pin the single OWNER device with --device (exactly-once). The daemon (shared
412
- with routines) auto-starts on first add; manage it with 'agents routines start|stop'.
467
+ with routines) auto-starts on first add unless daemon.enabled is false on this
468
+ device; manage it with 'agents daemon start|stop' (or 'agents routines start|stop').
413
469
 
414
470
  v1 evaluates poll sources (command, poll, poll-http, file, device). Push
415
471
  sources (ws, webhook) are accepted but delivered through a receiver wired in
@@ -419,7 +475,7 @@ export function registerMonitorsCommands(program) {
419
475
  // ─── add ────────────────────────────────────────────────────────────────────
420
476
  monitorsCmd
421
477
  .command('add [nameOrPath]')
422
- .description('Create a monitor from inline flags or a YAML file. Auto-starts the daemon.')
478
+ .description('Create a monitor from inline flags or a YAML file. Auto-starts the daemon unless daemon.enabled is false.')
423
479
  // SOURCE
424
480
  .option('--watch <cmd>', 'Run a shell command; its stdout is the observation')
425
481
  .option('--poll <cmd...>', 'Re-run a command every interval: --poll "<cmd>" <interval> (e.g. 30s)')
@@ -445,6 +501,7 @@ export function registerMonitorsCommands(program) {
445
501
  .option('--mode <mode>', 'Execution mode for --run: plan, edit, auto, or skip')
446
502
  .option('--effort <effort>', 'Reasoning effort for --run: low | medium | high | xhigh | max | auto')
447
503
  .option('--action-timeout <t>', 'Kill the --run action if it runs longer than this (e.g. 10m)')
504
+ .option('--postcondition <cmd>', 'Shell command that must exit 0 after a --run/--routine action settles; otherwise the fire records as no-effect, not ok. {event} is replaced with the fired event summary')
448
505
  .option('--routine <name>', 'Fire an existing routine on change')
449
506
  .option('--notify [channel]', 'Notify the owner (notify.owner); [channel] overrides the owner channel')
450
507
  .option('--webhook-out <url>', 'POST the event to this URL')
@@ -481,8 +538,9 @@ export function registerMonitorsCommands(program) {
481
538
  await guardAgainstDuplicateMonitor(config, options.force === true);
482
539
  writeMonitor(config);
483
540
  console.log(chalk.green(`Monitor '${name}' added`));
484
- ensureDaemonRunning();
485
- await assertEnginePickup(config);
541
+ warnMissingPostcondition(config.action);
542
+ if (ensureDaemonRunning())
543
+ await assertEnginePickup(config);
486
544
  return;
487
545
  }
488
546
  if (!nameOrPath) {
@@ -556,8 +614,9 @@ export function registerMonitorsCommands(program) {
556
614
  writeMonitor(config);
557
615
  console.log(chalk.green(`Monitor '${nameOrPath}' added`));
558
616
  console.log(chalk.gray(` ${sourceLabel(source)} → [${condition.mode}] → ${actionLabel(action)} · owner: ${ownerLabel(config)}`));
559
- ensureDaemonRunning();
560
- await assertEnginePickup(config);
617
+ warnMissingPostcondition(action);
618
+ if (ensureDaemonRunning())
619
+ await assertEnginePickup(config);
561
620
  });
562
621
  // ─── list ────────────────────────────────────────────────────────────────────
563
622
  monitorsCmd
@@ -641,7 +700,7 @@ export function registerMonitorsCommands(program) {
641
700
  state,
642
701
  liveness,
643
702
  stalled: isStalled(monitor, liveness),
644
- recentFires,
703
+ recentFires: recentFires.map((f) => ({ ...f, reconciled: resolveFireOutcome(name, f) })),
645
704
  });
646
705
  return;
647
706
  }
@@ -671,9 +730,11 @@ export function registerMonitorsCommands(program) {
671
730
  if (recentFires.length > 0) {
672
731
  console.log(chalk.bold('\nRecent fires'));
673
732
  for (const f of recentFires) {
674
- // Reconciled against the run's real status — see `runs`, above (RUSH-2690).
675
- const { ok } = resolveFireOutcome(name, f);
676
- console.log(` ${chalk.gray(f.firedAt)} ${f.action ?? '?'} ${ok ? chalk.green('ok') : chalk.red('failed')}`);
733
+ // Reconciled against the run's real status + postcondition (RUSH-2690, PHNX-2842).
734
+ const { label, note } = fireOutcomeDisplay(name, f);
735
+ console.log(` ${chalk.gray(f.firedAt)} ${f.action ?? '?'} ${label}`);
736
+ if (note)
737
+ console.log(note);
677
738
  }
678
739
  }
679
740
  });
@@ -847,23 +908,17 @@ export function registerMonitorsCommands(program) {
847
908
  }
848
909
  console.log(chalk.bold(`Fire history: ${name}\n`));
849
910
  for (const f of fires.slice(-20)) {
850
- // Reconcile against the run's REAL, current status (RUSH-2690) rather
851
- // than trusting the frozen `ok` written at fire time — `dispatchAction`
852
- // only sees a synchronous 'running' snapshot before the run has
853
- // actually settled, so a run that later fails/times out/never produces
854
- // output would otherwise read `ok` here forever while `agents monitors
855
- // logs` (which reads the run record fresh) already shows the truth.
856
- const { ok, runStatus } = resolveFireOutcome(name, f);
857
- const outcome = ok ? chalk.green('ok') : chalk.red('failed');
858
- // Surface the divergence explicitly when the frozen write disagreed
859
- // with the reconciled read — the exact "fire says ok, run says
860
- // otherwise" symptom RUSH-2690 reported.
861
- const corrected = f.ok !== false && !ok && runStatus
862
- ? chalk.yellow(` (run ${runStatus})`)
863
- : '';
911
+ // Reconcile against the run's REAL, current status (RUSH-2690) and a
912
+ // declared postcondition (PHNX-2842) rather than trusting the frozen
913
+ // `ok` written at fire time — `dispatchAction` only sees a synchronous
914
+ // 'running' snapshot before the run has actually settled, and a
915
+ // `completed` agent that did nothing used to read `ok` here forever.
916
+ const { label, note } = fireOutcomeDisplay(name, f);
864
917
  const runRef = f.runId ? chalk.gray(` run ${f.runId}`) : '';
865
- console.log(` ${f.firedAt} ${(f.action ?? '?').padEnd(12)} ${outcome}${corrected}${runRef}`);
918
+ console.log(` ${f.firedAt} ${(f.action ?? '?').padEnd(12)} ${label}${runRef}`);
866
919
  console.log(chalk.gray(` ${f.summary.slice(0, 100)}`));
920
+ if (note)
921
+ console.log(note);
867
922
  }
868
923
  });
869
924
  // ─── pause / resume ───────────────────────────────────────────────────────────