@phnx-labs/agents-cli 1.20.92 → 1.21.0

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 (146) hide show
  1. package/CHANGELOG.md +385 -0
  2. package/README.md +3 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/beta.js +3 -5
  5. package/dist/commands/doctor.js +23 -0
  6. package/dist/commands/events.js +91 -1
  7. package/dist/commands/exec.js +13 -0
  8. package/dist/commands/mailboxes.js +39 -1
  9. package/dist/commands/message.js +12 -1
  10. package/dist/commands/packages.js +14 -5
  11. package/dist/commands/projects.d.ts +38 -0
  12. package/dist/commands/projects.js +355 -48
  13. package/dist/commands/routines.js +27 -1
  14. package/dist/commands/rules.js +6 -0
  15. package/dist/commands/secrets.d.ts +17 -0
  16. package/dist/commands/secrets.js +198 -7
  17. package/dist/commands/send.d.ts +14 -12
  18. package/dist/commands/send.js +105 -35
  19. package/dist/commands/sessions.d.ts +17 -0
  20. package/dist/commands/sessions.js +16 -8
  21. package/dist/commands/setup-browser.js +5 -1
  22. package/dist/commands/setup-fleet.js +5 -0
  23. package/dist/commands/setup-preferences.d.ts +53 -0
  24. package/dist/commands/setup-preferences.js +142 -0
  25. package/dist/commands/setup.js +8 -9
  26. package/dist/commands/ssh.js +220 -10
  27. package/dist/commands/sync.d.ts +2 -2
  28. package/dist/commands/sync.js +13 -12
  29. package/dist/commands/view.js +4 -0
  30. package/dist/commands/watchdog.js +26 -3
  31. package/dist/index.js +38 -6
  32. package/dist/lib/activity.d.ts +15 -0
  33. package/dist/lib/activity.js +26 -2
  34. package/dist/lib/auto-dispatch.d.ts +6 -1
  35. package/dist/lib/auto-dispatch.js +7 -2
  36. package/dist/lib/beta.d.ts +1 -1
  37. package/dist/lib/beta.js +1 -1
  38. package/dist/lib/channels/send.d.ts +83 -0
  39. package/dist/lib/channels/send.js +112 -0
  40. package/dist/lib/daemon.d.ts +13 -0
  41. package/dist/lib/daemon.js +80 -60
  42. package/dist/lib/device-config.d.ts +82 -0
  43. package/dist/lib/device-config.js +296 -0
  44. package/dist/lib/event-provenance.d.ts +19 -0
  45. package/dist/lib/event-provenance.js +48 -0
  46. package/dist/lib/events-ingest.d.ts +46 -0
  47. package/dist/lib/events-ingest.js +182 -0
  48. package/dist/lib/events.d.ts +17 -5
  49. package/dist/lib/events.js +59 -56
  50. package/dist/lib/feed.d.ts +1 -1
  51. package/dist/lib/feed.js +2 -0
  52. package/dist/lib/git.d.ts +13 -2
  53. package/dist/lib/git.js +38 -6
  54. package/dist/lib/linear-project-counts.d.ts +129 -0
  55. package/dist/lib/linear-project-counts.js +206 -0
  56. package/dist/lib/linear-projects.d.ts +62 -0
  57. package/dist/lib/linear-projects.js +144 -0
  58. package/dist/lib/mailbox-gc.js +30 -7
  59. package/dist/lib/mailbox.d.ts +14 -1
  60. package/dist/lib/mailbox.js +35 -3
  61. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  63. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  64. package/dist/lib/menubar/install-menubar.js +35 -44
  65. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  66. package/dist/lib/menubar/notify-desktop.js +8 -2
  67. package/dist/lib/migrate.js +1 -1
  68. package/dist/lib/models.js +21 -11
  69. package/dist/lib/overdue.d.ts +1 -1
  70. package/dist/lib/overdue.js +54 -19
  71. package/dist/lib/project-import.d.ts +96 -0
  72. package/dist/lib/project-import.js +171 -0
  73. package/dist/lib/project-probe.d.ts +75 -0
  74. package/dist/lib/project-probe.js +160 -0
  75. package/dist/lib/project-resources.d.ts +8 -0
  76. package/dist/lib/project-resources.js +31 -3
  77. package/dist/lib/project-status.d.ts +32 -1
  78. package/dist/lib/project-status.js +82 -1
  79. package/dist/lib/projects.d.ts +6 -0
  80. package/dist/lib/projects.js +12 -0
  81. package/dist/lib/remote-agents-json.d.ts +14 -1
  82. package/dist/lib/remote-agents-json.js +21 -3
  83. package/dist/lib/routine-notify.d.ts +11 -0
  84. package/dist/lib/routine-notify.js +22 -0
  85. package/dist/lib/routines.d.ts +53 -0
  86. package/dist/lib/routines.js +105 -4
  87. package/dist/lib/rules/run-sync.d.ts +18 -0
  88. package/dist/lib/rules/run-sync.js +92 -0
  89. package/dist/lib/run-notify.js +3 -0
  90. package/dist/lib/scheduler.js +12 -2
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/audit.d.ts +1 -1
  94. package/dist/lib/secrets/audit.js +53 -10
  95. package/dist/lib/secrets/index.d.ts +4 -5
  96. package/dist/lib/secrets/index.js +4 -5
  97. package/dist/lib/secrets/list-filter.d.ts +20 -5
  98. package/dist/lib/secrets/list-filter.js +22 -6
  99. package/dist/lib/secrets/usage-db.d.ts +106 -0
  100. package/dist/lib/secrets/usage-db.js +236 -0
  101. package/dist/lib/session/active.d.ts +16 -2
  102. package/dist/lib/session/active.js +8 -4
  103. package/dist/lib/session/bundle.js +5 -1
  104. package/dist/lib/session/remote-active.d.ts +5 -1
  105. package/dist/lib/session/remote-active.js +4 -1
  106. package/dist/lib/session/remote-list.d.ts +1 -15
  107. package/dist/lib/session/remote-list.js +22 -111
  108. package/dist/lib/session/sync/config.d.ts +13 -5
  109. package/dist/lib/session/sync/config.js +21 -13
  110. package/dist/lib/sqlite.js +28 -1
  111. package/dist/lib/startup/command-registry.d.ts +0 -2
  112. package/dist/lib/startup/command-registry.js +1 -5
  113. package/dist/lib/state.d.ts +19 -2
  114. package/dist/lib/state.js +99 -10
  115. package/dist/lib/sync-umbrella.d.ts +11 -23
  116. package/dist/lib/sync-umbrella.js +14 -45
  117. package/dist/lib/teams/agents.d.ts +35 -3
  118. package/dist/lib/teams/agents.js +52 -8
  119. package/dist/lib/teams/scheduler.d.ts +29 -2
  120. package/dist/lib/teams/scheduler.js +60 -16
  121. package/dist/lib/types.d.ts +22 -5
  122. package/dist/lib/versions.d.ts +6 -0
  123. package/dist/lib/versions.js +6 -4
  124. package/package.json +1 -2
  125. package/dist/commands/drive.d.ts +0 -10
  126. package/dist/commands/drive.js +0 -183
  127. package/dist/commands/hq.d.ts +0 -2
  128. package/dist/commands/hq.js +0 -58
  129. package/dist/commands/sessions-sync.d.ts +0 -17
  130. package/dist/commands/sessions-sync.js +0 -135
  131. package/dist/commands/sync-provision.d.ts +0 -23
  132. package/dist/commands/sync-provision.js +0 -107
  133. package/dist/lib/drive-sync.d.ts +0 -45
  134. package/dist/lib/drive-sync.js +0 -238
  135. package/dist/lib/hq/floor.d.ts +0 -87
  136. package/dist/lib/hq/floor.js +0 -243
  137. package/dist/lib/session/sync/crdt.d.ts +0 -44
  138. package/dist/lib/session/sync/crdt.js +0 -119
  139. package/dist/lib/session/sync/manifest.d.ts +0 -62
  140. package/dist/lib/session/sync/manifest.js +0 -100
  141. package/dist/lib/session/sync/provision.d.ts +0 -49
  142. package/dist/lib/session/sync/provision.js +0 -91
  143. package/dist/lib/session/sync/r2.d.ts +0 -32
  144. package/dist/lib/session/sync/r2.js +0 -121
  145. package/dist/lib/session/sync/sync.d.ts +0 -106
  146. package/dist/lib/session/sync/sync.js +0 -374
@@ -31,6 +31,7 @@ import { readMeta } from '../lib/state.js';
31
31
  import { parseDuration } from '../lib/hooks/cache.js';
32
32
  import { emit, query } from '../lib/events.js';
33
33
  import { emitSecretAudit } from '../lib/secrets/audit.js';
34
+ import { getBundleUsage, getAllBundleUsage, getUsageHistory, SECRET_USAGE_EVENTS, } from '../lib/secrets/usage-db.js';
34
35
  import { frequentlyPromptedBundles } from '../lib/secrets/unlock-hints.js';
35
36
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
36
37
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -733,6 +734,54 @@ function countExpiringSoon(meta) {
733
734
  }
734
735
  return n;
735
736
  }
737
+ /** Human labels for the usage events, in the order `view` prints them. */
738
+ const USAGE_EVENT_LABELS = {
739
+ access: 'accessed',
740
+ unlock: 'unlocked',
741
+ import: 'imported',
742
+ export: 'exported',
743
+ create: 'created',
744
+ view: 'viewed',
745
+ };
746
+ /**
747
+ * Compact one-line usage summary for `secrets view` — each recorded event with
748
+ * its count and how long ago it last happened ("accessed 42× (last 2h ago) ·
749
+ * exported 3× (last 1d ago)"). Empty string when nothing has been recorded yet.
750
+ * Pure — unit-tested.
751
+ */
752
+ export function formatUsageLine(summary) {
753
+ if (!summary || summary.total === 0)
754
+ return '';
755
+ const parts = [];
756
+ for (const ev of SECRET_USAGE_EVENTS) {
757
+ const stat = summary.events[ev];
758
+ if (stat.count === 0)
759
+ continue;
760
+ const age = stat.last ? relativeAge(stat.last) : null;
761
+ parts.push(`${USAGE_EVENT_LABELS[ev]} ${stat.count}×${age ? ` (last ${age})` : ''}`);
762
+ }
763
+ return parts.join(' · ');
764
+ }
765
+ /**
766
+ * The held-state line for `secrets view`: whether the secrets-agent currently
767
+ * holds this bundle (so reads are prompt-free) and for how long, or that Touch
768
+ * ID is required on the next read. Only meaningful for keychain bundles on macOS
769
+ * — file/vault backends have no broker to hold, and a `never`-policy bundle is
770
+ * always readable — so both return an empty line and the caller skips it.
771
+ * `heldExpiresAt` is the max expiry across every held scope.
772
+ */
773
+ export function renderViewStatusLine(b, heldExpiresAt) {
774
+ if (process.platform !== 'darwin')
775
+ return '';
776
+ if ((b.backend ?? 'keychain') !== 'keychain')
777
+ return '';
778
+ if (bundlePolicy(b) === 'never')
779
+ return '';
780
+ if (heldExpiresAt && heldExpiresAt > Date.now()) {
781
+ return chalk.green(`status: unlocked (held ${compactRemaining(heldExpiresAt)}; reads are prompt-free until it locks)`);
782
+ }
783
+ return chalk.gray('status: locked (Touch ID required on the next read; `agents secrets unlock` holds it)');
784
+ }
736
785
  /**
737
786
  * The EXPIRING cell. Counts keys needing attention — already expired plus due
738
787
  * within 30 days — and colours by the worst of the two: red once anything has
@@ -819,9 +868,13 @@ export function registerSecretsCommands(program) {
819
868
  # Inject the bundle into an agent run
820
869
  agents run claude "deploy the worker" --secrets prod
821
870
 
822
- # See what's in the bundle (values masked); shows its prompt policy
871
+ # See what's in the bundle (values masked); shows its policy, held state, and usage
823
872
  agents secrets view prod
824
873
 
874
+ # Order the list by how recently / how often each bundle is used
875
+ agents secrets list --sort used
876
+ agents secrets list --sort uses
877
+
825
878
  # Stop a noisy automation bundle from prompting every run: ask once a week
826
879
  agents secrets policy prod hold
827
880
 
@@ -839,6 +892,17 @@ export function registerSecretsCommands(program) {
839
892
  never touch disk in plaintext. Every item is device-local and gated by Touch ID
840
893
  or device passcode; cross-machine sync is handled by 'agents secrets push/pull'.
841
894
 
895
+ Naming: name a bundle after the thing it holds credentials for, so an agent can
896
+ guess it without listing. For a website, use its domain WITH the real suffix —
897
+ 'stripe.com', 'openai.ai', 'github.com'. For a desktop app, use the app's binary
898
+ suffix — 'slack.app' (macOS) or 'photoshop.exe' (Windows). Always pass
899
+ '--description' so 'list' / 'view' explain the bundle without opening it; an
900
+ undescribed bundle prints a "No description found" nudge. 'view' also shows
901
+ whether the bundle is currently unlocked (held by the agent) and how often / how
902
+ recently it has been created, imported, exported, viewed, and accessed;
903
+ 'list --sort used|uses' orders by recency / frequency, and
904
+ 'agents secrets activity [bundle]' prints the recent value-free event timeline.
905
+
842
906
  Touch ID noise: macOS pops a prompt per bundle per process. Each bundle has
843
907
  a prompt policy, shown in the POLICY column of 'agents secrets list':
844
908
  hold (default) ask once, then serve it silently from the local agent
@@ -868,7 +932,7 @@ export function registerSecretsCommands(program) {
868
932
  `,
869
933
  });
870
934
  registerCommandGroups(cmd, [
871
- { title: 'Bundle commands', names: ['list', 'view', 'create', 'rename', 'describe', 'delete'] },
935
+ { title: 'Bundle commands', names: ['list', 'view', 'activity', 'create', 'rename', 'describe', 'delete'] },
872
936
  { title: 'Secret commands', names: ['add', 'rotate', 'remove', 'import', 'export'] },
873
937
  { title: 'Agent commands', names: ['start', 'stop', 'unlock', 'lock', 'status', 'policy'] },
874
938
  { title: 'Raw item commands', names: ['get', 'set'] },
@@ -893,7 +957,7 @@ export function registerSecretsCommands(program) {
893
957
  .option('--expired', 'Only bundles with at least one key whose expiry has already passed')
894
958
  .option('--expiring [days]', `Only bundles with a key falling due within N days (default ${DEFAULT_EXPIRING_DAYS})`)
895
959
  .option('--unused <duration>', 'Only bundles not read since this far back (e.g. 30d, 4w, 3mo). Never-used bundles always match')
896
- .addOption(new Option('--sort <field>', 'Sort by: name (default), used, created, updated, expiry').choices([...SORT_FIELDS]))
960
+ .addOption(new Option('--sort <field>', 'Sort by: name (default), used (most recently used), uses (most frequently accessed), created, updated, expiry').choices([...SORT_FIELDS]))
897
961
  .option('-n, --limit <n>', 'Show at most this many bundles (after filtering and sorting)')
898
962
  .action(async (query, opts) => {
899
963
  const targets = parseHostsOption(opts);
@@ -942,11 +1006,20 @@ export function registerSecretsCommands(program) {
942
1006
  /* broker not running — render policy without the countdown */
943
1007
  }
944
1008
  }
1009
+ // Value-free usage read-model backs `--sort used|uses` and the --json
1010
+ // `uses`/`usage` fields. Open the DB only when a caller actually needs it
1011
+ // (a machine payload, or a usage-ranked sort) so a plain human `list`
1012
+ // stays a pure keychain read.
1013
+ const needUsage = Boolean(opts.json) || sortField === 'used' || sortField === 'uses';
1014
+ const usage = needUsage ? getAllBundleUsage() : new Map();
1015
+ const usageHints = new Map();
1016
+ for (const [name, s] of usage)
1017
+ usageHints.set(name, { lastUsedAt: s.lastUsedAt, uses: s.events.access.count });
945
1018
  // Filter, sort, then cap — before the --json branch, so the machine
946
1019
  // payload is the exact twin of the table (the sessions ordering rule).
947
1020
  const now = Date.now();
948
1021
  const matched = all.filter((b) => bundleMatchesFilter(b, filter, { held, now }));
949
- const sorted = sortBundles(matched, sortField);
1022
+ const sorted = sortBundles(matched, sortField, usageHints);
950
1023
  const bundles = limit === undefined ? sorted : sorted.slice(0, limit);
951
1024
  if (opts.json) {
952
1025
  // Discovery payload for agents: metadata only, no secret values. Gated on
@@ -974,6 +1047,11 @@ export function registerSecretsCommands(program) {
974
1047
  // stale past timestamp as if the bundle were still warm, disagreeing
975
1048
  // with the table and the filter about the very same bundle.
976
1049
  heldExpiresAt: liveHold(held.get(b.name), now),
1050
+ // Value-free usage from the read-model: `uses` is the recorded access
1051
+ // (read/inject) count; `usage` is the full per-kind rollup. Null when
1052
+ // nothing has been recorded yet.
1053
+ uses: usage.get(b.name)?.events.access.count ?? 0,
1054
+ usage: usage.get(b.name) ?? null,
977
1055
  }));
978
1056
  process.stdout.write(JSON.stringify(payload) + '\n');
979
1057
  return;
@@ -1010,6 +1088,9 @@ export function registerSecretsCommands(program) {
1010
1088
  # Everything, newest-used first
1011
1089
  agents secrets list --sort used
1012
1090
 
1091
+ # Order by how often each bundle is read (most-accessed first)
1092
+ agents secrets list --sort uses
1093
+
1013
1094
  # Find one by name or description
1014
1095
  agents secrets list github
1015
1096
 
@@ -1044,6 +1125,51 @@ export function registerSecretsCommands(program) {
1044
1125
  - --unused matches bundles never read at all, not just old ones.
1045
1126
  - Filters apply before --json, so the JSON is the exact twin of the table.
1046
1127
  - Filters are forwarded over --host, so a remote list narrows the same way.
1128
+ - --sort used|uses read the value-free usage read-model (~/.agents/secrets/secrets.db); see 'agents secrets activity'.
1129
+ `,
1130
+ });
1131
+ const activityCmd = cmd
1132
+ .command('activity [name]')
1133
+ .description('Show the recent value-free usage timeline (created / imported / exported / viewed / accessed / unlocked) for one bundle, or across all bundles')
1134
+ .option('-n, --limit <n>', 'Show at most this many events (default 20)')
1135
+ .option('--json', 'Emit machine-readable JSON (metadata only — never secret values)')
1136
+ .action((name, opts) => {
1137
+ let limit = opts.limit === undefined ? 20 : Number(opts.limit);
1138
+ if (!Number.isInteger(limit) || limit < 1) {
1139
+ console.error(chalk.red(`Invalid --limit '${opts.limit}'. Use a whole number of events, e.g. --limit 50.`));
1140
+ process.exit(1);
1141
+ }
1142
+ const events = getUsageHistory(name, limit);
1143
+ if (opts.json) {
1144
+ process.stdout.write(JSON.stringify(events) + '\n');
1145
+ return;
1146
+ }
1147
+ if (events.length === 0) {
1148
+ console.log(chalk.gray(name ? `No recorded activity for '${name}'.` : 'No recorded secrets activity yet.'));
1149
+ return;
1150
+ }
1151
+ for (const e of events) {
1152
+ const who = e.agent ? chalk.gray(` by ${e.agent}`) : '';
1153
+ const where = e.source ? chalk.gray(` (${e.source}${e.host ? ` ${e.host}` : ''})`) : '';
1154
+ const bundleTag = name ? '' : chalk.cyan(` ${e.bundle}`);
1155
+ console.log(`${chalk.gray(relativeAge(e.ts).padEnd(10))} ${chalk.bold(e.event.padEnd(8))}${bundleTag}${who}${where}`);
1156
+ }
1157
+ });
1158
+ setHelpSections(activityCmd, {
1159
+ examples: `
1160
+ # The recent value-free event timeline across every bundle
1161
+ agents secrets activity
1162
+
1163
+ # Just one bundle, more history
1164
+ agents secrets activity anthropic.com --limit 50
1165
+
1166
+ # Machine-readable
1167
+ agents secrets activity anthropic.com --json
1168
+ `,
1169
+ notes: `
1170
+ - Reads the value-free usage read-model (~/.agents/secrets/secrets.db), fed off the
1171
+ same emitSecretAudit chokepoint as 'agents events'. Never a secret value.
1172
+ - History is bounded to the last 90 days; the full audit trail is 'agents events --module secrets'.
1047
1173
  `,
1048
1174
  });
1049
1175
  cmd
@@ -1090,6 +1216,29 @@ export function registerSecretsCommands(program) {
1090
1216
  process.exit(1);
1091
1217
  }
1092
1218
  const entries = describeBundle(bundle);
1219
+ // Value-free usage read-model (per-kind counts + recency + per-agent) for
1220
+ // both the human and --json views. Read before recording this view so the
1221
+ // just-happened view isn't counted in its own summary.
1222
+ const usage = getBundleUsage(bundle.name);
1223
+ // Is the secrets-agent currently holding this bundle? A held bundle reads
1224
+ // prompt-free. macOS keychain only; take the max expiry across every scope
1225
+ // it's held under. Soft-fails to "not held" if the broker is down.
1226
+ let heldExpiresAt = null;
1227
+ if (process.platform === 'darwin' && (bundle.backend ?? 'keychain') === 'keychain') {
1228
+ try {
1229
+ for (const e of await agentStatus()) {
1230
+ if (e.name === bundle.name && (heldExpiresAt === null || e.expiresAt > heldExpiresAt)) {
1231
+ heldExpiresAt = e.expiresAt;
1232
+ }
1233
+ }
1234
+ }
1235
+ catch {
1236
+ /* broker not running — render as not held */
1237
+ }
1238
+ }
1239
+ // Record the inspection through the ONE chokepoint (events.jsonl + the
1240
+ // usage read-model). Value-free; a masked `view` is metadata only.
1241
+ emitSecretAudit({ event: 'secrets.view', bundle: bundle.name, operation: opts.json ? 'view --json' : 'view', source: 'view', status: 'success' });
1093
1242
  if (opts.json) {
1094
1243
  // Machine-readable discovery for agents. Values are null unless --reveal
1095
1244
  // (which still routes through the same non-TTY/plaintext gate + audit
@@ -1158,14 +1307,25 @@ export function registerSecretsCommands(program) {
1158
1307
  createdAt: bundle.created_at ?? null,
1159
1308
  updatedAt: bundle.updated_at ?? null,
1160
1309
  lastUsed: bundle.last_used ?? null,
1310
+ // Held state + value-free usage rollup, same data the human view
1311
+ // shows. `heldExpiresAt` is null when the bundle is not held.
1312
+ heldExpiresAt,
1313
+ usage: usage ?? null,
1314
+ uses: usage?.events.access.count ?? 0,
1161
1315
  revealed: reveal,
1162
1316
  keys,
1163
1317
  }) + '\n');
1164
1318
  return;
1165
1319
  }
1166
1320
  console.log(chalk.bold(bundle.name));
1167
- if (bundle.description)
1321
+ if (bundle.description) {
1168
1322
  console.log(chalk.gray(safePrint(bundle.description)));
1323
+ }
1324
+ else {
1325
+ // A described bundle is self-documenting for the next agent/human that
1326
+ // reads it. Nudge — never block — toward adding one.
1327
+ console.log(chalk.yellow(`No description found. Add one: agents secrets describe ${bundle.name} "what this bundle is for"`));
1328
+ }
1169
1329
  if (bundle.allow_exec)
1170
1330
  console.log(chalk.yellow('allow_exec: true'));
1171
1331
  if (bundle.backend === 'file')
@@ -1183,12 +1343,22 @@ export function registerSecretsCommands(program) {
1183
1343
  ? chalk.gray(`policy: hold (ask once, then held for ${formatHoldWindow(secretsHoldMs())} — until sleep / logout; screen-lock does not drop it)`)
1184
1344
  : chalk.gray('policy: always (asks for Touch ID every time — never auto-held)'));
1185
1345
  }
1346
+ const statusLine = renderViewStatusLine(bundle, heldExpiresAt);
1347
+ if (statusLine)
1348
+ console.log(statusLine);
1186
1349
  if (bundle.created_at)
1187
1350
  console.log(chalk.gray(`created_at: ${bundle.created_at} (${humanAge(bundle.created_at)})`));
1188
1351
  if (bundle.updated_at)
1189
1352
  console.log(chalk.gray(`updated_at: ${bundle.updated_at} (${humanAge(bundle.updated_at)})`));
1190
1353
  if (bundle.last_used)
1191
1354
  console.log(chalk.gray(`last_used: ${bundle.last_used} (${humanAge(bundle.last_used)})`));
1355
+ const usageLine = formatUsageLine(usage);
1356
+ if (usageLine)
1357
+ console.log(chalk.gray(`usage: ${usageLine}`));
1358
+ if (usage && usage.byAgent.length > 0) {
1359
+ const top = usage.byAgent.slice(0, 3).map((a) => `${a.agent} ${a.count}×`).join(', ');
1360
+ console.log(chalk.gray(`by: ${top}`));
1361
+ }
1192
1362
  console.log();
1193
1363
  if (entries.length === 0) {
1194
1364
  console.log(chalk.gray('(no keys)'));
@@ -1357,8 +1527,8 @@ export function registerSecretsCommands(program) {
1357
1527
  });
1358
1528
  cmd
1359
1529
  .command('create [name]')
1360
- .description('Create an empty bundle')
1361
- .option('--description <text>', 'Free-form description')
1530
+ .description('Create an empty bundle. Name it after what it holds — a website by domain (stripe.com, openai.ai), a desktop app by its binary suffix (slack.app, photoshop.exe) — and pass --description.')
1531
+ .option('--description <text>', 'Free-form description (recommended — an undescribed bundle prints a "No description found" nudge)')
1362
1532
  .option('--allow-exec', 'Allow exec: refs in this bundle (off by default)')
1363
1533
  .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'.")
1364
1534
  .addOption(new Option('--tier <policy>', 'deprecated alias for --policy').hideHelp())
@@ -1395,6 +1565,9 @@ export function registerSecretsCommands(program) {
1395
1565
  vars: {},
1396
1566
  };
1397
1567
  writeBundle(bundle);
1568
+ // Record the create through the ONE chokepoint (events.jsonl + the usage
1569
+ // read-model). Value-free — bundle name only.
1570
+ emitSecretAudit({ event: 'secrets.create', bundle: resolvedName, operation: 'create', source: 'create', status: 'success' });
1398
1571
  const policyTag = bundlePolicy(bundle) === 'hold'
1399
1572
  ? 'policy: hold'
1400
1573
  : bundlePolicy(bundle) === 'always'
@@ -1406,6 +1579,11 @@ export function registerSecretsCommands(program) {
1406
1579
  backend === 'vault' ? 'synced' : null,
1407
1580
  ].filter(Boolean);
1408
1581
  console.log(chalk.green(`Bundle '${resolvedName}' created (${tags.join(', ')}).`));
1582
+ if (!bundle.description) {
1583
+ // A described bundle is self-documenting for the next agent that reads
1584
+ // `list` / `view`. Nudge toward one at create time — never blocking.
1585
+ console.log(chalk.yellow(`No description found. Add one: agents secrets describe ${resolvedName} "what this bundle is for"`));
1586
+ }
1409
1587
  if (bundlePolicy(bundle) === 'never') {
1410
1588
  console.log(chalk.red('Stored without biometry protection — reads are silent. Automation-only; rotate anything sensitive out of it.'));
1411
1589
  }
@@ -1807,6 +1985,7 @@ Examples:
1807
1985
  const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
1808
1986
  const bundle = resolveImportBundle(resolvedBundleName, opts.backend, opts.synced);
1809
1987
  const { added, skipped } = applyEnvToBundle(bundle, env, opts);
1988
+ emitSecretAudit({ event: 'secrets.import', bundle: bundle.name, operation: 'import --from-file', source: 'file', status: 'success', keyCount: added });
1810
1989
  console.log(chalk.green(`Imported ${added} key(s) from file${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
1811
1990
  return;
1812
1991
  }
@@ -1820,6 +1999,7 @@ Examples:
1820
1999
  const env = await remoteResolveEnv(target, resolvedBundleName, { osLookupName: opts.host });
1821
2000
  const bundle = resolveImportBundle(resolvedBundleName, opts.backend, opts.synced);
1822
2001
  const { added, skipped } = applyEnvToBundle(bundle, env, opts);
2002
+ emitSecretAudit({ event: 'secrets.import', bundle: bundle.name, operation: 'import --from-ssh', source: 'ssh', host: opts.host, status: 'success', keyCount: added });
1823
2003
  console.log(chalk.green(`Imported ${added} key(s) from ${opts.host}${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
1824
2004
  return;
1825
2005
  }
@@ -1855,6 +2035,7 @@ Examples:
1855
2035
  for (const { envKey, value } of secrets)
1856
2036
  env[envKey] = value;
1857
2037
  const { added, skipped } = applyEnvToBundle(bundle, env, opts);
2038
+ emitSecretAudit({ event: 'secrets.import', bundle: bundle.name, operation: `import --from 1password:${vault}`, source: '1password', status: 'success', keyCount: added });
1858
2039
  if (opSkipped.length) {
1859
2040
  console.log(chalk.yellow(`Skipped ${opSkipped.length} item(s) with no importable fields.`));
1860
2041
  }
@@ -1864,6 +2045,7 @@ Examples:
1864
2045
  const raw = readImportDotenv(source.path);
1865
2046
  const pairs = parseDotenv(raw);
1866
2047
  const { added, skipped } = applyEnvToBundle(bundle, pairs, opts);
2048
+ emitSecretAudit({ event: 'secrets.import', bundle: bundle.name, operation: 'import --from env-file', source: 'env-file', status: 'success', keyCount: added });
1867
2049
  console.log(chalk.green(`Imported ${added} key(s)${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
1868
2050
  }
1869
2051
  }
@@ -1903,6 +2085,7 @@ Examples:
1903
2085
  }
1904
2086
  const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: 'export --to-file', keyMode: 'storage', agentOnly: isHeadlessSecretsContext() });
1905
2087
  exportBundleToFile(env, opts.toFile, passphrase);
2088
+ emitSecretAudit({ event: 'secrets.export', bundle: resolvedBundleName, operation: 'export --to-file', source: 'file', status: 'success', keyCount: Object.keys(env).length });
1906
2089
  console.log(chalk.green(`Exported ${Object.keys(env).length} key(s) to ${opts.toFile}`));
1907
2090
  return;
1908
2091
  }
@@ -2009,6 +2192,7 @@ Examples:
2009
2192
  const remoteMsg = (res.stdout || '').trim().split('\n').map((l) => l.trim()).filter(Boolean).pop();
2010
2193
  console.log(chalk.green(`${host} -> '${resolvedBundleName}': ${remoteMsg || `${keyCount} key(s) exported`}`));
2011
2194
  }
2195
+ emitSecretAudit({ event: 'secrets.export', bundle: resolvedBundleName, operation: `export --host ${hosts.join(',')}`, source: 'ssh', host: hosts.join(','), status: failures > 0 ? 'error' : 'success', keyCount });
2012
2196
  if (failures > 0)
2013
2197
  process.exit(1);
2014
2198
  return;
@@ -2043,6 +2227,7 @@ Examples:
2043
2227
  parts.push(`${overwritten} overwritten`);
2044
2228
  if (skipped)
2045
2229
  parts.push(`${skipped} skipped (already exist, pass --force)`);
2230
+ emitSecretAudit({ event: 'secrets.export', bundle: resolvedBundleName, operation: `export --to-1password ${vault}`, source: '1password', status: 'success', keyCount: created + overwritten });
2046
2231
  console.log(chalk.green(`Exported to 1Password vault '${vault}': ${parts.join(', ')}.`));
2047
2232
  return;
2048
2233
  }
@@ -2077,6 +2262,12 @@ Examples:
2077
2262
  const output = needsQuotes ? `'${v.replace(/'/g, `'\\''`)}'` : v;
2078
2263
  process.stdout.write(`export ${exportKey}=${output}\n`);
2079
2264
  }
2265
+ // Record the shell export (the `eval "$(...)"` path). The `--format json`
2266
+ // branch above is the machine-to-machine remote-resolve transport (already
2267
+ // counted as an access on this machine by the resolve above), so it is
2268
+ // deliberately not counted here as an export — that would tally every peer
2269
+ // pull as an export.
2270
+ emitSecretAudit({ event: 'secrets.export', bundle: resolvedBundleName, operation: 'export --plaintext', source: 'shell', status: 'success', keyCount: Object.keys(env).length });
2080
2271
  }
2081
2272
  catch (err) {
2082
2273
  if (isPromptCancelled(err))
@@ -1,19 +1,21 @@
1
1
  /**
2
- * `agents send <text> --channel <name> --to <target>` — deliver a message over
3
- * any registered channel (mailbox, telegram, imessage, slack, discord, desktop, apps).
2
+ * `agents send` — deliver a message over any registered channel provider.
4
3
  *
5
- * `desktop` is the local machine's notification centre — the only channel with no
6
- * external dependency, so it still reaches the operator at their Mac when a
7
- * messaging gateway is down.
4
+ * Envelope (flag-first, industry-shaped):
5
+ * agents send --to <dest> --text "…" [--channel <name>] [--attach …] [--url …]
8
6
  *
9
- * `agents notify <text>` — owner-facing alias. Fills channel + target from the
10
- * `notify.owner` block in agents.yaml when not passed, so routines/agents ping
11
- * the owner without hardcoding a chat id / host / account.
7
+ * Destination:
8
+ * --to owner expands to notify.owner.{channel,to} in agents.yaml
9
+ * --to <id> channel-specific recipient (requires --channel)
12
10
  *
13
- * Both go through one `doSend()` seam: resolve the provider for the channel
14
- * (notify.transports picks the transport per host) and call provider.send().
15
- * The agent mailbox is just one channel; `agents message` stays the richer
16
- * agent-control command (feed-claim / identity / PTY / resume) on the same spool.
11
+ * Compat: positional text still works (`agents send "hi" --channel --to …`).
12
+ *
13
+ * `agents notify` is the same delivery path with owner defaults
14
+ * (`send --to owner`). Not a second stack. Not agent control use
15
+ * `agents message` / `agents sessions inject` for running agents.
16
+ *
17
+ * Feed / activity are a different plane (record + read); feed.broadcast may
18
+ * call this command as a forward sink.
17
19
  */
18
20
  import type { Command } from 'commander';
19
21
  export declare function registerSendCommand(program: Command): void;
@@ -1,20 +1,39 @@
1
1
  import chalk from 'chalk';
2
2
  import { die } from '../lib/format.js';
3
+ import { setHelpSections } from '../lib/help.js';
3
4
  import { readMeta } from '../lib/state.js';
4
- import { registerBuiltinProviders } from '../lib/channels/providers/index.js';
5
- import { resolveTransport } from '../lib/channels/resolve.js';
6
- async function doSend(text, channel, to, opts) {
7
- registerBuiltinProviders();
8
- const provider = resolveTransport(channel, readMeta());
9
- const result = await provider.send(text, {
10
- target: to,
5
+ import { sendMessage } from '../lib/channels/send.js';
6
+ function mergeAttachments(opts) {
7
+ const list = [...(opts.attach ?? []), ...(opts.attachment ?? [])];
8
+ return list.length ? list : undefined;
9
+ }
10
+ function toInput(positionalText, opts, ownerMode) {
11
+ return {
12
+ text: opts.text,
13
+ positionalText,
14
+ to: opts.to,
15
+ channel: opts.channel,
11
16
  thread: opts.thread,
12
- attachments: opts.attachment,
17
+ attachments: mergeAttachments(opts),
18
+ urls: opts.url,
13
19
  from: opts.from,
14
20
  dryRun: opts.dryRun,
15
- });
21
+ ownerMode,
22
+ };
23
+ }
24
+ async function runSend(positionalText, opts, ownerMode) {
25
+ const meta = readMeta();
26
+ const out = await sendMessage(toInput(positionalText, opts, ownerMode), meta);
27
+ if ('error' in out) {
28
+ die(out.error);
29
+ }
30
+ const { result, envelope } = out;
16
31
  if (opts.json) {
17
- console.log(JSON.stringify(result));
32
+ console.log(JSON.stringify({
33
+ ...result,
34
+ text: envelope.text,
35
+ dryRun: Boolean(envelope.dryRun),
36
+ }));
18
37
  if (!result.ok)
19
38
  process.exit(1);
20
39
  return;
@@ -23,40 +42,91 @@ async function doSend(text, channel, to, opts) {
23
42
  die(`send failed [${result.channel} → ${result.id}]: ${result.error ?? 'unknown error'}`);
24
43
  }
25
44
  const suffix = result.msgId ? chalk.dim(` (${result.msgId})`) : '';
26
- const dry = opts.dryRun ? chalk.dim(' [dry-run]') : '';
45
+ const dry = envelope.dryRun ? chalk.dim(' [dry-run]') : '';
27
46
  console.log(chalk.green(`Sent via ${result.channel} → ${result.id}`) + suffix + dry);
28
47
  }
48
+ const SHARED_NOTES = `
49
+ Planes (do not mix them up):
50
+ send / notify - DELIVER a message to a recipient (this command)
51
+ feed post - RECORD progress / milestones (optional broadcast may call send)
52
+ activity - READ the activity stream (not a send path)
53
+ message / inject - CONTROL a running agent (mailbox answer or terminal keystroke)
54
+
55
+ --to owner is an address alias for notify.owner in agents.yaml, not a
56
+ special control path. Agent resume / PTY inject stay on message and
57
+ sessions inject.
58
+ `;
29
59
  export function registerSendCommand(program) {
30
- program
31
- .command('send <text>')
32
- .description('Send a message through a channel provider (mailbox, telegram, imessage, slack, discord, desktop).')
33
- .requiredOption('--channel <name>', 'channel / provider name')
34
- .requiredOption('--to <target>', 'channel-specific recipient id')
60
+ const sendCmd = program
61
+ .command('send [text]')
62
+ .description('Deliver a message through a channel provider (imessage, slack, desktop, mailbox, ). Prefer --text/--to flags.')
63
+ .option('--text <text>', 'message body (preferred over positional text)')
64
+ .option('--to <target>', 'recipient id, or "owner" for notify.owner in agents.yaml')
65
+ .option('--channel <name>', 'channel / provider (required unless --to owner)')
35
66
  .option('--thread <id>', 'channel thread id / timestamp')
36
- .option('--attachment <path...>', 'file attachment path (repeatable)')
67
+ .option('--attach <path...>', 'local file attachment path (repeatable)')
68
+ .option('--attachment <path...>', 'alias of --attach')
69
+ .option('--url <url...>', 'link or remote media URL to include in the body (repeatable)')
37
70
  .option('--from <who>', 'sender label (mailbox)')
38
71
  .option('--json', 'output JSON')
39
- .option('--dry-run', 'resolve + build but do not send')
40
- .action(async (text, opts) => {
41
- await doSend(text, opts.channel, opts.to, opts);
72
+ .option('--dry-run', 'resolve + build but do not send');
73
+ setHelpSections(sendCmd, {
74
+ examples: `
75
+ # Flag-first envelope (preferred)
76
+ agents send --channel imessage --to "+18055550100" --text "PR #1803 is green"
77
+ agents send --to owner --text "need a decision on the release"
78
+ agents send --channel desktop --to local --text "deploy finished" --url https://example.com/pr/1
79
+ agents send --channel mailbox --to <session-id> --text "peer note" --from orchestrator
80
+
81
+ # Attach a local file
82
+ agents send --to owner --text "screenshot" --attach ./out/cover.png
83
+
84
+ # Owner alias without the notify verb
85
+ agents send --to owner --text "wiring test"
86
+
87
+ # Legacy positional text still works
88
+ agents send "hi" --channel desktop --to local
89
+
90
+ # Dry-run (resolve provider, no delivery)
91
+ agents send --to owner --text "probe" --dry-run --json
92
+ `,
93
+ notes: SHARED_NOTES,
42
94
  });
43
- program
44
- .command('notify <text>')
45
- .description('Notify the owner (channel + target default to notify.owner in agents.yaml).')
95
+ sendCmd.action(async (text, opts) => {
96
+ await runSend(text, opts, false);
97
+ });
98
+ const notifyCmd = program
99
+ .command('notify [text]')
100
+ .description('Deliver to the owner (alias of send --to owner). Channel + target default from notify.owner in agents.yaml.')
101
+ .option('--text <text>', 'message body (preferred over positional text)')
46
102
  .option('--channel <name>', 'override owner channel')
47
- .option('--to <target>', 'override owner target')
103
+ .option('--to <target>', 'override owner target (or pass a non-owner dest with --channel)')
48
104
  .option('--thread <id>', 'channel thread id / timestamp')
49
- .option('--attachment <path...>', 'file attachment path (repeatable)')
105
+ .option('--attach <path...>', 'local file attachment path (repeatable)')
106
+ .option('--attachment <path...>', 'alias of --attach')
107
+ .option('--url <url...>', 'link or remote media URL to include in the body (repeatable)')
108
+ .option('--from <who>', 'sender label (mailbox)')
50
109
  .option('--json', 'output JSON')
51
- .option('--dry-run', 'resolve + build but do not send')
52
- .action(async (text, opts) => {
53
- const owner = readMeta().notify?.owner;
54
- const channel = opts.channel ?? owner?.channel;
55
- const to = opts.to ?? owner?.to;
56
- if (!channel || !to) {
57
- die('notify: no --channel/--to given and no notify.owner.{channel,to} in agents.yaml. ' +
58
- 'Set notify.owner or pass --channel/--to.');
59
- }
60
- await doSend(text, channel, to, opts);
110
+ .option('--dry-run', 'resolve + build but do not send');
111
+ setHelpSections(notifyCmd, {
112
+ examples: `
113
+ # Same as: agents send --to owner --text "…"
114
+ agents notify --text "Build finished — PR #1346 is green"
115
+ agents notify "legacy positional still works"
116
+
117
+ # Override channel for this one ping
118
+ agents notify --text "fallback" --channel desktop --to local
119
+
120
+ agents notify --text "probe" --dry-run --json
121
+ `,
122
+ notes: `
123
+ notify ≡ send --to owner. One delivery stack (lib/channels). Set
124
+ notify.owner.{channel,to} in agents.yaml once per machine/fleet.
125
+
126
+ ${SHARED_NOTES}
127
+ `,
128
+ });
129
+ notifyCmd.action(async (text, opts) => {
130
+ await runSend(text, opts, true);
61
131
  });
62
132
  }
@@ -315,6 +315,13 @@ export declare function isBareBrowserListing(options: SessionsOptions, query: st
315
315
  * `runSessionBrowser` picker cannot represent.
316
316
  */
317
317
  export declare function hasNoBrowserDisqualifyingFlags(options: SessionsOptions, query: string | undefined): boolean;
318
+ /** Resolve a session by id/query globally and print its compact preview (no pager).
319
+ * Backs `--preview` — the fast path for the "peek before resume" hot loop. */
320
+ export declare function renderSessionPreview(query: string, scope: {
321
+ agent?: string;
322
+ project?: string;
323
+ local?: boolean;
324
+ }): Promise<void>;
318
325
  /**
319
326
  * The one-line live status banner shown above a session preview: the glyph, the
320
327
  * status word, and — when the session needs a human or has LOST one — a plain
@@ -351,6 +358,16 @@ export declare function teamBadge(session: SessionMeta): {
351
358
  * when `showTicket` — otherwise a listing with no refs would waste a column of
352
359
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
353
360
  export declare function flatSessionRow(session: SessionMeta, live?: ActiveSession, showTicket?: boolean, cols?: PickerColumns, favorite?: boolean): string;
361
+ /**
362
+ * Live-session index for enriching the default listing, or undefined when
363
+ * enrichment is off (`--no-live`) or irrelevant (`--json`, which serializes
364
+ * SessionMeta). Full detection (incl. the headless `ps` scan) is deliberate:
365
+ * bare-CLI and tmux agents are the common case here, and skipping them would
366
+ * leave the glyph almost never showing. The listing is a one-shot user action,
367
+ * not a hot loop, so the `ps`/`lsof` cost is acceptable; `--no-live` is the
368
+ * escape hatch. Never throws — a probe failure just yields a plain listing.
369
+ */
370
+ export declare function maybeLiveIndex(options: SessionsOptions): Promise<Map<string, ActiveSession> | undefined>;
354
371
  /**
355
372
  * Group key for the overview: resolve the cwd through the same canonical
356
373
  * resolver the `agents activity` timeline groups by — a defined project's name