@phnx-labs/agents-cli 1.20.78 → 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 (176) hide show
  1. package/CHANGELOG.md +487 -0
  2. package/README.md +25 -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 +65 -0
  12. package/dist/commands/doctor.js +575 -31
  13. package/dist/commands/events.js +1 -1
  14. package/dist/commands/exec.js +24 -5
  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 +187 -11
  29. package/dist/commands/routines.js +12 -2
  30. package/dist/commands/rules.js +13 -12
  31. package/dist/commands/secrets.d.ts +3 -1
  32. package/dist/commands/secrets.js +100 -32
  33. package/dist/commands/sessions-inject.d.ts +7 -4
  34. package/dist/commands/sessions-inject.js +18 -18
  35. package/dist/commands/sessions-picker.js +32 -3
  36. package/dist/commands/sessions-resume.d.ts +16 -0
  37. package/dist/commands/sessions-resume.js +14 -13
  38. package/dist/commands/sessions.d.ts +31 -0
  39. package/dist/commands/sessions.js +141 -16
  40. package/dist/commands/set.d.ts +15 -0
  41. package/dist/commands/set.js +79 -0
  42. package/dist/commands/setup-fleet.d.ts +21 -0
  43. package/dist/commands/setup-fleet.js +201 -0
  44. package/dist/commands/setup-secrets.d.ts +20 -0
  45. package/dist/commands/setup-secrets.js +223 -0
  46. package/dist/commands/setup.js +17 -3
  47. package/dist/commands/skills.js +1 -1
  48. package/dist/commands/sync.js +7 -2
  49. package/dist/commands/teams-picker.js +0 -1
  50. package/dist/commands/teams.d.ts +33 -1
  51. package/dist/commands/teams.js +208 -44
  52. package/dist/commands/versions.js +10 -5
  53. package/dist/commands/view.d.ts +1 -0
  54. package/dist/commands/view.js +11 -0
  55. package/dist/index.js +5 -8
  56. package/dist/lib/acp/harnesses.js +0 -7
  57. package/dist/lib/activity.d.ts +156 -0
  58. package/dist/lib/activity.js +282 -0
  59. package/dist/lib/agents.d.ts +9 -2
  60. package/dist/lib/agents.js +72 -63
  61. package/dist/lib/browser/service.js +3 -0
  62. package/dist/lib/browser/types.d.ts +7 -0
  63. package/dist/lib/capabilities.js +6 -2
  64. package/dist/lib/crabbox/lease.js +2 -2
  65. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  66. package/dist/lib/crabbox/setup-copy.js +4 -4
  67. package/dist/lib/daemon.d.ts +18 -0
  68. package/dist/lib/daemon.js +47 -6
  69. package/dist/lib/devices/fleet.d.ts +1 -1
  70. package/dist/lib/devices/fleet.js +1 -1
  71. package/dist/lib/doctor-diff.d.ts +20 -0
  72. package/dist/lib/doctor-diff.js +6 -1
  73. package/dist/lib/drift.d.ts +31 -11
  74. package/dist/lib/drift.js +58 -7
  75. package/dist/lib/events.d.ts +8 -1
  76. package/dist/lib/events.js +10 -1
  77. package/dist/lib/exec.js +35 -8
  78. package/dist/lib/feed-post.d.ts +28 -1
  79. package/dist/lib/feed-post.js +110 -2
  80. package/dist/lib/fleet/apply.js +8 -1
  81. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  82. package/dist/lib/fleet/auth-sync.js +25 -11
  83. package/dist/lib/fleet/remote-login.js +5 -0
  84. package/dist/lib/git.d.ts +14 -14
  85. package/dist/lib/git.js +41 -44
  86. package/dist/lib/hooks.d.ts +42 -0
  87. package/dist/lib/hooks.js +137 -93
  88. package/dist/lib/hosts/dispatch.d.ts +28 -1
  89. package/dist/lib/hosts/dispatch.js +33 -5
  90. package/dist/lib/hosts/option.js +2 -2
  91. package/dist/lib/hosts/passthrough.d.ts +22 -1
  92. package/dist/lib/hosts/passthrough.js +238 -13
  93. package/dist/lib/hosts/run-target.d.ts +2 -0
  94. package/dist/lib/hosts/run-target.js +1 -0
  95. package/dist/lib/hq/floor.d.ts +1 -1
  96. package/dist/lib/hq/floor.js +6 -1
  97. package/dist/lib/mcp.js +3 -71
  98. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  99. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  100. package/dist/lib/menubar/notify-desktop.js +41 -5
  101. package/dist/lib/merged-resources.d.ts +11 -0
  102. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  103. package/dist/lib/permissions.d.ts +0 -32
  104. package/dist/lib/permissions.js +4 -157
  105. package/dist/lib/picker.js +1 -1
  106. package/dist/lib/platform/process.d.ts +16 -5
  107. package/dist/lib/platform/process.js +54 -0
  108. package/dist/lib/plugins.d.ts +0 -8
  109. package/dist/lib/plugins.js +4 -110
  110. package/dist/lib/project-resources.js +4 -1
  111. package/dist/lib/refresh.d.ts +5 -3
  112. package/dist/lib/refresh.js +7 -5
  113. package/dist/lib/resources/commands.js +4 -1
  114. package/dist/lib/resources/mcp.js +0 -4
  115. package/dist/lib/resources/permissions.d.ts +1 -1
  116. package/dist/lib/resources/permissions.js +1 -5
  117. package/dist/lib/resources/rules.js +4 -1
  118. package/dist/lib/resources/skills.js +4 -1
  119. package/dist/lib/resources/subagents.js +4 -0
  120. package/dist/lib/resources/types.d.ts +1 -1
  121. package/dist/lib/routines.d.ts +20 -0
  122. package/dist/lib/routines.js +6 -0
  123. package/dist/lib/run-defaults.d.ts +1 -0
  124. package/dist/lib/run-defaults.js +9 -0
  125. package/dist/lib/runner.d.ts +0 -12
  126. package/dist/lib/runner.js +49 -10
  127. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  128. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  129. package/dist/lib/secrets/agent.d.ts +16 -0
  130. package/dist/lib/secrets/agent.js +105 -3
  131. package/dist/lib/secrets/audit.d.ts +46 -0
  132. package/dist/lib/secrets/audit.js +56 -0
  133. package/dist/lib/secrets/bundles.d.ts +11 -8
  134. package/dist/lib/secrets/bundles.js +31 -23
  135. package/dist/lib/secrets/index.d.ts +2 -1
  136. package/dist/lib/secrets/index.js +8 -2
  137. package/dist/lib/secrets/remote.d.ts +75 -2
  138. package/dist/lib/secrets/remote.js +139 -5
  139. package/dist/lib/secrets/sync.js +3 -3
  140. package/dist/lib/session/active.d.ts +110 -26
  141. package/dist/lib/session/active.js +161 -50
  142. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  143. package/dist/lib/session/actor-sidecar.js +101 -0
  144. package/dist/lib/session/db.d.ts +4 -1
  145. package/dist/lib/session/db.js +65 -8
  146. package/dist/lib/session/discover.d.ts +4 -0
  147. package/dist/lib/session/discover.js +13 -0
  148. package/dist/lib/session/hook-sessions.d.ts +9 -0
  149. package/dist/lib/session/hook-sessions.js +57 -8
  150. package/dist/lib/session/pid-registry.d.ts +13 -0
  151. package/dist/lib/session/render.d.ts +2 -0
  152. package/dist/lib/session/render.js +1 -1
  153. package/dist/lib/session/types.d.ts +16 -0
  154. package/dist/lib/staleness/detectors/commands.js +2 -2
  155. package/dist/lib/staleness/detectors/permissions.js +1 -45
  156. package/dist/lib/staleness/writers/commands.js +4 -5
  157. package/dist/lib/staleness/writers/hooks.js +1 -1
  158. package/dist/lib/startup/command-registry.d.ts +1 -2
  159. package/dist/lib/startup/command-registry.js +2 -4
  160. package/dist/lib/subagents-registry.js +5 -12
  161. package/dist/lib/subagents.d.ts +0 -10
  162. package/dist/lib/subagents.js +0 -12
  163. package/dist/lib/teams/agents.d.ts +0 -2
  164. package/dist/lib/teams/agents.js +10 -27
  165. package/dist/lib/teams/index.d.ts +1 -1
  166. package/dist/lib/teams/index.js +1 -1
  167. package/dist/lib/types.d.ts +17 -9
  168. package/dist/lib/usage.d.ts +28 -5
  169. package/dist/lib/usage.js +271 -8
  170. package/dist/lib/versions.js +7 -1
  171. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  172. package/dist/lib/watchdog/watchdog.js +25 -13
  173. package/package.json +1 -2
  174. package/dist/commands/check.d.ts +0 -15
  175. package/dist/commands/check.js +0 -180
  176. package/dist/commands/resources.d.ts +0 -5
@@ -15,7 +15,7 @@ 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';
@@ -29,6 +29,7 @@ import { getCliVersionFresh } from '../lib/version.js';
29
29
  import { readMeta } from '../lib/state.js';
30
30
  import { parseDuration } from '../lib/hooks/cache.js';
31
31
  import { emit, query } from '../lib/events.js';
32
+ import { emitSecretAudit } from '../lib/secrets/audit.js';
32
33
  import { frequentlyPromptedBundles } from '../lib/secrets/unlock-hints.js';
33
34
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
34
35
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -468,7 +469,7 @@ export function renderPolicyCol(b, held) {
468
469
  if (bundlePolicy(b) === 'always')
469
470
  return chalk.yellow('always · prompt');
470
471
  const exp = held?.get(b.name);
471
- return exp ? chalk.green(`daily · held ${compactRemaining(exp)}`) : chalk.gray('daily');
472
+ return exp ? chalk.green(`hold · held ${compactRemaining(exp)}`) : chalk.gray('hold');
472
473
  }
473
474
  /** Human-readable hold window for `secrets status`. Sub-hour values render in
474
475
  * minutes (so a near-floor `holdMs` never shows a confusing "0 hours"), whole
@@ -669,7 +670,7 @@ function countExpiringSoon(meta) {
669
670
  * guard can't drift between them.
670
671
  */
671
672
  function resolveImportBundle(name, backendOpt, synced = false) {
672
- const requestedBackend = synced ? 'vault' : parseBackendOpt(backendOpt);
673
+ const requestedBackend = synced ? 'vault' : resolveBackendOpt(backendOpt);
673
674
  if (bundleExists(name)) {
674
675
  const bundle = readBundle(name);
675
676
  if (requestedBackend !== 'keychain' && bundle.backend !== requestedBackend) {
@@ -735,7 +736,7 @@ export function registerSecretsCommands(program) {
735
736
  agents secrets view prod
736
737
 
737
738
  # Stop a noisy automation bundle from prompting every run: ask once a week
738
- agents secrets policy prod daily
739
+ agents secrets policy prod hold
739
740
 
740
741
  # Eval the bundle into your current shell
741
742
  eval "$(agents secrets export prod --plaintext)"
@@ -753,17 +754,21 @@ export function registerSecretsCommands(program) {
753
754
 
754
755
  Touch ID noise: macOS pops a prompt per bundle per process. Each bundle has
755
756
  a prompt policy, shown in the POLICY column of 'agents secrets list':
756
- daily (default) ask once, then hold it silently in the local agent up
757
- to ~7 days, until sleep / logout or 'lock' (a bare
758
- 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'.
759
762
  always ask for Touch ID every time — never auto-held.
760
- 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
761
766
  'secrets.policy' in agents.yaml, or per bundle with 'agents secrets policy
762
767
  <bundle> always'. 'agents secrets unlock <bundle>' holds any bundle after one
763
768
  prompt regardless of policy. Nothing on disk.
764
769
 
765
770
  See also:
766
- 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
767
772
  agents secrets unlock <bundle> hold a bundle after one Touch ID
768
773
  agents secrets lock wipe held bundles (re-prompt next read)
769
774
  agents secrets status show held bundles + when they lock
@@ -789,6 +794,8 @@ export function registerSecretsCommands(program) {
789
794
  .description('List configured secrets bundles (use --host/--hosts to list bundles on other machines over SSH)')
790
795
  .option('--host <target>', 'List bundles on a remote host over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
791
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')
792
799
  .option('--json', 'Emit machine-readable JSON (bundle metadata only — never secret values) instead of the table')
793
800
  .action(async (opts) => {
794
801
  const targets = parseHostsOption(opts);
@@ -856,6 +863,8 @@ export function registerSecretsCommands(program) {
856
863
  .option('--json', 'Emit machine-readable JSON (values masked unless --reveal) instead of the human view')
857
864
  .option('--host <target>', 'Show a bundle on a remote host over SSH (enrolled `agents hosts` name, ssh-config alias, or user@host)')
858
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')
859
868
  .action(async (name, opts) => {
860
869
  try {
861
870
  const targets = parseHostsOption(opts);
@@ -913,8 +922,8 @@ export function registerSecretsCommands(program) {
913
922
  }
914
923
  const exposed = revealed.size + entries.filter((e) => e.kind === 'literal').length;
915
924
  if (exposed > 0) {
916
- emit('secrets.get', {
917
- module: 'secrets',
925
+ emitSecretAudit({
926
+ event: 'secrets.get',
918
927
  bundle: bundle.name,
919
928
  operation: 'view --reveal --json',
920
929
  source: 'reveal',
@@ -972,8 +981,8 @@ export function registerSecretsCommands(program) {
972
981
  console.log(chalk.red.bold('policy: never — NO biometry ACL; reads are silent (no Touch ID, no user-presence check). Automation-only.'));
973
982
  }
974
983
  else {
975
- console.log(bundlePolicy(bundle) === 'daily'
976
- ? 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)')
977
986
  : chalk.gray('policy: always (asks for Touch ID every time — never auto-held)'));
978
987
  }
979
988
  if (bundle.created_at)
@@ -1021,8 +1030,8 @@ export function registerSecretsCommands(program) {
1021
1030
  const literalCount = entries.filter((e) => e.kind === 'literal').length;
1022
1031
  const exposedCount = revealedValues.size + literalCount;
1023
1032
  if (exposedCount > 0) {
1024
- emit('secrets.get', {
1025
- module: 'secrets',
1033
+ emitSecretAudit({
1034
+ event: 'secrets.get',
1026
1035
  bundle: bundle.name,
1027
1036
  operation: 'view --reveal',
1028
1037
  source: 'reveal',
@@ -1079,7 +1088,7 @@ export function registerSecretsCommands(program) {
1079
1088
  const value = getKeychainToken(item);
1080
1089
  // Raw item reads bypass readAndResolveBundleEnv, so audit here too.
1081
1090
  // `item` is the keychain service name, never the value.
1082
- emit('secrets.get', { module: 'secrets', item, source: 'raw-item', status: 'success' });
1091
+ emitSecretAudit({ event: 'secrets.get', item, source: 'raw-item', status: 'success' });
1083
1092
  process.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
1084
1093
  }
1085
1094
  catch {
@@ -1153,10 +1162,10 @@ export function registerSecretsCommands(program) {
1153
1162
  .description('Create an empty bundle')
1154
1163
  .option('--description <text>', 'Free-form description')
1155
1164
  .option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
1156
- .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'.")
1157
1166
  .addOption(new Option('--tier <policy>', 'deprecated alias for --policy').hideHelp())
1158
1167
  .option('--i-understand', 'Confirm creating a "never"-policy bundle (no biometry ACL) without an interactive prompt')
1159
- .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)')
1160
1169
  .option('--synced', 'Store this bundle in the age-encrypted synced secrets file for user-managed cross-machine file sync')
1161
1170
  .option('--force', 'Overwrite an existing bundle')
1162
1171
  .action(async (name, opts) => {
@@ -1167,7 +1176,7 @@ export function registerSecretsCommands(program) {
1167
1176
  // inherits the configured default (`daily`) instead of being pinned.
1168
1177
  const policyOpt = opts.policy ?? opts.tier;
1169
1178
  const policy = policyOpt ? parsePolicyOpt(policyOpt) : undefined;
1170
- const backend = opts.synced ? 'vault' : parseBackendOpt(opts.backend);
1179
+ const backend = opts.synced ? 'vault' : resolveBackendOpt(opts.backend);
1171
1180
  if (bundleExists(resolvedName) && !opts.force) {
1172
1181
  console.error(chalk.red(`Bundle '${resolvedName}' already exists. Use --force to overwrite.`));
1173
1182
  process.exit(1);
@@ -1188,8 +1197,8 @@ export function registerSecretsCommands(program) {
1188
1197
  vars: {},
1189
1198
  };
1190
1199
  writeBundle(bundle);
1191
- const policyTag = bundlePolicy(bundle) === 'daily'
1192
- ? 'policy: daily'
1200
+ const policyTag = bundlePolicy(bundle) === 'hold'
1201
+ ? 'policy: hold'
1193
1202
  : bundlePolicy(bundle) === 'always'
1194
1203
  ? 'policy: always ask'
1195
1204
  : 'policy: never (NO biometry ACL)';
@@ -1572,7 +1581,7 @@ Examples:
1572
1581
  .addOption(new Option('--from-1password', 'deprecated alias for --from 1password:<vault>').hideHelp())
1573
1582
  .addOption(new Option('--vault <name>', 'deprecated: name the vault in --from 1password:<vault>').hideHelp())
1574
1583
  .option('--all-plaintext', 'Store every imported value as a literal in the bundle metadata (skip keychain item creation)')
1575
- .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)')
1576
1585
  .option('--synced', 'When creating the bundle, store it in the age-encrypted synced secrets file')
1577
1586
  .option('--force', 'Overwrite an existing key in the bundle')
1578
1587
  .option('--purge', 'With --from icloud: delete the iCloud copies after a successful import (iCloud propagates the deletion to your other devices)')
@@ -1623,7 +1632,7 @@ Examples:
1623
1632
  if (opts.from1password) {
1624
1633
  console.log(chalk.yellow('--from-1password is deprecated; use --from 1password:<vault>.'));
1625
1634
  }
1626
- const requestedBackend = opts.synced ? 'vault' : parseBackendOpt(opts.backend);
1635
+ const requestedBackend = opts.synced ? 'vault' : resolveBackendOpt(opts.backend);
1627
1636
  if (source.kind === 'icloud') {
1628
1637
  await importFromICloud(bundleName, {
1629
1638
  force: opts.force,
@@ -1674,12 +1683,18 @@ Examples:
1674
1683
  .option('--to-1password', 'Push every key in the bundle as a PASSWORD item in a 1Password vault')
1675
1684
  .option('--vault <name>', '1Password vault name (used with --to-1password)')
1676
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')
1677
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')
1678
1688
  .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --host)')
1679
1689
  .option('--format <shell|json>', 'Output for --plaintext export: shell (default) or json (lossless, machine-readable; used by remote resolve)', 'shell')
1680
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)')
1681
1691
  .action(async (bundleName, opts) => {
1682
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];
1683
1698
  const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
1684
1699
  const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
1685
1700
  if (opts.toFile) {
@@ -1770,6 +1785,29 @@ Examples:
1770
1785
  console.error(chalk.red(`${host}: remote import failed (exit ${res.code})${msg ? `: ${msg}` : ''}`));
1771
1786
  continue;
1772
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
+ }
1773
1811
  const remoteMsg = (res.stdout || '').trim().split('\n').map((l) => l.trim()).filter(Boolean).pop();
1774
1812
  console.log(chalk.green(`${host} -> '${resolvedBundleName}': ${remoteMsg || `${keyCount} key(s) exported`}`));
1775
1813
  }
@@ -2152,13 +2190,29 @@ Examples:
2152
2190
  loaded++;
2153
2191
  // Persist a durable session snapshot so the unlock survives a daemon
2154
2192
  // restart / upgrade (and sleep too, with --durable). session-store.ts.
2193
+ const durable = opts.durable ?? secretsAgentDurable();
2155
2194
  saveSession(name, {
2156
2195
  bundle,
2157
2196
  env,
2158
2197
  expiresAt: Date.now() + ttlMs,
2159
- sleepPersist: opts.durable ?? secretsAgentDurable(),
2198
+ sleepPersist: durable,
2160
2199
  harness,
2161
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
+ });
2162
2216
  console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(Date.now() + ttlMs)})`)}`);
2163
2217
  }
2164
2218
  else {
@@ -2278,7 +2332,7 @@ Examples:
2278
2332
  cmd
2279
2333
  .command('policy <bundle> [policy]')
2280
2334
  .alias('tier')
2281
- .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'.")
2282
2336
  .option('--i-understand', 'Confirm switching to the "never" policy (no biometry ACL) without an interactive prompt')
2283
2337
  .action(async (bundleName, policyArg, opts) => {
2284
2338
  try {
@@ -2295,12 +2349,12 @@ Examples:
2295
2349
  return;
2296
2350
  }
2297
2351
  bundle.policy = next;
2298
- // writeBundle evicts any broker-held copy, so tightening daily ->
2352
+ // writeBundle evicts any broker-held copy, so tightening hold ->
2299
2353
  // always/never takes effect NOW: the next read re-prompts (`always`)
2300
2354
  // or reads its no-ACL item directly (`never`).
2301
2355
  writeBundle(bundle);
2302
2356
  console.log(chalk.green(`${bundle.name} policy set to ${next}.`));
2303
- if (next === 'daily') {
2357
+ if (next === 'hold') {
2304
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).'));
2305
2359
  }
2306
2360
  else if (next === 'always') {
@@ -2375,11 +2429,11 @@ export function parsePolicyOpt(raw) {
2375
2429
  const v = (raw ?? 'always').toLowerCase();
2376
2430
  if (v === 'always' || v === 'biometry')
2377
2431
  return 'always';
2378
- if (v === 'daily' || v === 'session')
2379
- return 'daily';
2432
+ if (v === 'hold' || v === 'daily' || v === 'session')
2433
+ return 'hold';
2380
2434
  if (v === 'never' || v === 'none')
2381
2435
  return 'never';
2382
- throw new Error(`Invalid policy '${raw}'. Use 'always', 'daily', or 'never'.`);
2436
+ throw new Error(`Invalid policy '${raw}'. Use 'always', 'hold', or 'never'.`);
2383
2437
  }
2384
2438
  /**
2385
2439
  * Gate a create/switch to the `never` prompt-policy behind an explicit,
@@ -2414,13 +2468,27 @@ async function confirmNeverPolicyInteractive(bundleName) {
2414
2468
  return confirm({ message: `Store '${bundleName}' WITHOUT biometry protection?`, default: false });
2415
2469
  }
2416
2470
  /** Validate a --backend value, exiting with a clear message on a bad one. */
2417
- function parseBackendOpt(raw) {
2418
- 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';
2419
2484
  if (v === 'keychain' || v === 'file')
2420
2485
  return v;
2421
2486
  console.error(chalk.red(`Invalid --backend '${raw}'. Use 'keychain' or 'file'. For cross-machine file sync, pass --synced.`));
2422
2487
  process.exit(1);
2423
2488
  }
2489
+ function resolveBackendOpt(raw) {
2490
+ return parseBackendOpt(raw, raw === undefined ? secretsDefaultBackend() : 'keychain');
2491
+ }
2424
2492
  /** Human-readable "locks in 3 hours" / "locks in 5 minutes" from an epoch-ms expiry. */
2425
2493
  function humanRemaining(expiresAt) {
2426
2494
  const ms = expiresAt - Date.now();
@@ -4,10 +4,13 @@
4
4
  * (`injectIntoTerminal`, src/lib/terminal/inject.ts), so a native watchdog
5
5
  * (RUSH-1415) can shell out to nudge a stalled agent with "continue".
6
6
  *
7
- * Resolution: find the active session by id, map its `provenance.reply` rail
8
- * (provenance.ts:47) to an engine `InjectTarget` and inject. Today only tmux rails
9
- * are externally addressable; `--pane`/`--pty` target a backend directly when the
10
- * handle is already known (skipping the session lookup).
7
+ * Resolution: find the active session by id, then resolve its exact split through
8
+ * the SAME canonical resolver the watchdog uses `resolveInjectTargetForSession`
9
+ * (lib/terminal/resolve.ts), precedence tmux > iterm > vscodium > pty. Sharing one
10
+ * resolver keeps the manual unblock path and the watchdog in agreement on which
11
+ * sessions are addressable (a prior duplicate resolver read only `provenance.reply`
12
+ * and could not address a VSCodium/Cursor terminal the watchdog handled fine).
13
+ * `--pane`/`--pty` target a backend directly when the handle is already known.
11
14
  */
12
15
  import type { Command } from 'commander';
13
16
  /** Attach the `inject` subcommand to an existing `sessions` command. */
@@ -4,33 +4,32 @@
4
4
  * (`injectIntoTerminal`, src/lib/terminal/inject.ts), so a native watchdog
5
5
  * (RUSH-1415) can shell out to nudge a stalled agent with "continue".
6
6
  *
7
- * Resolution: find the active session by id, map its `provenance.reply` rail
8
- * (provenance.ts:47) to an engine `InjectTarget` and inject. Today only tmux rails
9
- * are externally addressable; `--pane`/`--pty` target a backend directly when the
10
- * handle is already known (skipping the session lookup).
7
+ * Resolution: find the active session by id, then resolve its exact split through
8
+ * the SAME canonical resolver the watchdog uses `resolveInjectTargetForSession`
9
+ * (lib/terminal/resolve.ts), precedence tmux > iterm > vscodium > pty. Sharing one
10
+ * resolver keeps the manual unblock path and the watchdog in agreement on which
11
+ * sessions are addressable (a prior duplicate resolver read only `provenance.reply`
12
+ * and could not address a VSCodium/Cursor terminal the watchdog handled fine).
13
+ * `--pane`/`--pty` target a backend directly when the handle is already known.
11
14
  */
12
15
  import chalk from 'chalk';
13
16
  import { getActiveSessions } from '../lib/session/active.js';
14
- import { injectTargetFromReplyRail } from '../lib/session/inject.js';
17
+ import { resolveInjectTargetForSession } from '../lib/terminal/resolve.js';
15
18
  import { injectIntoTerminal } from '../lib/terminal/index.js';
16
19
  import { setHelpSections } from '../lib/help.js';
17
- /** Resolve a session id (short or full) to an addressable terminal target. */
20
+ /** Resolve a session id (short or full) to an addressable terminal target, via the
21
+ * same resolver the watchdog uses so both agree on what is reachable. */
18
22
  async function resolveTarget(sessionId) {
19
23
  const sessions = await getActiveSessions();
20
24
  const match = sessions.find((s) => s.sessionId === sessionId || (s.sessionId != null && s.sessionId.startsWith(sessionId)));
21
25
  if (!match)
22
26
  return { target: null, reason: `No active session matches "${sessionId}".` };
23
- const rail = match.provenance?.reply;
24
- if (!rail) {
25
- return {
26
- target: null,
27
- reason: `Session "${sessionId}" has no addressable reply rail (not running under tmux). Relaunch it under a tmux/pty rail to inject.`,
28
- };
27
+ const resolution = resolveInjectTargetForSession(match);
28
+ if (!resolution.addressable) {
29
+ // Surface the resolver's precise reason (host/rail), not a generic "not tmux".
30
+ return { target: null, reason: `Session "${sessionId}": ${resolution.reason}` };
29
31
  }
30
- const target = injectTargetFromReplyRail(rail);
31
- if (!target)
32
- return { target: null, reason: `Session "${sessionId}" reply rail is not injectable.` };
33
- return { target };
32
+ return { target: resolution.target };
34
33
  }
35
34
  async function runInject(sessionId, text, options) {
36
35
  // Direct-target shortcuts skip the session lookup — the watchdog often already
@@ -97,8 +96,9 @@ export function registerSessionsInjectCommand(sessionsCmd) {
97
96
  notes: `
98
97
  - Ink-TUI Enter semantics: by default the text and Enter are two separate
99
98
  writes, which is what Claude's Ink TUI needs. --combined fuses them.
100
- - Only sessions running under a tmux pane are addressable by id today
101
- (provenance.ts reply rails). Use --pane/--pty for direct targeting.
99
+ - A session is addressable by id when it resolves to a precise split —
100
+ tmux, iTerm, a VSCodium/Cursor/VS Code integrated terminal, or a pty
101
+ (resolveInjectTargetForSession). Use --pane/--pty for direct targeting.
102
102
  - Built on the Terminal Engine (src/lib/terminal): --host runs the tmux /
103
103
  AppleScript spec over the same SSH transport the launch engine uses.
104
104
  `,
@@ -11,7 +11,7 @@ import chalk from 'chalk';
11
11
  import { truncate, humanDuration } from '../lib/format.js';
12
12
  import { parseSession, sanitizeForTerminal } from '../lib/session/parse.js';
13
13
  import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.js';
14
- import { linkPath, linkUrl, relativeToCwd } from '../lib/session/render.js';
14
+ import { linkPath, linkUrl, relativeToCwd, shortenModel } from '../lib/session/render.js';
15
15
  import { linearIssueUrl } from '../lib/session/linear.js';
16
16
  import { extractTodoProgress, WORKTREE_RE } from '../lib/session/state.js';
17
17
  import { renderMarkdown } from '../lib/markdown.js';
@@ -137,7 +137,7 @@ function displayAgent(agent) {
137
137
  }
138
138
  const DOT = chalk.gray(' · ');
139
139
  function formatHeader(session, events) {
140
- const model = extractModel(events);
140
+ const model = extractModel(events) || session.model;
141
141
  const { startedAgo, duration } = extractTiming(events);
142
142
  const totalMessages = session.messageCount ?? countMessages(events);
143
143
  const totalTokens = session.tokenCount;
@@ -147,7 +147,7 @@ function formatHeader(session, events) {
147
147
  if (session.shortId)
148
148
  line1.push(chalk.dim(session.shortId));
149
149
  if (model)
150
- line1.push(chalk.bold.white(model));
150
+ line1.push(chalk.bold.white(shortenModel(model)));
151
151
  if (session.account)
152
152
  line1.push(chalk.gray(session.account));
153
153
  // Line 2: cwd · project · branch · started X ago · lasted Y
@@ -285,6 +285,8 @@ function formatCompactPreview(events, session) {
285
285
  let planFile = '';
286
286
  /** Latest checklist from the transcript (Claude TodoWrite / Codex update_plan). */
287
287
  let latestTodos;
288
+ let subAgentCount = 0;
289
+ const toolTags = new Set();
288
290
  for (const event of events) {
289
291
  if (event.type === 'message') {
290
292
  if (event.role === 'user' && !firstUser && event.content) {
@@ -298,6 +300,11 @@ function formatCompactPreview(events, session) {
298
300
  }
299
301
  else if (event.type === 'tool_use' && !event._local) {
300
302
  const tool = event.tool || '';
303
+ const command = event.command || '';
304
+ for (const tag of classifySessionTool(tool, command))
305
+ toolTags.add(tag);
306
+ if (isSubAgentTool(tool, command))
307
+ subAgentCount++;
301
308
  const p = event.path || event.args?.file_path || event.args?.path || '';
302
309
  if (['Read', 'read_file', 'view_file', 'cat_file', 'get_file'].includes(tool) && p) {
303
310
  filesRead.add(p);
@@ -369,6 +376,13 @@ function formatCompactPreview(events, session) {
369
376
  if (activity.length) {
370
377
  lines.push(chalk.cyan('Changes: ') + activity.join(chalk.gray(' · ')));
371
378
  }
379
+ const metadata = [
380
+ ...toolTags,
381
+ subAgentCount ? `${subAgentCount} sub-agent${subAgentCount === 1 ? '' : 's'}` : '',
382
+ ].filter(Boolean);
383
+ if (metadata.length) {
384
+ lines.push(chalk.cyan('Meta: ') + chalk.gray(metadata.join(' · ')));
385
+ }
372
386
  // Tool mix (top 4) — what kind of work this was.
373
387
  const hist = toolHistogram(toolCounts, 4);
374
388
  if (hist.length) {
@@ -403,6 +417,21 @@ function formatCompactPreview(events, session) {
403
417
  }
404
418
  return lines.map(l => ' ' + l).join('\n');
405
419
  }
420
+ function classifySessionTool(tool, command) {
421
+ const toolName = tool.toLowerCase();
422
+ const commandUses = (surface) => (new RegExp(`\\b(?:agents|ag)\\b[^\\n;&|]*\\b${surface}\\b`).test(command.toLowerCase()));
423
+ const tags = [];
424
+ if (/browser|webfetch|websearch/.test(toolName) || commandUses('browser'))
425
+ tags.push('browser');
426
+ if (/computer/.test(toolName) || commandUses('computer'))
427
+ tags.push('computer');
428
+ return tags;
429
+ }
430
+ function isSubAgentTool(tool, command) {
431
+ if (/^(Agent|Task)$/i.test(tool))
432
+ return true;
433
+ return /\b(?:agents|ag)\b[^\n;&|]*\b(?:run|cloud\s+run|teams\s+(?:add|start))\b/.test(command);
434
+ }
406
435
  /**
407
436
  * Unique directories the session touched, compact and human-readable.
408
437
  * Prefer `session.dirsTouched` when the parser teammate has populated it;
@@ -1,2 +1,18 @@
1
1
  import type { Command } from 'commander';
2
+ import { type Packing } from '../lib/terminal/index.js';
3
+ interface ResumeOptions {
4
+ agent?: string;
5
+ all?: boolean;
6
+ teams?: boolean;
7
+ since?: string;
8
+ limit?: string;
9
+ host?: string;
10
+ iterm?: boolean;
11
+ ghostty?: boolean;
12
+ tmux?: boolean;
13
+ vscodium?: boolean;
14
+ splits?: boolean;
15
+ }
2
16
  export declare function registerSessionsResumeCommand(sessionsCmd: Command): void;
17
+ export declare function resolveResumePacking(options: Pick<ResumeOptions, 'splits'>): Packing;
18
+ export {};
@@ -4,9 +4,9 @@
4
4
  *
5
5
  * Unlike the single-select picker behind bare `agents sessions` (which resumes
6
6
  * one session in place), this opens a checkbox picker, then asks where the
7
- * chosen sessions should resume. By default they pack two-per-tab (session 1 in
8
- * a new tab, session 2 split beside it, session 3 a new tab, …) in the terminal
9
- * you're in iTerm / Ghostty / tmux, locally or on a remote host via --host.
7
+ * chosen sessions should resume. By default each session opens in its own tab in
8
+ * the terminal you're in iTerm / Ghostty / tmux, locally or on a remote host
9
+ * via --host. `--splits` opts into packing two sessions side by side per tab.
10
10
  */
11
11
  import * as fs from 'fs';
12
12
  import chalk from 'chalk';
@@ -36,25 +36,24 @@ export function registerSessionsResumeCommand(sessionsCmd) {
36
36
  .option('--ghostty', 'Force the Ghostty backend')
37
37
  .option('--tmux', 'Force the tmux backend')
38
38
  .option('--vscodium', 'Force the VSCodium agent-terminal backend (swarm-ext)')
39
- .option('--tabs', 'One tab per session (default: pack two panes per tab)');
39
+ .option('--splits', 'Pack two sessions side by side per tab (default: one tab per session)');
40
40
  setHelpSections(cmd, {
41
41
  examples: `
42
- # Pick several sessions; they pack two-per-tab in your current terminal
42
+ # Pick several sessions; each opens in its own tab
43
43
  agents sessions resume
44
44
 
45
45
  # Pre-filter the pool before selecting (space in the filter → use [query])
46
46
  agents sessions resume "auth middleware"
47
47
 
48
- # Force a backend / one tab each / a remote host
48
+ # Force a backend / side-by-side splits / a remote host
49
49
  agents sessions resume --ghostty
50
50
  agents sessions resume --vscodium
51
- agents sessions resume --tabs
51
+ agents sessions resume --splits
52
52
  agents sessions resume --host zion --tmux
53
53
  `,
54
54
  notes: `
55
55
  - space toggles a session, enter confirms; tab toggles the preview pane.
56
- - Layout: two-per-tab by default (session 1 new tab, session 2 split beside it, …). --tabs disables splitting.
57
- - VSCodium defaults to one editor tab per session (matches swarm-ext's native agent-terminal UX); the others default to two-per-tab.
56
+ - Layout: one tab per session by default. --splits packs session pairs side by side in each tab.
58
57
  - Backend: auto-detected from the terminal you're in (iTerm / Ghostty / tmux); override with --iterm/--ghostty/--tmux/--vscodium.
59
58
  - --vscodium opens each session as an agent terminal tab in VSCodium via the swarm-ext extension (works with --host too).
60
59
  - --host <alias> resumes on a remote machine over the same SSH transport as 'sessions --host' (defaults to tmux).
@@ -153,10 +152,9 @@ async function sessionsResumeAction(query, options) {
153
152
  await resumeSessionInPlace(it.session);
154
153
  return;
155
154
  }
156
- // 5b. Fan out through the engine. Split-packing (two-per-tab) suits the
157
- // terminal apps; the VSCodium backend defaults to one editor tab per session,
158
- // matching swarm-ext's native agent-terminal UX. --tabs forces tabs anywhere.
159
- const packing = options.tabs || backend === 'vscodium-agent' ? 'tabs' : 'two-per-tab';
155
+ // 5b. Fan out through the engine. Full-width tabs are the default for batch
156
+ // recovery; callers can explicitly opt into pairs of side-by-side panes.
157
+ const packing = resolveResumePacking(options);
160
158
  const where = options.host ? `${backend} on ${options.host}` : backend;
161
159
  console.log(chalk.gray(`Opening ${items.length} session${items.length === 1 ? '' : 's'} in ${where} (${packing})…`));
162
160
  const results = await openSurfaces(items.map((it) => ({ cwd: it.cwd, command: it.command })), { backend, host: options.host, packing });
@@ -174,6 +172,9 @@ async function sessionsResumeAction(query, options) {
174
172
  });
175
173
  console.log(chalk.gray(`\nOpened ${opened}/${items.length} in ${where}.`));
176
174
  }
175
+ export function resolveResumePacking(options) {
176
+ return options.splits ? 'two-per-tab' : 'tabs';
177
+ }
177
178
  /**
178
179
  * Decide which backend to launch into. Returns a concrete backend, `'inplace'`
179
180
  * (resume in the current process — no GUI/tmux available), or `'cancel'` (the