@phnx-labs/agents-cli 1.20.77 → 1.20.82

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 (212) hide show
  1. package/CHANGELOG.md +589 -0
  2. package/README.md +27 -25
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +9 -0
  5. package/dist/commands/activity.js +153 -24
  6. package/dist/commands/apply.js +11 -4
  7. package/dist/commands/cli.js +1 -1
  8. package/dist/commands/commands.d.ts +1 -1
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/defaults.js +3 -11
  11. package/dist/commands/doctor.d.ts +74 -0
  12. package/dist/commands/doctor.js +718 -40
  13. package/dist/commands/events.js +1 -1
  14. package/dist/commands/exec.js +53 -8
  15. package/dist/commands/feed.d.ts +4 -0
  16. package/dist/commands/feed.js +87 -11
  17. package/dist/commands/harness.js +2 -2
  18. package/dist/commands/hooks.js +1 -1
  19. package/dist/commands/import.js +8 -4
  20. package/dist/commands/logs.js +6 -0
  21. package/dist/commands/models.js +1 -1
  22. package/dist/commands/packages.js +6 -6
  23. package/dist/commands/permissions.js +1 -1
  24. package/dist/commands/profiles.js +1 -1
  25. package/dist/commands/pull.d.ts +1 -1
  26. package/dist/commands/pull.js +4 -4
  27. package/dist/commands/repo.d.ts +78 -0
  28. package/dist/commands/repo.js +195 -11
  29. package/dist/commands/routines.js +16 -4
  30. package/dist/commands/rules.js +13 -12
  31. package/dist/commands/secrets.d.ts +3 -1
  32. package/dist/commands/secrets.js +108 -34
  33. package/dist/commands/sessions-browser.d.ts +45 -3
  34. package/dist/commands/sessions-browser.js +118 -12
  35. package/dist/commands/sessions-export.d.ts +3 -0
  36. package/dist/commands/sessions-export.js +13 -10
  37. package/dist/commands/sessions-inject.d.ts +7 -4
  38. package/dist/commands/sessions-inject.js +18 -18
  39. package/dist/commands/sessions-picker.d.ts +15 -1
  40. package/dist/commands/sessions-picker.js +90 -7
  41. package/dist/commands/sessions-resume.d.ts +16 -0
  42. package/dist/commands/sessions-resume.js +14 -13
  43. package/dist/commands/sessions.d.ts +126 -1
  44. package/dist/commands/sessions.js +365 -42
  45. package/dist/commands/set.d.ts +15 -0
  46. package/dist/commands/set.js +79 -0
  47. package/dist/commands/setup-fleet.d.ts +21 -0
  48. package/dist/commands/setup-fleet.js +201 -0
  49. package/dist/commands/setup-secrets.d.ts +20 -0
  50. package/dist/commands/setup-secrets.js +223 -0
  51. package/dist/commands/setup.js +17 -3
  52. package/dist/commands/skills.js +1 -1
  53. package/dist/commands/ssh.js +9 -1
  54. package/dist/commands/sync.js +7 -2
  55. package/dist/commands/teams-picker.js +0 -1
  56. package/dist/commands/teams.d.ts +33 -1
  57. package/dist/commands/teams.js +208 -44
  58. package/dist/commands/versions.js +10 -5
  59. package/dist/commands/view.d.ts +1 -0
  60. package/dist/commands/view.js +11 -0
  61. package/dist/index.js +8 -13
  62. package/dist/lib/acp/harnesses.js +0 -7
  63. package/dist/lib/activity.d.ts +156 -0
  64. package/dist/lib/activity.js +282 -0
  65. package/dist/lib/agents.d.ts +9 -23
  66. package/dist/lib/agents.js +72 -100
  67. package/dist/lib/auto-pull.d.ts +16 -8
  68. package/dist/lib/auto-pull.js +23 -28
  69. package/dist/lib/browser/service.js +3 -0
  70. package/dist/lib/browser/types.d.ts +7 -0
  71. package/dist/lib/capabilities.js +6 -2
  72. package/dist/lib/crabbox/lease.js +2 -2
  73. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  74. package/dist/lib/crabbox/setup-copy.js +4 -4
  75. package/dist/lib/daemon.d.ts +26 -40
  76. package/dist/lib/daemon.js +63 -123
  77. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  78. package/dist/lib/devices/fleet-divergence.js +188 -0
  79. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  80. package/dist/lib/devices/fleet-inventory.js +57 -0
  81. package/dist/lib/devices/fleet.d.ts +1 -1
  82. package/dist/lib/devices/fleet.js +1 -1
  83. package/dist/lib/devices/health-report.d.ts +10 -2
  84. package/dist/lib/devices/health-report.js +32 -1
  85. package/dist/lib/doctor-diff.d.ts +20 -0
  86. package/dist/lib/doctor-diff.js +6 -1
  87. package/dist/lib/drift.d.ts +31 -11
  88. package/dist/lib/drift.js +58 -7
  89. package/dist/lib/events.d.ts +8 -1
  90. package/dist/lib/events.js +10 -1
  91. package/dist/lib/exec.js +35 -8
  92. package/dist/lib/feed-post.d.ts +28 -1
  93. package/dist/lib/feed-post.js +110 -2
  94. package/dist/lib/fleet/apply.js +8 -1
  95. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  96. package/dist/lib/fleet/auth-sync.js +25 -11
  97. package/dist/lib/fleet/remote-login.js +5 -0
  98. package/dist/lib/git.d.ts +27 -14
  99. package/dist/lib/git.js +138 -43
  100. package/dist/lib/hooks.d.ts +42 -0
  101. package/dist/lib/hooks.js +137 -93
  102. package/dist/lib/hosts/dispatch.d.ts +28 -1
  103. package/dist/lib/hosts/dispatch.js +33 -5
  104. package/dist/lib/hosts/option.js +2 -2
  105. package/dist/lib/hosts/passthrough.d.ts +22 -1
  106. package/dist/lib/hosts/passthrough.js +238 -13
  107. package/dist/lib/hosts/remote-cmd.js +2 -0
  108. package/dist/lib/hosts/run-target.d.ts +2 -0
  109. package/dist/lib/hosts/run-target.js +1 -0
  110. package/dist/lib/hq/floor.d.ts +1 -1
  111. package/dist/lib/hq/floor.js +6 -1
  112. package/dist/lib/mcp.js +3 -71
  113. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  116. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  117. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  118. package/dist/lib/menubar/notify-desktop.js +41 -5
  119. package/dist/lib/merged-resources.d.ts +11 -0
  120. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  121. package/dist/lib/permissions.d.ts +0 -32
  122. package/dist/lib/permissions.js +4 -157
  123. package/dist/lib/picker.js +1 -1
  124. package/dist/lib/platform/process.d.ts +16 -5
  125. package/dist/lib/platform/process.js +54 -0
  126. package/dist/lib/plugins.d.ts +0 -8
  127. package/dist/lib/plugins.js +4 -110
  128. package/dist/lib/project-resources.js +4 -1
  129. package/dist/lib/refresh.d.ts +5 -3
  130. package/dist/lib/refresh.js +7 -5
  131. package/dist/lib/resources/commands.js +4 -1
  132. package/dist/lib/resources/mcp.js +0 -4
  133. package/dist/lib/resources/permissions.d.ts +1 -1
  134. package/dist/lib/resources/permissions.js +1 -5
  135. package/dist/lib/resources/rules.js +4 -1
  136. package/dist/lib/resources/skills.js +4 -1
  137. package/dist/lib/resources/subagents.js +4 -0
  138. package/dist/lib/resources/types.d.ts +1 -1
  139. package/dist/lib/routines.d.ts +20 -0
  140. package/dist/lib/routines.js +6 -0
  141. package/dist/lib/run-defaults.d.ts +1 -0
  142. package/dist/lib/run-defaults.js +9 -0
  143. package/dist/lib/runner.d.ts +0 -12
  144. package/dist/lib/runner.js +71 -29
  145. package/dist/lib/sandbox.js +0 -16
  146. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  147. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  148. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  149. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  150. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  151. package/dist/lib/secrets/agent.d.ts +18 -0
  152. package/dist/lib/secrets/agent.js +131 -17
  153. package/dist/lib/secrets/audit.d.ts +46 -0
  154. package/dist/lib/secrets/audit.js +56 -0
  155. package/dist/lib/secrets/bundles.d.ts +12 -9
  156. package/dist/lib/secrets/bundles.js +47 -31
  157. package/dist/lib/secrets/index.d.ts +2 -1
  158. package/dist/lib/secrets/index.js +8 -2
  159. package/dist/lib/secrets/remote.d.ts +75 -2
  160. package/dist/lib/secrets/remote.js +139 -5
  161. package/dist/lib/secrets/scope.d.ts +26 -0
  162. package/dist/lib/secrets/scope.js +29 -0
  163. package/dist/lib/secrets/session-store.d.ts +10 -0
  164. package/dist/lib/secrets/session-store.js +64 -11
  165. package/dist/lib/secrets/sync.js +3 -3
  166. package/dist/lib/session/active.d.ts +117 -26
  167. package/dist/lib/session/active.js +199 -53
  168. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  169. package/dist/lib/session/actor-sidecar.js +101 -0
  170. package/dist/lib/session/db.d.ts +41 -1
  171. package/dist/lib/session/db.js +173 -8
  172. package/dist/lib/session/digest.js +126 -21
  173. package/dist/lib/session/discover.d.ts +19 -2
  174. package/dist/lib/session/discover.js +32 -11
  175. package/dist/lib/session/hook-sessions.d.ts +9 -0
  176. package/dist/lib/session/hook-sessions.js +57 -8
  177. package/dist/lib/session/origin-machine.d.ts +18 -0
  178. package/dist/lib/session/origin-machine.js +34 -0
  179. package/dist/lib/session/pid-registry.d.ts +13 -0
  180. package/dist/lib/session/render.d.ts +2 -0
  181. package/dist/lib/session/render.js +1 -1
  182. package/dist/lib/session/state.d.ts +1 -0
  183. package/dist/lib/session/state.js +1 -1
  184. package/dist/lib/session/sync/config.js +2 -2
  185. package/dist/lib/session/types.d.ts +16 -0
  186. package/dist/lib/smart-launch.d.ts +86 -0
  187. package/dist/lib/smart-launch.js +172 -0
  188. package/dist/lib/staleness/detectors/commands.js +2 -2
  189. package/dist/lib/staleness/detectors/permissions.js +1 -45
  190. package/dist/lib/staleness/writers/commands.js +4 -5
  191. package/dist/lib/staleness/writers/hooks.js +1 -1
  192. package/dist/lib/startup/command-registry.d.ts +1 -2
  193. package/dist/lib/startup/command-registry.js +2 -4
  194. package/dist/lib/subagents-registry.js +5 -12
  195. package/dist/lib/subagents.d.ts +0 -10
  196. package/dist/lib/subagents.js +0 -12
  197. package/dist/lib/teams/agents.d.ts +0 -2
  198. package/dist/lib/teams/agents.js +10 -27
  199. package/dist/lib/teams/index.d.ts +1 -1
  200. package/dist/lib/teams/index.js +1 -1
  201. package/dist/lib/types.d.ts +17 -9
  202. package/dist/lib/usage.d.ts +28 -5
  203. package/dist/lib/usage.js +271 -8
  204. package/dist/lib/versions.js +7 -1
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +25 -13
  207. package/package.json +1 -2
  208. package/dist/commands/check.d.ts +0 -15
  209. package/dist/commands/check.js +0 -180
  210. package/dist/commands/resources.d.ts +0 -5
  211. package/dist/lib/secrets/account-token.d.ts +0 -20
  212. package/dist/lib/secrets/account-token.js +0 -64
@@ -15,19 +15,21 @@ import * as path from 'path';
15
15
  import { SSH_TARGET_RE, assertValidSshTarget, sshExec } from '../lib/ssh-exec.js';
16
16
  import { quoteWin32ExecArg, composeWin32CommandLine } from '../lib/platform/index.js';
17
17
  import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
18
- import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, } from '../lib/secrets/remote.js';
18
+ import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, verifyRemoteKeychainPush, keychainWriteFailureMessage, } from '../lib/secrets/remote.js';
19
19
  import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
20
20
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
21
21
  import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, readBundleIfDecryptable, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, } from '../lib/secrets/bundles.js';
22
22
  import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
23
23
  import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
24
24
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
25
+ import { GLOBAL_HARNESS } from '../lib/secrets/scope.js';
25
26
  import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
26
27
  import { saveSession, deleteBundleSessions, deleteAllSessions } from '../lib/secrets/session-store.js';
27
28
  import { getCliVersionFresh } from '../lib/version.js';
28
29
  import { readMeta } from '../lib/state.js';
29
30
  import { parseDuration } from '../lib/hooks/cache.js';
30
31
  import { emit, query } from '../lib/events.js';
32
+ import { emitSecretAudit } from '../lib/secrets/audit.js';
31
33
  import { frequentlyPromptedBundles } from '../lib/secrets/unlock-hints.js';
32
34
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
33
35
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -467,7 +469,7 @@ export function renderPolicyCol(b, held) {
467
469
  if (bundlePolicy(b) === 'always')
468
470
  return chalk.yellow('always · prompt');
469
471
  const exp = held?.get(b.name);
470
- return exp ? chalk.green(`daily · held ${compactRemaining(exp)}`) : chalk.gray('daily');
472
+ return exp ? chalk.green(`hold · held ${compactRemaining(exp)}`) : chalk.gray('hold');
471
473
  }
472
474
  /** Human-readable hold window for `secrets status`. Sub-hour values render in
473
475
  * minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
@@ -668,7 +670,7 @@ function countExpiringSoon(meta) {
668
670
  * guard can't drift between them.
669
671
  */
670
672
  function resolveImportBundle(name, backendOpt, synced = false) {
671
- const requestedBackend = synced ? 'vault' : parseBackendOpt(backendOpt);
673
+ const requestedBackend = synced ? 'vault' : resolveBackendOpt(backendOpt);
672
674
  if (bundleExists(name)) {
673
675
  const bundle = readBundle(name);
674
676
  if (requestedBackend !== 'keychain' && bundle.backend !== requestedBackend) {
@@ -734,7 +736,7 @@ export function registerSecretsCommands(program) {
734
736
  agents secrets view prod
735
737
 
736
738
  # Stop a noisy automation bundle from prompting every run: ask once a week
737
- agents secrets policy prod daily
739
+ agents secrets policy prod hold
738
740
 
739
741
  # Eval the bundle into your current shell
740
742
  eval "$(agents secrets export prod --plaintext)"
@@ -752,17 +754,21 @@ export function registerSecretsCommands(program) {
752
754
 
753
755
  Touch ID noise: macOS pops a prompt per bundle per process. Each bundle has
754
756
  a prompt policy, shown in the POLICY column of 'agents secrets list':
755
- daily (default) ask once, then hold it silently in the local agent up
756
- to ~7 days, until sleep / logout or 'lock' (a bare
757
- screen-lock does NOT drop it). Name is historical.
757
+ hold (default) ask once, then serve it silently from the local agent
758
+ for the hold window ('secrets.agent.holdMs', 7d by
759
+ default), until sleep / logout or 'lock' (a bare
760
+ screen-lock does NOT drop it). Accepts the older names
761
+ 'daily' and 'session'.
758
762
  always ask for Touch ID every time — never auto-held.
759
- The default is 'daily' (one Touch ID per ~7 days); change it globally with
763
+ never no biometry ACL at all reads are silent forever, even
764
+ if the agent is down. Automation credentials only.
765
+ The default is 'hold' (one Touch ID per hold window); change it globally with
760
766
  'secrets.policy' in agents.yaml, or per bundle with 'agents secrets policy
761
767
  <bundle> always'. 'agents secrets unlock <bundle>' holds any bundle after one
762
768
  prompt regardless of policy. Nothing on disk.
763
769
 
764
770
  See also:
765
- agents secrets policy <bundle> daily ask once a week, not every run
771
+ agents secrets policy <bundle> hold ask once per hold window (7d by default), not every run
766
772
  agents secrets unlock <bundle> hold a bundle after one Touch ID
767
773
  agents secrets lock wipe held bundles (re-prompt next read)
768
774
  agents secrets status show held bundles + when they lock
@@ -788,6 +794,8 @@ export function registerSecretsCommands(program) {
788
794
  .description('List configured secrets bundles (use --host/--hosts to list bundles on other machines over SSH)')
789
795
  .option('--host <target>', 'List bundles on a remote host over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
790
796
  .option('--hosts <list>', 'Comma-separated hosts to list in one shot, e.g. yosemite-s0,yosemite-s1')
797
+ .option('--device <target>', 'Alias for --host')
798
+ .option('--devices <list>', 'Alias for --hosts')
791
799
  .option('--json', 'Emit machine-readable JSON (bundle metadata only — never secret values) instead of the table')
792
800
  .action(async (opts) => {
793
801
  const targets = parseHostsOption(opts);
@@ -855,6 +863,8 @@ export function registerSecretsCommands(program) {
855
863
  .option('--json', 'Emit machine-readable JSON (values masked unless --reveal) instead of the human view')
856
864
  .option('--host <target>', 'Show a bundle on a remote host over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
857
865
  .option('--hosts <list>', 'Comma-separated hosts to show in one shot, e.g. yosemite-s0,yosemite-s1')
866
+ .option('--device <target>', 'Alias for --host')
867
+ .option('--devices <list>', 'Alias for --hosts')
858
868
  .action(async (name, opts) => {
859
869
  try {
860
870
  const targets = parseHostsOption(opts);
@@ -912,8 +922,8 @@ export function registerSecretsCommands(program) {
912
922
  }
913
923
  const exposed = revealed.size + entries.filter((e) => e.kind === 'literal').length;
914
924
  if (exposed > 0) {
915
- emit('secrets.get', {
916
- module: 'secrets',
925
+ emitSecretAudit({
926
+ event: 'secrets.get',
917
927
  bundle: bundle.name,
918
928
  operation: 'view --reveal --json',
919
929
  source: 'reveal',
@@ -971,8 +981,8 @@ export function registerSecretsCommands(program) {
971
981
  console.log(chalk.red.bold('policy: never — NO biometry ACL; reads are silent (no Touch ID, no user-presence check). Automation-only.'));
972
982
  }
973
983
  else {
974
- console.log(bundlePolicy(bundle) === 'daily'
975
- ? chalk.gray('policy: daily (ask once, then held ~7 days until sleep / logout screen-lock does not drop it)')
984
+ console.log(bundlePolicy(bundle) === 'hold'
985
+ ? chalk.gray('policy: hold (ask once, then held for the hold window — 7d by default — until sleep / logout; screen-lock does not drop it)')
976
986
  : chalk.gray('policy: always (asks for Touch ID every time — never auto-held)'));
977
987
  }
978
988
  if (bundle.created_at)
@@ -1020,8 +1030,8 @@ export function registerSecretsCommands(program) {
1020
1030
  const literalCount = entries.filter((e) => e.kind === 'literal').length;
1021
1031
  const exposedCount = revealedValues.size + literalCount;
1022
1032
  if (exposedCount > 0) {
1023
- emit('secrets.get', {
1024
- module: 'secrets',
1033
+ emitSecretAudit({
1034
+ event: 'secrets.get',
1025
1035
  bundle: bundle.name,
1026
1036
  operation: 'view --reveal',
1027
1037
  source: 'reveal',
@@ -1078,7 +1088,7 @@ export function registerSecretsCommands(program) {
1078
1088
  const value = getKeychainToken(item);
1079
1089
  // Raw item reads bypass readAndResolveBundleEnv, so audit here too.
1080
1090
  // `item` is the keychain service name, never the value.
1081
- emit('secrets.get', { module: 'secrets', item, source: 'raw-item', status: 'success' });
1091
+ emitSecretAudit({ event: 'secrets.get', item, source: 'raw-item', status: 'success' });
1082
1092
  process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
1083
1093
  }
1084
1094
  catch {
@@ -1152,10 +1162,10 @@ export function registerSecretsCommands(program) {
1152
1162
  .description('Create an empty bundle')
1153
1163
  .option('--description <text>', 'Free-form description')
1154
1164
  .option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
1155
- .option('--policy <policy>', 'prompt policy: daily (default, ask once a week), always (ask every time), or never (silent, NO biometry ACL — needs --i-understand)')
1165
+ .option('--policy <policy>', "prompt policy: hold (default, ask once per hold window — secrets.agent.holdMs, 7d by default), always (ask every time), or never (silent, NO biometry ACL — needs --i-understand). 'daily'/'session' are accepted aliases for 'hold'.")
1156
1166
  .addOption(new Option('--tier <policy>', 'deprecated alias for --policy').hideHelp())
1157
1167
  .option('--i-understand', 'Confirm creating a "never"-policy bundle (no biometry ACL) without an interactive prompt')
1158
- .option('--backend <backend>', 'storage backend: keychain (default) or file (passphrase-encrypted)', 'keychain')
1168
+ .option('--backend <backend>', 'storage backend: keychain or file (defaults to agents.yaml secrets.backend)')
1159
1169
  .option('--synced', 'Store this bundle in the age-encrypted synced secrets file for user-managed cross-machine file sync')
1160
1170
  .option('--force', 'Overwrite an existing bundle')
1161
1171
  .action(async (name, opts) => {
@@ -1166,7 +1176,7 @@ export function registerSecretsCommands(program) {
1166
1176
  // inherits the configured default (`daily`) instead of being pinned.
1167
1177
  const policyOpt = opts.policy ?? opts.tier;
1168
1178
  const policy = policyOpt ? parsePolicyOpt(policyOpt) : undefined;
1169
- const backend = opts.synced ? 'vault' : parseBackendOpt(opts.backend);
1179
+ const backend = opts.synced ? 'vault' : resolveBackendOpt(opts.backend);
1170
1180
  if (bundleExists(resolvedName) && !opts.force) {
1171
1181
  console.error(chalk.red(`Bundle '${resolvedName}' already exists. Use --force to overwrite.`));
1172
1182
  process.exit(1);
@@ -1187,8 +1197,8 @@ export function registerSecretsCommands(program) {
1187
1197
  vars: {},
1188
1198
  };
1189
1199
  writeBundle(bundle);
1190
- const policyTag = bundlePolicy(bundle) === 'daily'
1191
- ? 'policy: daily'
1200
+ const policyTag = bundlePolicy(bundle) === 'hold'
1201
+ ? 'policy: hold'
1192
1202
  : bundlePolicy(bundle) === 'always'
1193
1203
  ? 'policy: always ask'
1194
1204
  : 'policy: never (NO biometry ACL)';
@@ -1571,7 +1581,7 @@ Examples:
1571
1581
  .addOption(new Option('--from-1password', 'deprecated alias for --from 1password:<vault>').hideHelp())
1572
1582
  .addOption(new Option('--vault <name>', 'deprecated: name the vault in --from 1password:<vault>').hideHelp())
1573
1583
  .option('--all-plaintext', 'Store every imported value as a literal in the bundle metadata (skip keychain item creation)')
1574
- .option('--backend <backend>', 'When creating the bundle: keychain (default) or file (passphrase-encrypted)', 'keychain')
1584
+ .option('--backend <backend>', 'When creating the bundle: keychain or file (defaults to agents.yaml secrets.backend)')
1575
1585
  .option('--synced', 'When creating the bundle, store it in the age-encrypted synced secrets file')
1576
1586
  .option('--force', 'Overwrite an existing key in the bundle')
1577
1587
  .option('--purge', 'With --from icloud: delete the iCloud copies after a successful import (iCloud propagates the deletion to your other devices)')
@@ -1622,7 +1632,7 @@ Examples:
1622
1632
  if (opts.from1password) {
1623
1633
  console.log(chalk.yellow('--from-1password is deprecated; use --from 1password:<vault>.'));
1624
1634
  }
1625
- const requestedBackend = opts.synced ? 'vault' : parseBackendOpt(opts.backend);
1635
+ const requestedBackend = opts.synced ? 'vault' : resolveBackendOpt(opts.backend);
1626
1636
  if (source.kind === 'icloud') {
1627
1637
  await importFromICloud(bundleName, {
1628
1638
  force: opts.force,
@@ -1673,12 +1683,18 @@ Examples:
1673
1683
  .option('--to-1password', 'Push every key in the bundle as a PASSWORD item in a 1Password vault')
1674
1684
  .option('--vault <name>', '1Password vault name (used with --to-1password)')
1675
1685
  .option('--host <target...>', 'Push the bundle over SSH to this target (host alias or user@host); repeatable for multiple machines')
1686
+ .option('--device <target...>', 'Alias for --host; repeatable')
1676
1687
  .option('--remote-backend <backend>', 'Backend for the bundle on the remote (with --host): keychain (default) or file (passphrase-encrypted, headless-readable). file forwards AGENTS_SECRETS_PASSPHRASE over stdin.', 'keychain')
1677
1688
  .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --host)')
1678
1689
  .option('--format <shell|json>', 'Output for --plaintext export: shell (default) or json (lossless, machine-readable; used by remote resolve)', 'shell')
1679
1690
  .option('--to-file <path>', 'Write the bundle as an AES-256-GCM encrypted offline file (needs AGENTS_SECRETS_PASSPHRASE; symmetric counterpart of import --from-file)')
1680
1691
  .action(async (bundleName, opts) => {
1681
1692
  try {
1693
+ // `--device` is an alias for `--host` (fleet vocabulary parity with
1694
+ // `agents activity`/`run --device`); fold it into the host list up front so
1695
+ // every downstream branch sees one resolved target list.
1696
+ if (opts.device?.length)
1697
+ opts.host = [...(opts.host ?? []), ...opts.device];
1682
1698
  const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
1683
1699
  const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
1684
1700
  if (opts.toFile) {
@@ -1769,6 +1785,29 @@ Examples:
1769
1785
  console.error(chalk.red(`${host}: remote import failed (exit ${res.code})${msg ? `: ${msg}` : ''}`));
1770
1786
  continue;
1771
1787
  }
1788
+ // A keychain-backed push to a macOS remote over headless SSH can land
1789
+ // the bundle metadata but no READABLE value items: the remote login
1790
+ // keychain is locked in the non-interactive SSH context, so Security
1791
+ // accepts the write but the biometry-ACL'd item is unreadable, and the
1792
+ // remote `import` still exits 0. Read the bundle back the same way a
1793
+ // release will (drops the plaintext, keeps only key presence; the
1794
+ // remote's headless `agentOnly` guard fails fast, so no Touch ID) and
1795
+ // FAIL LOUDLY if the keys didn't materialize — rather than leave a
1796
+ // metadata-only bundle that breaks later with "stored item not found".
1797
+ // The file backend is headless-readable by construction, so skip it.
1798
+ if (remoteBackend === 'keychain') {
1799
+ const verdict = verifyRemoteKeychainPush(host, resolvedBundleName, Object.keys(env), { osLookupName: host });
1800
+ if (!verdict.ok) {
1801
+ failures++;
1802
+ if (verdict.kind === 'locked-keychain') {
1803
+ console.error(chalk.red(keychainWriteFailureMessage(host, resolvedBundleName, verdict.reason)));
1804
+ }
1805
+ else {
1806
+ console.error(chalk.red(`${host}: pushed '${resolvedBundleName}' but could not verify it on the remote: ${verdict.reason}`));
1807
+ }
1808
+ continue;
1809
+ }
1810
+ }
1772
1811
  const remoteMsg = (res.stdout || '').trim().split('\n').map((l) => l.trim()).filter(Boolean).pop();
1773
1812
  console.log(chalk.green(`${host} -> '${resolvedBundleName}': ${remoteMsg || `${keyCount} key(s) exported`}`));
1774
1813
  }
@@ -2057,7 +2096,7 @@ Examples:
2057
2096
  .description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS). With --host, unlock FILE-backed bundle(s) on a remote (the passphrase prompt surfaces over the SSH TTY); keychain/biometry bundles are GUI-only and can\'t be remote-unlocked.')
2058
2097
  .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
2059
2098
  .option('--durable', 'Keep the unlock across sleep + reboot too (default: survives upgrade/restart but re-locks on sleep). Set secrets.agent.durable in agents.yaml to make this the default.')
2060
- .option('--for <agent>', 'Scope the unlock to one harness type (for example claude, codex, or kimi).')
2099
+ .option('--for <agent>', 'Narrow the unlock to ONE harness type (for example claude, codex, or kimi). Default: the grant is global — every harness and a plain shell can read it, so one Touch ID covers them all.')
2061
2100
  .option('--all', 'Unlock every configured bundle')
2062
2101
  .option('--host <target>', 'Unlock the bundle(s) on this remote machine over SSH instead of locally (file-backed bundles only — the remote\'s passphrase prompt surfaces on your terminal over a -tt session). Single-valued (NOT variadic) so it never swallows the bundle name: `unlock <name> --host <machine>`.')
2063
2102
  .action(async (names, opts) => {
@@ -2130,7 +2169,12 @@ Examples:
2130
2169
  // (single-instance start lock, #414) and best-effort — never blocks unlock.
2131
2170
  ensureDaemonStarted();
2132
2171
  let loaded = 0;
2133
- const harness = opts.for || process.env.AGENTS_AGENT_NAME || 'cli';
2172
+ // An unlock is a deliberate act, so it grants GLOBALLY unless `--for`
2173
+ // narrows it to one harness. It must NOT inherit the ambient
2174
+ // AGENTS_AGENT_NAME: that silently scoped a terminal unlock to whichever
2175
+ // agent happened to launch the shell, leaving the grant unreadable to
2176
+ // every other reader for its whole TTL.
2177
+ const harness = opts.for || GLOBAL_HARNESS;
2134
2178
  for (const name of targets) {
2135
2179
  try {
2136
2180
  // noAgent: read the real keychain (one Touch ID) rather than the
@@ -2146,13 +2190,29 @@ Examples:
2146
2190
  loaded++;
2147
2191
  // Persist a durable session snapshot so the unlock survives a daemon
2148
2192
  // restart / upgrade (and sleep too, with --durable). session-store.ts.
2193
+ const durable = opts.durable ?? secretsAgentDurable();
2149
2194
  saveSession(name, {
2150
2195
  bundle,
2151
2196
  env,
2152
2197
  expiresAt: Date.now() + ttlMs,
2153
- sleepPersist: opts.durable ?? secretsAgentDurable(),
2198
+ sleepPersist: durable,
2154
2199
  harness,
2155
2200
  });
2201
+ // Audit the GRANT itself — the broker + durable session now serve this
2202
+ // bundle prompt-free for the whole TTL, to every reader in `harness`
2203
+ // scope. The pre-read above emits `secrets.get`; this records the
2204
+ // longer-lived unlock a `secrets.get` does not capture. Key NAMES only.
2205
+ emitSecretAudit({
2206
+ event: 'secrets.unlocked',
2207
+ bundle: name,
2208
+ operation: 'unlock',
2209
+ source: durable ? 'broker+durable' : 'broker',
2210
+ status: 'success',
2211
+ keyCount: Object.keys(env).length,
2212
+ keys: Object.keys(env).sort(),
2213
+ agent: harness,
2214
+ ttlMs,
2215
+ });
2156
2216
  console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(Date.now() + ttlMs)})`)}`);
2157
2217
  }
2158
2218
  else {
@@ -2272,7 +2332,7 @@ Examples:
2272
2332
  cmd
2273
2333
  .command('policy <bundle> [policy]')
2274
2334
  .alias('tier')
2275
- .description("Show or set a bundle's prompt policy: daily (default, ask once a week), always (ask every time), or never (silent, NO biometry ACL).")
2335
+ .description("Show or set a bundle's prompt policy: hold (default, ask once per hold window — secrets.agent.holdMs, 7d by default), always (ask every time), or never (silent, NO biometry ACL). 'daily'/'session' are accepted aliases for 'hold'.")
2276
2336
  .option('--i-understand', 'Confirm switching to the "never" policy (no biometry ACL) without an interactive prompt')
2277
2337
  .action(async (bundleName, policyArg, opts) => {
2278
2338
  try {
@@ -2289,12 +2349,12 @@ Examples:
2289
2349
  return;
2290
2350
  }
2291
2351
  bundle.policy = next;
2292
- // writeBundle evicts any broker-held copy, so tightening daily ->
2352
+ // writeBundle evicts any broker-held copy, so tightening hold ->
2293
2353
  // always/never takes effect NOW: the next read re-prompts (`always`)
2294
2354
  // or reads its no-ACL item directly (`never`).
2295
2355
  writeBundle(bundle);
2296
2356
  console.log(chalk.green(`${bundle.name} policy set to ${next}.`));
2297
- if (next === 'daily') {
2357
+ if (next === 'hold') {
2298
2358
  console.log(chalk.gray('Held by the secrets-agent for ~7 days after one unlock (auto-cache is on by default; disable with `secrets.agent.auto: false` in agents.yaml).'));
2299
2359
  }
2300
2360
  else if (next === 'always') {
@@ -2369,11 +2429,11 @@ export function parsePolicyOpt(raw) {
2369
2429
  const v = (raw ?? 'always').toLowerCase();
2370
2430
  if (v === 'always' || v === 'biometry')
2371
2431
  return 'always';
2372
- if (v === 'daily' || v === 'session')
2373
- return 'daily';
2432
+ if (v === 'hold' || v === 'daily' || v === 'session')
2433
+ return 'hold';
2374
2434
  if (v === 'never' || v === 'none')
2375
2435
  return 'never';
2376
- throw new Error(`Invalid policy '${raw}'. Use 'always', 'daily', or 'never'.`);
2436
+ throw new Error(`Invalid policy '${raw}'. Use 'always', 'hold', or 'never'.`);
2377
2437
  }
2378
2438
  /**
2379
2439
  * Gate a create/switch to the `never` prompt-policy behind an explicit,
@@ -2408,13 +2468,27 @@ async function confirmNeverPolicyInteractive(bundleName) {
2408
2468
  return confirm({ message: `Store '${bundleName}' WITHOUT biometry protection?`, default: false });
2409
2469
  }
2410
2470
  /** Validate a --backend value, exiting with a clear message on a bad one. */
2411
- function parseBackendOpt(raw) {
2412
- const v = (raw ?? 'keychain').toLowerCase();
2471
+ export function secretsDefaultBackend() {
2472
+ const raw = readMeta().secrets?.backend;
2473
+ if (raw === undefined)
2474
+ return 'keychain';
2475
+ if (raw === 'keychain' || raw === 'file' || raw === 'vault')
2476
+ return raw;
2477
+ console.error(chalk.red(`Invalid secrets.backend '${raw}'. Use 'keychain', 'file', or 'vault'.`));
2478
+ process.exit(1);
2479
+ }
2480
+ function parseBackendOpt(raw, defaultBackend = 'keychain') {
2481
+ const v = (raw ?? defaultBackend).toLowerCase();
2482
+ if (!raw && v === 'vault')
2483
+ return 'vault';
2413
2484
  if (v === 'keychain' || v === 'file')
2414
2485
  return v;
2415
2486
  console.error(chalk.red(`Invalid --backend '${raw}'. Use 'keychain' or 'file'. For cross-machine file sync, pass --synced.`));
2416
2487
  process.exit(1);
2417
2488
  }
2489
+ function resolveBackendOpt(raw) {
2490
+ return parseBackendOpt(raw, raw === undefined ? secretsDefaultBackend() : 'keychain');
2491
+ }
2418
2492
  /** Human-readable "locks in 3 hours" / "locks in 5 minutes" from an epoch-ms expiry. */
2419
2493
  function humanRemaining(expiresAt) {
2420
2494
  const ms = expiresAt - Date.now();
@@ -9,7 +9,8 @@
9
9
  * Built on {@link dynamicPicker}; every data source (discover, fleet, live index,
10
10
  * preview, resume dispatch) is reused from the existing sessions plumbing.
11
11
  */
12
- import type { SessionMeta } from '../lib/session/types.js';
12
+ import { type SessionMeta } from '../lib/session/types.js';
13
+ import type { ActiveSession } from '../lib/session/active.js';
13
14
  /**
14
15
  * The single canonical filter state. Every field has a flag equivalent, so the
15
16
  * same view is reachable interactively (hotkeys) or from the command line (flags).
@@ -59,11 +60,14 @@ export declare function activeBrowserSeed(opts: {
59
60
  agent?: string;
60
61
  host?: string[];
61
62
  since?: string;
63
+ all?: boolean;
62
64
  }): Partial<BrowserFilter>;
63
65
  /**
64
66
  * The initial filter for the bare interactive listing: current-repo subtree by
65
- * default (matches the static overview's cwd scoping), `--all` widens to every
66
- * directory, `--since` seeds the window.
67
+ * default (matches the static overview's cwd scoping). `--all` sets every
68
+ * non-status filter to its "all" value — every directory (project) AND all-time
69
+ * (window) — so one flag maxes the view; `--since` still overrides the window and
70
+ * `-a`/`--device` still narrow their axis.
67
71
  */
68
72
  export declare function bareBrowserSeed(opts: {
69
73
  teams?: boolean;
@@ -71,6 +75,44 @@ export declare function bareBrowserSeed(opts: {
71
75
  all?: boolean;
72
76
  since?: string;
73
77
  }): Partial<BrowserFilter>;
78
+ /**
79
+ * A live session's stable row key: its session id when the agent reported one,
80
+ * else a per-machine handle so an id-less live session (a just-booted harness, a
81
+ * cloud task) still gets exactly one row instead of being dropped. Cloud rows key
82
+ * on the task id because they have no pid — keying them all on the machine alone
83
+ * would collapse two cloud tasks into one row, the same silent-drop this whole
84
+ * change exists to remove.
85
+ */
86
+ export declare function liveRowKey(a: ActiveSession, self: string): string;
87
+ /** Index live sessions by {@link liveRowKey} — the join key between the live scan
88
+ * and the transcript pool. Id-carrying rows key on the session id, so a live row
89
+ * and its transcript row collapse to one. */
90
+ export declare function indexLiveRows(rows: ActiveSession[], self: string): Map<string, ActiveSession>;
91
+ /**
92
+ * Project a live session onto the picker's row shape, for a session the transcript
93
+ * pool doesn't carry — a peer's session, a transcript outside the current window,
94
+ * or an agent that has not written one yet. `filePath` is the live transcript path
95
+ * when the scan resolved one and empty otherwise, which is what
96
+ * {@link handlePickedSession} keys "there is nothing to open yet" off.
97
+ */
98
+ export declare function liveSessionToMeta(a: ActiveSession, self: string): SessionMeta;
99
+ /**
100
+ * Fold the live scan INTO the transcript pool. The running filter used to be a
101
+ * pure intersection (`pool ∩ live`), which meant a session had to already be in
102
+ * the pool to be shown as running — so every session on another machine, and
103
+ * every local one outside the pool's window, was invisible in the browser while
104
+ * `--active --json` listed it. Live sessions the pool lacks are appended as their
105
+ * own rows, then the whole set is grouped local-machine-first.
106
+ */
107
+ export declare function mergeLiveIntoPool(rows: SessionMeta[], live: Map<string, ActiveSession>, self: string): SessionMeta[];
108
+ /**
109
+ * Whether to render the host-program column. It belongs to the running view
110
+ * only, so this gates on the FILTER — not on the live index being populated.
111
+ * `liveCache` outlives a toggle of the `r` hotkey, so testing `live` alone would
112
+ * keep the column after running is turned back off, widening a plain transcript
113
+ * listing that has no live rows to explain it.
114
+ */
115
+ export declare function shouldShowHostColumn(f: BrowserFilter, live: Map<string, ActiveSession> | null, rows: SessionMeta[]): boolean;
74
116
  /**
75
117
  * Launch the interactive session browser. `initial` seeds the filter (e.g.
76
118
  * `{ running: true }` for `--active`). Resolves after the user resumes a session
@@ -9,14 +9,15 @@
9
9
  * Built on {@link dynamicPicker}; every data source (discover, fleet, live index,
10
10
  * preview, resume dispatch) is reused from the existing sessions plumbing.
11
11
  */
12
+ import path from 'path';
12
13
  import { spawnSync } from 'child_process';
13
14
  import { dynamicPicker } from '../lib/picker.js';
14
- import { getActiveSessions } from '../lib/session/active.js';
15
+ import { isSessionTrackedAgent } from '../lib/session/types.js';
15
16
  import { discoverSessions } from '../lib/session/discover.js';
16
17
  import { gatherRemoteList } from '../lib/session/remote-list.js';
17
18
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
18
19
  import { buildPreview } from './sessions-picker.js';
19
- import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, indexActiveBySessionId, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, } from './sessions.js';
20
+ import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, } from './sessions.js';
20
21
  /** Ordered window cycle for the `W` key. `undefined` = all time. */
21
22
  const WINDOW_CYCLE = [undefined, '1d', '7d', '30d'];
22
23
  /** Return the next value in `[undefined, ...options]`, wrapping. */
@@ -102,20 +103,25 @@ export function activeBrowserSeed(opts) {
102
103
  agent: opts.agent,
103
104
  projectScope: 'all',
104
105
  device: normalizeDeviceSeed(opts.host?.[0]),
105
- window: opts.since ?? '30d',
106
+ // --all widens the window to all-time (project is already 'all' here);
107
+ // --since still overrides.
108
+ window: opts.since ?? (opts.all ? undefined : '30d'),
106
109
  };
107
110
  }
108
111
  /**
109
112
  * The initial filter for the bare interactive listing: current-repo subtree by
110
- * default (matches the static overview's cwd scoping), `--all` widens to every
111
- * directory, `--since` seeds the window.
113
+ * default (matches the static overview's cwd scoping). `--all` sets every
114
+ * non-status filter to its "all" value — every directory (project) AND all-time
115
+ * (window) — so one flag maxes the view; `--since` still overrides the window and
116
+ * `-a`/`--device` still narrow their axis.
112
117
  */
113
118
  export function bareBrowserSeed(opts) {
114
119
  return {
115
120
  teams: !!opts.teams,
116
121
  agent: opts.agent,
122
+ // --all maxes every non-status filter: all dirs AND all-time. --since wins.
117
123
  projectScope: opts.all ? 'all' : 'repo',
118
- window: opts.since ?? '30d',
124
+ window: opts.since ?? (opts.all ? undefined : '30d'),
119
125
  };
120
126
  }
121
127
  /** Copy text to the OS clipboard (best-effort; silently no-ops if unavailable). */
@@ -182,6 +188,99 @@ async function fetchRawPool(f, self, local, hosts) {
182
188
  }
183
189
  return { key: `${since ?? 'all'}|${f.teams}`, rows };
184
190
  }
191
+ /**
192
+ * A live session's stable row key: its session id when the agent reported one,
193
+ * else a per-machine handle so an id-less live session (a just-booted harness, a
194
+ * cloud task) still gets exactly one row instead of being dropped. Cloud rows key
195
+ * on the task id because they have no pid — keying them all on the machine alone
196
+ * would collapse two cloud tasks into one row, the same silent-drop this whole
197
+ * change exists to remove.
198
+ */
199
+ export function liveRowKey(a, self) {
200
+ if (a.sessionId)
201
+ return a.sessionId;
202
+ const handle = a.cloudTaskId ?? (a.pid != null ? String(a.pid) : 'unknown');
203
+ return `${LIVE_ROW_PREFIX}${a.machine ?? self}:${handle}`;
204
+ }
205
+ /** Index live sessions by {@link liveRowKey} — the join key between the live scan
206
+ * and the transcript pool. Id-carrying rows key on the session id, so a live row
207
+ * and its transcript row collapse to one. */
208
+ export function indexLiveRows(rows, self) {
209
+ const byKey = new Map();
210
+ for (const a of rows)
211
+ byKey.set(liveRowKey(a, self), a);
212
+ return byKey;
213
+ }
214
+ /**
215
+ * Project a live session onto the picker's row shape, for a session the transcript
216
+ * pool doesn't carry — a peer's session, a transcript outside the current window,
217
+ * or an agent that has not written one yet. `filePath` is the live transcript path
218
+ * when the scan resolved one and empty otherwise, which is what
219
+ * {@link handlePickedSession} keys "there is nothing to open yet" off.
220
+ */
221
+ export function liveSessionToMeta(a, self) {
222
+ const machine = a.machine ?? self;
223
+ const started = a.startedAtMs ?? a.lastActivityMs;
224
+ const topic = a.topic ?? a.preview;
225
+ return {
226
+ id: liveRowKey(a, self),
227
+ // No session id to short — name the row by what it IS (a cloud task, a pid),
228
+ // so the id column still identifies the process you'd go looking for.
229
+ shortId: a.sessionId
230
+ ? a.sessionId.slice(0, 8)
231
+ : a.cloudTaskId
232
+ ? a.cloudTaskId.slice(0, 8)
233
+ : `p:${a.pid ?? '?'}`,
234
+ agent: isSessionTrackedAgent(a.kind) ? a.kind : 'claude',
235
+ timestamp: new Date(started ?? Date.now()).toISOString(),
236
+ lastActivity: a.lastActivityMs ? new Date(a.lastActivityMs).toISOString() : undefined,
237
+ project: a.cwd ? path.basename(a.cwd) : undefined,
238
+ cwd: a.cwd,
239
+ filePath: a.sessionFile ?? '',
240
+ // A live topic is raw transcript text: a newline in it would break the row
241
+ // into two and misalign every column after it. Indexed rows are already
242
+ // cleaned at scan time, so this puts projected rows on the same footing.
243
+ topic: topic ? cleanPreview(topic) : undefined,
244
+ label: a.label ? cleanPreview(a.label) : undefined,
245
+ machine,
246
+ // Reading/resuming a peer's session hops back over SSH — same contract the
247
+ // cross-machine listing sets, so handlePickedSession routes it correctly.
248
+ _remote: machine !== self,
249
+ prUrl: a.pr?.url,
250
+ prNumber: a.pr?.number,
251
+ ticketId: a.ticket?.id,
252
+ worktreeSlug: a.worktree?.slug,
253
+ };
254
+ }
255
+ /**
256
+ * Fold the live scan INTO the transcript pool. The running filter used to be a
257
+ * pure intersection (`pool ∩ live`), which meant a session had to already be in
258
+ * the pool to be shown as running — so every session on another machine, and
259
+ * every local one outside the pool's window, was invisible in the browser while
260
+ * `--active --json` listed it. Live sessions the pool lacks are appended as their
261
+ * own rows, then the whole set is grouped local-machine-first.
262
+ */
263
+ export function mergeLiveIntoPool(rows, live, self) {
264
+ const known = new Set(rows.map((r) => r.id));
265
+ const extra = [];
266
+ for (const [key, a] of live) {
267
+ if (!known.has(key))
268
+ extra.push(liveSessionToMeta(a, self));
269
+ }
270
+ return extra.length === 0 ? rows : mergeLocalFirst([...rows, ...extra], self);
271
+ }
272
+ /**
273
+ * Whether to render the host-program column. It belongs to the running view
274
+ * only, so this gates on the FILTER — not on the live index being populated.
275
+ * `liveCache` outlives a toggle of the `r` hotkey, so testing `live` alone would
276
+ * keep the column after running is turned back off, widening a plain transcript
277
+ * listing that has no live rows to explain it.
278
+ */
279
+ export function shouldShowHostColumn(f, live, rows) {
280
+ if (!f.running || !live)
281
+ return false;
282
+ return rows.some((r) => liveHostLabel(live.get(r.id)) !== '');
283
+ }
185
284
  /** Apply the cheap in-memory filters (agent / device / project / running). */
186
285
  function applyFilters(rows, live, f, self) {
187
286
  let out = rows;
@@ -269,11 +368,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
269
368
  return []; // superseded — don't touch shared state
270
369
  pool = fetched;
271
370
  }
272
- // Only pay for the live scan when the running filter needs it.
371
+ // Only pay for the live scan when the running filter needs it. Same fleet
372
+ // sweep the static `--active` view uses, so the two never disagree about
373
+ // what is running.
273
374
  let live = liveCache;
274
375
  if (f.running && !live) {
275
376
  try {
276
- live = indexActiveBySessionId(await getActiveSessions());
377
+ const { sessions } = await gatherActiveSessions({ local, hosts });
378
+ live = indexLiveRows(sessions, self);
277
379
  }
278
380
  catch {
279
381
  live = new Map();
@@ -286,10 +388,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
286
388
  rawCache = pool;
287
389
  if (live)
288
390
  liveCache = live;
289
- agentsInPool = distinct(pool.rows.map((r) => r.agent));
290
- devicesInPool = distinct(pool.rows.map((r) => r.machine ?? self));
291
- const filtered = applyFilters(pool.rows, live ?? new Map(), f, self);
391
+ // Live sessions the transcript pool lacks become rows of their own, so the
392
+ // running view lists every active session, not just the ones already indexed.
393
+ const rows = f.running && live ? mergeLiveIntoPool(pool.rows, live, self) : pool.rows;
394
+ agentsInPool = distinct(rows.map((r) => r.agent));
395
+ devicesInPool = distinct(rows.map((r) => r.machine ?? self));
396
+ const filtered = applyFilters(rows, live ?? new Map(), f, self);
292
397
  cols = pickerColumnsFor(filtered);
398
+ cols.showHost = shouldShowHostColumn(f, live, filtered);
293
399
  return filtered;
294
400
  };
295
401
  const picked = await dynamicPicker({
@@ -297,7 +403,7 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
297
403
  initialFilter,
298
404
  load,
299
405
  keyFor: (s) => s.id,
300
- labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id)),
406
+ labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id))),
301
407
  matches: sessionMatchesQuery,
302
408
  buildPreview,
303
409
  headerFor,
@@ -1,2 +1,5 @@
1
1
  import type { Command } from 'commander';
2
+ import type { SessionMeta } from '../lib/session/types.js';
2
3
  export declare function registerSessionsExportCommand(sessionsCmd: Command): void;
4
+ /** ids > query > everything-in-scope. Exported for the id-resolution test. */
5
+ export declare function selectSessions(metas: SessionMeta[], selectors: string[]): SessionMeta[];