@phnx-labs/agents-cli 1.20.76 → 1.20.77

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 (85) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/exec.js +58 -16
  4. package/dist/commands/routines.js +271 -13
  5. package/dist/commands/secrets.js +59 -17
  6. package/dist/commands/sessions-browser.js +11 -1
  7. package/dist/commands/sessions-picker.d.ts +14 -1
  8. package/dist/commands/sessions-picker.js +168 -15
  9. package/dist/commands/sessions.d.ts +23 -4
  10. package/dist/commands/sessions.js +128 -38
  11. package/dist/commands/ssh.d.ts +13 -0
  12. package/dist/commands/ssh.js +39 -2
  13. package/dist/index.js +2 -1
  14. package/dist/lib/activity.d.ts +5 -4
  15. package/dist/lib/activity.js +450 -36
  16. package/dist/lib/cloud/session-index.js +2 -2
  17. package/dist/lib/daemon.d.ts +10 -1
  18. package/dist/lib/daemon.js +99 -12
  19. package/dist/lib/devices/fleet.d.ts +16 -1
  20. package/dist/lib/devices/fleet.js +10 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/exec.d.ts +16 -0
  23. package/dist/lib/exec.js +27 -4
  24. package/dist/lib/feed.d.ts +7 -1
  25. package/dist/lib/feed.js +96 -6
  26. package/dist/lib/heal.d.ts +7 -4
  27. package/dist/lib/heal.js +10 -22
  28. package/dist/lib/hosts/dispatch.d.ts +9 -0
  29. package/dist/lib/hosts/dispatch.js +24 -4
  30. package/dist/lib/hosts/passthrough.js +7 -2
  31. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  32. package/dist/lib/hosts/remote-cmd.js +31 -8
  33. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  34. package/dist/lib/hosts/remote-session-id.js +84 -0
  35. package/dist/lib/hosts/run-target.d.ts +4 -0
  36. package/dist/lib/hosts/run-target.js +5 -1
  37. package/dist/lib/hosts/session-index.js +3 -3
  38. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  39. package/dist/lib/menubar/install-menubar.js +14 -0
  40. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  41. package/dist/lib/menubar/notify-desktop.js +78 -0
  42. package/dist/lib/overdue.d.ts +4 -5
  43. package/dist/lib/overdue.js +8 -41
  44. package/dist/lib/routine-notify.d.ts +76 -0
  45. package/dist/lib/routine-notify.js +190 -0
  46. package/dist/lib/routines-placement.d.ts +38 -0
  47. package/dist/lib/routines-placement.js +79 -0
  48. package/dist/lib/routines-project.d.ts +97 -0
  49. package/dist/lib/routines-project.js +349 -0
  50. package/dist/lib/routines.d.ts +68 -0
  51. package/dist/lib/routines.js +74 -7
  52. package/dist/lib/runner.d.ts +12 -2
  53. package/dist/lib/runner.js +150 -25
  54. package/dist/lib/sandbox.js +10 -0
  55. package/dist/lib/secrets/account-token.d.ts +20 -0
  56. package/dist/lib/secrets/account-token.js +64 -0
  57. package/dist/lib/secrets/agent.d.ts +9 -4
  58. package/dist/lib/secrets/agent.js +30 -20
  59. package/dist/lib/secrets/bundles.d.ts +20 -6
  60. package/dist/lib/secrets/bundles.js +70 -34
  61. package/dist/lib/secrets/index.d.ts +11 -2
  62. package/dist/lib/secrets/index.js +21 -9
  63. package/dist/lib/secrets/session-store.d.ts +6 -2
  64. package/dist/lib/secrets/session-store.js +65 -15
  65. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  66. package/dist/lib/secrets/unlock-hints.js +36 -0
  67. package/dist/lib/session/active.d.ts +10 -0
  68. package/dist/lib/session/active.js +67 -4
  69. package/dist/lib/session/db.d.ts +6 -0
  70. package/dist/lib/session/db.js +83 -6
  71. package/dist/lib/session/discover.d.ts +13 -1
  72. package/dist/lib/session/discover.js +91 -3
  73. package/dist/lib/session/parse.js +11 -2
  74. package/dist/lib/session/prompt.d.ts +7 -0
  75. package/dist/lib/session/prompt.js +37 -0
  76. package/dist/lib/session/provenance.d.ts +7 -0
  77. package/dist/lib/session/render.js +18 -16
  78. package/dist/lib/session/state.d.ts +4 -0
  79. package/dist/lib/session/state.js +93 -11
  80. package/dist/lib/session/types.d.ts +25 -1
  81. package/dist/lib/session/types.js +8 -0
  82. package/dist/lib/state.d.ts +7 -3
  83. package/dist/lib/state.js +20 -5
  84. package/dist/lib/types.d.ts +10 -0
  85. package/package.json +1 -1
@@ -1020,15 +1020,19 @@ export function resolveBundleEnv(bundle, _opts = {}) {
1020
1020
  * True when the current process is a background / non-interactive context that
1021
1021
  * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
1022
1022
  * a prompt nobody is watching. Two signals, either sufficient:
1023
- * - `AGENTS_RUNTIME` is `headless` or `teams` (set on the child env by
1024
- * `agents run --headless`, scheduled routines, and teammates see
1025
- * exec.ts:resolveInteractive, runner.ts, teams/agents.ts).
1023
+ * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` i.e. ANY agent
1024
+ * launch, interactive included, and inherited by everything spawned beneath
1025
+ * one (set on the child env by `agents run --headless`, scheduled routines,
1026
+ * teammates, and interactive runs — see exec.ts:430, runner.ts,
1027
+ * teams/agents.ts).
1026
1028
  * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
1027
1029
  * stdio is redirected to a log — e.g. a release script run in the
1028
1030
  * background as `( ... ) >log 2>&1 </dev/null`).
1029
1031
  * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
1030
- * even in a non-TTY context. An interactive `eval "$(agents secrets export X)"`
1031
- * keeps its terminal stdin, so it is NOT classified headless and still prompts.
1032
+ * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
1033
+ * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
1034
+ * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
1035
+ * broker-only — the agent, not the human, is the caller there.
1032
1036
  *
1033
1037
  * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
1034
1038
  * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
@@ -1041,7 +1045,11 @@ export function resolveBundleEnv(bundle, _opts = {}) {
1041
1045
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
1042
1046
  * the per-caller pattern already used by the daemon (daemon.ts:readDaemonClaudeOAuthToken).
1043
1047
  */
1044
- export function isHeadlessSecretsContext(env = process.env, platform = process.platform) {
1048
+ export function isHeadlessSecretsContext(env = process.env, platform = process.platform,
1049
+ // Injected so the TTY branch below is testable: it is the branch that decides a
1050
+ // plain human shell still prompts, which is this guard's entire safety argument,
1051
+ // and reading process.* directly made it unreachable from a test.
1052
+ tty = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }) {
1045
1053
  if (platform !== 'darwin')
1046
1054
  return false; // no biometry prompt to suppress off-darwin
1047
1055
  const override = env.AGENTS_SECRETS_NO_PROMPT;
@@ -1049,10 +1057,21 @@ export function isHeadlessSecretsContext(env = process.env, platform = process.p
1049
1057
  return true;
1050
1058
  if (override === '0')
1051
1059
  return false;
1060
+ // Every AGENT-LAUNCH runtime resolves broker-only, interactive included.
1061
+ // `terminal` was missing, which made an agent terminal the one launch path
1062
+ // still allowed to pop Touch ID: exec.ts sets AGENTS_RUNTIME='terminal' for an
1063
+ // interactive run (exec.ts:430), that fell through to the TTY check below, and
1064
+ // a TTY meant "a human is watching, so prompting is fine". It is not fine —
1065
+ // opening a terminal is not a request to authenticate, and a launch that needs
1066
+ // a locked bundle should say so and point at `agents secrets unlock`, not grab
1067
+ // the fingerprint sensor. AGENTS_RUNTIME is INHERITED by everything spawned under
1068
+ // an agent, so `agents secrets export` run beneath one resolves broker-only too —
1069
+ // correctly: there the agent, not the human, is the caller. A plain shell carries
1070
+ // no AGENTS_RUNTIME, so a person running it themselves still gets the sheet.
1052
1071
  const runtime = env.AGENTS_RUNTIME;
1053
- if (runtime === 'headless' || runtime === 'teams')
1072
+ if (runtime === 'headless' || runtime === 'teams' || runtime === 'terminal')
1054
1073
  return true;
1055
- return !process.stdin.isTTY && !process.stdout.isTTY;
1074
+ return !tty.stdin && !tty.stdout;
1056
1075
  }
1057
1076
  /**
1058
1077
  * Read a bundle's metadata AND resolve its env in a single Touch ID prompt.
@@ -1078,7 +1097,8 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1078
1097
  // file-backed bundle has none to dedup. The never-unlocked path is a single
1079
1098
  // stat (agentSocketExists) so it costs nothing when the agent isn't running.
1080
1099
  if (backend === 'keychain' && !opts.noAgent && process.env.AGENTS_SECRETS_NO_AGENT !== '1') {
1081
- const hit = agentGetSync(name);
1100
+ const harness = opts.agent || process.env.AGENTS_AGENT_NAME || 'cli';
1101
+ const hit = agentGetSync(name, harness);
1082
1102
  if (hit) {
1083
1103
  // The agent stores the FULL bundle env. Apply the same subset filter and
1084
1104
  // expiry gate as the slow path — without this, `--secrets-keys X` would
@@ -1102,13 +1122,13 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1102
1122
  // Touch ID. Serve from it and re-warm the broker, so a warm bundle stays warm
1103
1123
  // across restart — this fixes BOTH the interactive re-prompt and the headless
1104
1124
  // throw below (which now fires only when there is genuinely no session).
1105
- const session = loadSession(name);
1125
+ const session = loadSession(name, Date.now(), harness);
1106
1126
  if (session) {
1107
1127
  const filtered = filterAgentHitBySubsetAndExpiry({ bundle: session.bundle, env: session.env }, opts);
1108
1128
  stampLastUsed(filtered.bundle);
1109
1129
  // Re-warm the broker with the remaining TTL so later reads hit RAM and
1110
1130
  // `agents secrets status` is honest. Best-effort; no-ops off darwin.
1111
- agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()));
1131
+ agentAutoLoadSync(name, session.bundle, session.env, Math.max(1, session.expiresAt - Date.now()), harness);
1112
1132
  emit('secrets.get', {
1113
1133
  module: 'secrets',
1114
1134
  bundle: name,
@@ -1120,32 +1140,30 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1120
1140
  return filtered;
1121
1141
  }
1122
1142
  }
1123
- // The headless guard exists only to keep a Touch ID sheet off the interactive
1124
- // user's screen so it must block ONLY reads that can actually raise one.
1125
- // A file-backed bundle resolves via passphrase with no prompt (handled below,
1126
- // never held by the broker). A `never`/no-ACL keychain bundle is written
1127
- // WITHOUT the biometry access control at every tier (see writeBundle), so its
1128
- // reads are fully silent (no Touch ID, no broker)exactly like a file
1129
- // bundle, and exactly the "automation-only" tier a headless daemon reads
1130
- // (daemon.ts:readDaemonClaudeOAuthToken). Learning the policy here is itself
1131
- // prompt-less: metadata items are written no-ACL at every tier, so readBundle
1132
- // reads them silently. An unreadable metadata read is treated as prompt-risky
1133
- // (throw) the safe default.
1134
- if (opts.agentOnly && backend === 'keychain') {
1143
+ // Never/no-ACL bundles remain prompt-free regardless. No agent launch harness,
1144
+ // teammate, routine, or the always-on daemon may raise the sheet itself.
1145
+ // Explicit opt-in ONLY a deliberate NARROWING of the agent-triggered approval
1146
+ // added in RUSH-2032 (b99796f8 removed this throw so an agent could raise the
1147
+ // sheet itself; 4eeada68 generalized the daemon rule into `!interactiveUnlock`).
1148
+ // That default true whenever an agent name was present was the spec, not a
1149
+ // bug. It is unwanted: each keychain read runs in its own helper process, so the
1150
+ // biometric assertion never reuses and one agent launch meant one sheet per
1151
+ // bundle. `agentOnly` decides alone now; a human in a plain shell carries no
1152
+ // AGENTS_RUNTIME, so isHeadlessSecretsContext() is false, agentOnly is false, the
1153
+ // guard never fires, and they still get their prompt. No caller passes this flag;
1154
+ // it remains the seam for a future unlock path that wants the sheet on purpose.
1155
+ const interactiveUnlock = opts.interactiveUnlock ?? false;
1156
+ if (opts.agentOnly && backend === 'keychain' && !interactiveUnlock) {
1135
1157
  let noAclBundle = false;
1136
1158
  try {
1137
1159
  noAclBundle = bundlePolicy(readBundle(name)) === 'never';
1138
1160
  }
1139
- catch {
1140
- noAclBundle = false;
1141
- }
1161
+ catch { /* fail closed */ }
1142
1162
  if (!noAclBundle) {
1143
- throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent, and this is a ` +
1144
- `headless/background process that must not raise a Touch ID prompt on the ` +
1145
- `interactive user's screen. Run 'agents secrets unlock ${name}' in a terminal ` +
1146
- `first, or set AGENTS_SECRETS_NO_PROMPT=0 to force an interactive prompt.`);
1163
+ throw new Error(`Secrets bundle '${name}' is not unlocked in the secrets agent. ` +
1164
+ `Run 'agents secrets unlock ${name}' in a terminal first an agent launch ` +
1165
+ `never raises a Touch ID sheet on its own.`);
1147
1166
  }
1148
- // never/no-ACL ⇒ fall through to the prompt-less keychain read below.
1149
1167
  }
1150
1168
  if (backend === 'file')
1151
1169
  assertFileBackendUsable(name);
@@ -1164,12 +1182,20 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1164
1182
  const reason = opts.caller
1165
1183
  ? `read ${name} secrets (for ${opts.caller})`
1166
1184
  : `read ${name} secrets`;
1167
- void reason;
1168
1185
  // secretItems are storage names as enumerated (opaque hashed names on macOS
1169
1186
  // with #316 hashing active, cleartext elsewhere); metaItem is cleartext and
1170
1187
  // hashed inside getBatch. Deduped because the hashed enumeration spans the
1171
1188
  // bundle's whole namespace.
1172
- const fetched = store.getBatch([...new Set([metaItem, ...secretItems])]);
1189
+ const fetched = backend === 'keychain'
1190
+ ? getKeychainTokens([...new Set([metaItem, ...secretItems])], {
1191
+ agent: opts.agent || process.env.AGENTS_AGENT_NAME || 'Agents CLI',
1192
+ bundle: name,
1193
+ reason: opts.caller ? `to ${opts.caller}` : reason,
1194
+ duration: opts.duration || humanUnlockDuration(secretsHoldMs()),
1195
+ defaultPolicy: secretsDefaultPolicy(),
1196
+ forceDuration: Boolean(opts.duration),
1197
+ })
1198
+ : store.getBatch([...new Set([metaItem, ...secretItems])]);
1173
1199
  const json = fetched.get(metaItem);
1174
1200
  if (json === undefined) {
1175
1201
  // For a file-backed bundle the metadata item is on disk (that's how
@@ -1297,7 +1323,7 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1297
1323
  bundlePolicy(bundle) === 'daily' &&
1298
1324
  secretsAgentAutoEnabled() &&
1299
1325
  canCacheResolvedEnv(bundle, selectedKeys, opts.keyMode)) {
1300
- agentAutoLoadSync(name, bundle, env, secretsHoldMs());
1326
+ agentAutoLoadSync(name, bundle, env, secretsHoldMs(), opts.agent || process.env.AGENTS_AGENT_NAME || 'cli');
1301
1327
  }
1302
1328
  return { bundle, env };
1303
1329
  }
@@ -1306,6 +1332,16 @@ export function readAndResolveBundleEnv(name, opts = {}) {
1306
1332
  throw err;
1307
1333
  }
1308
1334
  }
1335
+ export function humanUnlockDuration(ms) {
1336
+ const days = Math.round(ms / (24 * 60 * 60 * 1000));
1337
+ if (days >= 1)
1338
+ return `${days} day${days === 1 ? '' : 's'}`;
1339
+ const hours = Math.round(ms / (60 * 60 * 1000));
1340
+ if (hours >= 1)
1341
+ return `${hours} hour${hours === 1 ? '' : 's'}`;
1342
+ const minutes = Math.max(1, Math.round(ms / (60 * 1000)));
1343
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
1344
+ }
1309
1345
  // Build a keychain ref expression from a bundle+key pair, for storage in the bundle metadata.
1310
1346
  export function keychainRef(key) {
1311
1347
  return `keychain:${key}`;
@@ -210,7 +210,16 @@ export declare function hasKeychainToken(item: string): boolean;
210
210
  * call in the same process). For bundles, prefer getKeychainTokens() so a
211
211
  * single biometric prompt covers every key in the batch.
212
212
  */
213
- export declare function getKeychainToken(item: string): string;
213
+ export interface KeychainReadContext {
214
+ agent?: string;
215
+ bundle?: string;
216
+ reason?: string;
217
+ duration?: string;
218
+ defaultPolicy?: 'daily' | 'always' | 'never';
219
+ forceDuration?: boolean;
220
+ }
221
+ export declare function keychainOperationPrompt(context?: KeychainReadContext): string;
222
+ export declare function getKeychainToken(item: string, context?: KeychainReadContext): string;
214
223
  /**
215
224
  * Batch-read multiple keychain items behind a single Touch ID prompt. The
216
225
  * macOS helper holds one LAContext for its whole process: the first protected
@@ -221,7 +230,7 @@ export declare function getKeychainToken(item: string): string;
221
230
  * On Linux or when a test backend is installed, falls back to individual
222
231
  * lookups — no biometric prompt path on those platforms.
223
232
  */
224
- export declare function getKeychainTokens(items: string[]): Map<string, string>;
233
+ export declare function getKeychainTokens(items: string[], context?: KeychainReadContext): Map<string, string>;
225
234
  /** Store or update a secret value in the keychain/keyring. Device-local;
226
235
  * biometry-gated on macOS. `opts.noAcl` (the `never` prompt-policy) writes our
227
236
  * item WITHOUT the biometry access control so later reads are fully silent — it
@@ -707,14 +707,14 @@ export function hasKeychainToken(item) {
707
707
  stdio: ['ignore', 'pipe', 'pipe'],
708
708
  }).status === 0;
709
709
  }
710
- /**
711
- * Retrieve a secret value from the keychain/keyring. Throws if not found.
712
- *
713
- * On macOS this triggers Touch ID (or reuses an assertion held by an earlier
714
- * call in the same process). For bundles, prefer getKeychainTokens() so a
715
- * single biometric prompt covers every key in the batch.
716
- */
717
- export function getKeychainToken(item) {
710
+ export function keychainOperationPrompt(context = {}) {
711
+ const agent = context.agent || 'Agents CLI';
712
+ const bundle = context.bundle ? ` the '${context.bundle}' bundle` : ' secrets';
713
+ const duration = context.duration ? ` for ${context.duration}` : '';
714
+ const reason = context.reason ? ` ${context.reason}` : '';
715
+ return `${agent} is requesting to unlock${bundle}${duration}${reason}.`;
716
+ }
717
+ export function getKeychainToken(item, context = {}) {
718
718
  // Errors keep the requested (human-readable) name; the storage name may be
719
719
  // an opaque hash.
720
720
  const requested = item;
@@ -739,6 +739,11 @@ export function getKeychainToken(item) {
739
739
  }
740
740
  const bin = getKeychainHelperPath();
741
741
  const result = spawnSync(bin, ['get', item, os.userInfo().username], {
742
+ env: {
743
+ ...process.env,
744
+ AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
745
+ AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
746
+ },
742
747
  stdio: ['ignore', 'pipe', 'pipe'],
743
748
  });
744
749
  if (result.status === 1)
@@ -764,7 +769,7 @@ export function getKeychainToken(item) {
764
769
  * On Linux or when a test backend is installed, falls back to individual
765
770
  * lookups — no biometric prompt path on those platforms.
766
771
  */
767
- export function getKeychainTokens(items) {
772
+ export function getKeychainTokens(items, context = {}) {
768
773
  const result = new Map();
769
774
  if (items.length === 0)
770
775
  return result;
@@ -811,6 +816,13 @@ export function getKeychainTokens(items) {
811
816
  }
812
817
  const bin = getKeychainHelperPath();
813
818
  const child = spawnSync(bin, ['get-batch', os.userInfo().username, ...storageItems], {
819
+ env: {
820
+ ...process.env,
821
+ AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
822
+ AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
823
+ AGENTS_KEYCHAIN_DEFAULT_POLICY: context.defaultPolicy || 'daily',
824
+ AGENTS_KEYCHAIN_FORCE_DURATION: context.forceDuration ? '1' : '0',
825
+ },
814
826
  stdio: ['ignore', 'pipe', 'pipe'],
815
827
  });
816
828
  if (child.status === 4) {
@@ -38,12 +38,14 @@ export interface SessionEntry {
38
38
  expiresAt: number;
39
39
  /** true only for `--durable` unlocks — survives SLEEP. */
40
40
  sleepPersist: boolean;
41
+ harness?: string;
41
42
  }
42
43
  /** Metadata for one held bundle, kept in the index so we can rehydrate / prune
43
44
  * without reading every blob. */
44
45
  export interface SessionIndexMeta {
45
46
  expiresAt: number;
46
47
  sleepPersist: boolean;
48
+ harness?: string;
47
49
  }
48
50
  export interface SessionIndex {
49
51
  bundles: Record<string, SessionIndexMeta>;
@@ -73,9 +75,11 @@ export declare function writeIndex(index: SessionIndex): void;
73
75
  /** Persist one unlocked bundle: write its blob no-ACL and record it in the index. */
74
76
  export declare function saveSession(name: string, entry: SessionEntry): void;
75
77
  /** Read one session blob by known name. Null when absent/expired/malformed. */
76
- export declare function loadSession(name: string, now?: number): SessionEntry | null;
78
+ export declare function loadSession(name: string, now?: number, harness?: string): SessionEntry | null;
79
+ /** Remove every persisted harness grant for one bundle. */
80
+ export declare function deleteBundleSessions(name: string): void;
77
81
  /** Delete one bundle's session blob and prune it from the index. */
78
- export declare function deleteSession(name: string): void;
82
+ export declare function deleteSession(name: string, harness?: string): void;
79
83
  /** Delete every session blob + the index (for `secrets lock --all`). */
80
84
  export declare function deleteAllSessions(): void;
81
85
  /** Rehydrate every unexpired session into `[name, entry]` pairs for the broker to
@@ -82,8 +82,8 @@ export function removeEntry(index, name) {
82
82
  function shouldPersist() {
83
83
  return process.platform === 'darwin' || isKeychainBackendOverridden();
84
84
  }
85
- function sessionBlobItem(name) {
86
- return `${SESSION_ITEM_PREFIX}${name}`;
85
+ function sessionBlobItem(name, harness) {
86
+ return `${SESSION_ITEM_PREFIX}${harness}.${name}`;
87
87
  }
88
88
  /** Read the session index by its fixed name. `{bundles:{}}` when absent/unreadable. */
89
89
  export function readIndex() {
@@ -116,24 +116,26 @@ export function saveSession(name, entry) {
116
116
  if (!shouldPersist())
117
117
  return;
118
118
  try {
119
- setKeychainToken(sessionBlobItem(name), JSON.stringify(entry), { noAcl: true });
120
- writeIndex(upsertEntry(readIndex(), name, { expiresAt: entry.expiresAt, sleepPersist: entry.sleepPersist }));
119
+ const harness = entry.harness || 'cli';
120
+ const key = `${harness}:${name}`;
121
+ setKeychainToken(sessionBlobItem(name, harness), JSON.stringify({ ...entry, harness }), { noAcl: true });
122
+ writeIndex(upsertEntry(readIndex(), key, { expiresAt: entry.expiresAt, sleepPersist: entry.sleepPersist, harness }));
121
123
  }
122
124
  catch {
123
125
  /* best-effort — persistence is an optimization */
124
126
  }
125
127
  }
126
128
  /** Read one session blob by known name. Null when absent/expired/malformed. */
127
- export function loadSession(name, now = Date.now()) {
129
+ export function loadSession(name, now = Date.now(), harness = 'cli') {
128
130
  if (!shouldPersist())
129
131
  return null;
130
132
  try {
131
- const raw = getKeychainToken(sessionBlobItem(name));
133
+ const raw = getKeychainToken(sessionBlobItem(name, harness));
132
134
  const entry = JSON.parse(raw);
133
135
  if (!entry || typeof entry !== 'object' || !entry.bundle || !entry.env)
134
136
  return null;
135
137
  if (now >= entry.expiresAt) {
136
- deleteSession(name); // drop expired on read, mirroring the broker's get handler
138
+ deleteSession(name, harness); // drop expired on read
137
139
  return null;
138
140
  }
139
141
  return entry;
@@ -142,13 +144,32 @@ export function loadSession(name, now = Date.now()) {
142
144
  return null;
143
145
  }
144
146
  }
147
+ /** Remove every persisted harness grant for one bundle. */
148
+ export function deleteBundleSessions(name) {
149
+ if (!shouldPersist())
150
+ return;
151
+ const index = readIndex();
152
+ let next = index;
153
+ for (const [key, meta] of Object.entries(index.bundles)) {
154
+ const scopedName = key.includes(':') ? key.split(':').slice(1).join(':') : key;
155
+ if (scopedName !== name)
156
+ continue;
157
+ const harness = meta.harness || 'cli';
158
+ try {
159
+ deleteKeychainToken(key.includes(':') ? sessionBlobItem(name, harness) : `${SESSION_ITEM_PREFIX}${name}`);
160
+ }
161
+ catch { /* keep going */ }
162
+ next = removeEntry(next, key);
163
+ }
164
+ writeIndex(next);
165
+ }
145
166
  /** Delete one bundle's session blob and prune it from the index. */
146
- export function deleteSession(name) {
167
+ export function deleteSession(name, harness = 'cli') {
147
168
  if (!shouldPersist())
148
169
  return;
149
170
  try {
150
- deleteKeychainToken(sessionBlobItem(name));
151
- writeIndex(removeEntry(readIndex(), name));
171
+ deleteKeychainToken(sessionBlobItem(name, harness));
172
+ writeIndex(removeEntry(readIndex(), `${harness}:${name}`));
152
173
  }
153
174
  catch {
154
175
  /* best-effort */
@@ -160,8 +181,12 @@ export function deleteAllSessions() {
160
181
  return;
161
182
  try {
162
183
  for (const name of Object.keys(readIndex().bundles)) {
184
+ const meta = readIndex().bundles[name];
185
+ const bundleName = name.includes(':') ? name.split(':').slice(1).join(':') : name;
163
186
  try {
164
- deleteKeychainToken(sessionBlobItem(name));
187
+ deleteKeychainToken(name.includes(':')
188
+ ? sessionBlobItem(bundleName, meta.harness || 'cli')
189
+ : `${SESSION_ITEM_PREFIX}${bundleName}`);
165
190
  }
166
191
  catch { /* keep going */ }
167
192
  }
@@ -180,19 +205,43 @@ export function rehydrateSessions(now = Date.now()) {
180
205
  const out = [];
181
206
  try {
182
207
  const index = readIndex();
208
+ // One-time source migration from the pre-harness layout. Move each legacy
209
+ // bundle-name index/blob to the explicit cli scope, then delete the old blob.
210
+ let migratedIndex = index;
211
+ for (const [key, meta] of Object.entries(index.bundles)) {
212
+ if (key.includes(':'))
213
+ continue;
214
+ try {
215
+ const raw = getKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
216
+ const legacy = JSON.parse(raw);
217
+ setKeychainToken(sessionBlobItem(key, 'cli'), JSON.stringify({ ...legacy, harness: 'cli' }), { noAcl: true });
218
+ deleteKeychainToken(`${SESSION_ITEM_PREFIX}${key}`);
219
+ migratedIndex = upsertEntry(removeEntry(migratedIndex, key), `cli:${key}`, {
220
+ expiresAt: meta.expiresAt,
221
+ sleepPersist: meta.sleepPersist,
222
+ harness: 'cli',
223
+ });
224
+ }
225
+ catch { /* malformed/absent legacy entry is pruned below */ }
226
+ }
227
+ writeIndex(migratedIndex);
228
+ index.bundles = migratedIndex.bundles;
183
229
  const { survivors, expiredNames } = pruneExpired(index, now);
184
230
  for (const name of expiredNames) {
231
+ const meta = index.bundles[name];
185
232
  try {
186
- deleteKeychainToken(sessionBlobItem(name));
233
+ deleteKeychainToken(sessionBlobItem(name.split(':').slice(1).join(':'), meta.harness || 'cli'));
187
234
  }
188
235
  catch { /* keep going */ }
189
236
  }
190
237
  if (expiredNames.length)
191
238
  writeIndex(survivors);
192
239
  for (const name of selectRehydratable(survivors, now)) {
193
- const entry = loadSession(name, now);
240
+ const meta = survivors.bundles[name];
241
+ const bundleName = name.split(':').slice(1).join(':');
242
+ const entry = loadSession(bundleName, now, meta.harness || 'cli');
194
243
  if (entry)
195
- out.push({ name, entry });
244
+ out.push({ name: bundleName, entry });
196
245
  }
197
246
  }
198
247
  catch {
@@ -208,8 +257,9 @@ export function pruneSessionsOnSleep() {
208
257
  try {
209
258
  const { survivors, deletedNames } = pruneOnSleep(readIndex());
210
259
  for (const name of deletedNames) {
260
+ const meta = readIndex().bundles[name];
211
261
  try {
212
- deleteKeychainToken(sessionBlobItem(name));
262
+ deleteKeychainToken(sessionBlobItem(name.split(':').slice(1).join(':'), meta.harness || 'cli'));
213
263
  }
214
264
  catch { /* keep going */ }
215
265
  }
@@ -0,0 +1,27 @@
1
+ /** The subset of a `secrets.get` event this heuristic reads. */
2
+ export interface SecretGetRecord {
3
+ /** Bundle name the read targeted. */
4
+ bundle?: string;
5
+ /** Where the value came from: `agent` (broker hit) / `session` (durable
6
+ * unlock) are SILENT; anything else hit the keychain and thus prompted. */
7
+ source?: string;
8
+ }
9
+ export interface PromptedBundle {
10
+ name: string;
11
+ /** Number of prompting (keychain) reads in the window. */
12
+ count: number;
13
+ }
14
+ /**
15
+ * From recent `secrets.get` records, find bundles read often enough via the
16
+ * keychain (i.e. NOT served silently by the broker or a durable session) to be
17
+ * worth unlocking once. A bundle currently `held` is already silent, so it is
18
+ * excluded; the caller further drops `never`/no-ACL bundles (which never prompt,
19
+ * so unlocking them is a no-op).
20
+ *
21
+ * @param records recent `secrets.get` events (any order)
22
+ * @param held bundle names the broker currently holds (silent reads)
23
+ * @param opts.minReads minimum prompting reads to surface a bundle (default 3)
24
+ */
25
+ export declare function frequentlyPromptedBundles(records: SecretGetRecord[], held: Set<string>, opts?: {
26
+ minReads?: number;
27
+ }): PromptedBundle[];
@@ -0,0 +1,36 @@
1
+ // Usage-based unlock hints: surface bundles you keep getting a Touch ID prompt
2
+ // for, so `agents secrets status` can suggest unlocking them once instead of
3
+ // prompting on every read. Built on the existing `secrets.get` audit events
4
+ // (events.jsonl) — no new tracking. Pure + exported so the threshold/filter
5
+ // logic is unit-testable without the events sink or the keychain.
6
+ /**
7
+ * From recent `secrets.get` records, find bundles read often enough via the
8
+ * keychain (i.e. NOT served silently by the broker or a durable session) to be
9
+ * worth unlocking once. A bundle currently `held` is already silent, so it is
10
+ * excluded; the caller further drops `never`/no-ACL bundles (which never prompt,
11
+ * so unlocking them is a no-op).
12
+ *
13
+ * @param records recent `secrets.get` events (any order)
14
+ * @param held bundle names the broker currently holds (silent reads)
15
+ * @param opts.minReads minimum prompting reads to surface a bundle (default 3)
16
+ */
17
+ export function frequentlyPromptedBundles(records, held, opts = {}) {
18
+ const minReads = opts.minReads ?? 3;
19
+ const counts = new Map();
20
+ for (const r of records) {
21
+ if (!r.bundle)
22
+ continue;
23
+ // A broker hit or a durable-session read raised no Touch ID sheet — only a
24
+ // read that fell through to the keychain did. Count those; they are the ones
25
+ // an unlock would silence.
26
+ if (r.source === 'agent' || r.source === 'session')
27
+ continue;
28
+ if (held.has(r.bundle))
29
+ continue;
30
+ counts.set(r.bundle, (counts.get(r.bundle) ?? 0) + 1);
31
+ }
32
+ return [...counts.entries()]
33
+ .filter(([, n]) => n >= minReads)
34
+ .map(([name, count]) => ({ name, count }))
35
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
36
+ }
@@ -4,6 +4,7 @@ import { type HookSessionIndex } from './hook-sessions.js';
4
4
  import { type SessionActivity, type AwaitingReason, type StructuredQuestion, type TodoProgress, type DetectedPr, type DetectedWorktree, type DetectedTicket } from './state.js';
5
5
  import type { SessionAttachment } from './types.js';
6
6
  import { type SessionProvenance } from './provenance.js';
7
+ import { type DeviceRegistry } from '../devices/registry.js';
7
8
  import { type Presence } from './detached.js';
8
9
  /**
9
10
  * Per-PID `lsof` probes run bounded and staggered rather than as one parallel
@@ -353,6 +354,15 @@ export declare function listTmuxAgentSessions(): Promise<ActiveSession[]>;
353
354
  * terminal/headless row for the same session id.
354
355
  */
355
356
  export declare function getActiveSessions(opts?: ActiveQueryOptions): Promise<ActiveSession[]>;
357
+ /**
358
+ * Match an SSH client IP to a registered device (pure — testable with a plain
359
+ * registry object). Returns the device name + ssh login user when the IP is a
360
+ * known device address.
361
+ */
362
+ export declare function matchOriginDevice(clientIp: string, reg: DeviceRegistry): {
363
+ device: string;
364
+ user?: string;
365
+ } | undefined;
356
366
  /**
357
367
  * Collapse rows that resolve to the *same* session — a session with many
358
368
  * subagent/fork PIDs (all matched to one transcript file) would otherwise print
@@ -34,6 +34,7 @@ import { readSessionTailWithRaw } from './tail.js';
34
34
  import { computeTokPerSec } from './throughput.js';
35
35
  import { inferSessionState } from './state.js';
36
36
  import { detectProvenance } from './provenance.js';
37
+ import { loadDevices } from '../devices/registry.js';
37
38
  import { presenceFromStore } from './detached.js';
38
39
  import { mapBounded } from '../concurrency.js';
39
40
  const execFileAsync = promisify(execFile);
@@ -1081,8 +1082,11 @@ export async function listTmuxAgentSessions() {
1081
1082
  const pidAlive = pid ? isPidAlive(pid, liveEntry?.startedAtMs) : true;
1082
1083
  const { state, tokPerSec } = computeLiveSignals(id.agent, sessionFile, cwd, pidAlive);
1083
1084
  const { birthtimeMs, mtimeMs } = sessionFileTimes(sessionFile);
1084
- // Provenance is known exactly here (the pane IS a tmux pane) set it so
1085
- // enrichProvenance skips it and the locator/reply rails resolve off the pane.
1085
+ // The mux/reply rails are known exactly here (the pane IS a tmux pane), so we
1086
+ // stamp them off the pane. `transport:'local'` is only a placeholder: the pane
1087
+ // can't reveal how the shell above it was reached. enrichProvenance later reads
1088
+ // the pane process's env and upgrades this to 'ssh' (with the real origin) when
1089
+ // SSH_CONNECTION is present, while preserving this mux/reply.
1086
1090
  const provenance = {
1087
1091
  host: os.hostname(),
1088
1092
  transport: 'local',
@@ -1135,6 +1139,7 @@ export async function getActiveSessions(opts = {}) {
1135
1139
  const unattributed = opts.skipHeadless ? [] : await listUnattributedActive(knownPids);
1136
1140
  const merged = dedupeBySession([...tmuxAgents, ...teams, ...terminals, ...cloud, ...unattributed]);
1137
1141
  await enrichProvenance(merged);
1142
+ await resolveOrigins(merged);
1138
1143
  foldPresence(merged);
1139
1144
  return merged;
1140
1145
  }
@@ -1161,14 +1166,72 @@ function foldPresence(rows) {
1161
1166
  * each session once, not once per fork pid. Probes run in parallel — each is a
1162
1167
  * single /proc read (Linux) or `ps` call (macOS); failures leave `provenance`
1163
1168
  * undefined rather than blocking the listing.
1169
+ *
1170
+ * A row that already carries provenance (the tmux path, which knows its exact
1171
+ * mux/reply from the pane) is not skipped — it is probe-and-MERGED. The tmux
1172
+ * path can only stamp a `transport:'local'` placeholder because the pane alone
1173
+ * doesn't reveal how the shell above it was reached; the process env does. So we
1174
+ * still read the env and fill in the real SSH origin/term, while preserving the
1175
+ * authoritative mux/reply the pane already gave us. Skipping this (the old
1176
+ * behavior) is exactly why ssh-launched tmux sessions rendered as local.
1164
1177
  */
1165
1178
  async function enrichProvenance(sessions) {
1166
1179
  await Promise.all(sessions.map(async (s) => {
1167
- if (s.provenance || !s.pid)
1180
+ if (!s.pid)
1181
+ return;
1182
+ const probed = await detectProvenance(s.pid);
1183
+ if (!probed)
1184
+ return;
1185
+ if (!s.provenance) {
1186
+ s.provenance = probed;
1168
1187
  return;
1169
- s.provenance = await detectProvenance(s.pid);
1188
+ }
1189
+ // Row already carries exact mux/reply (the tmux path). Fill only what a
1190
+ // pre-set provenance can't know from the pane alone: the real launch origin.
1191
+ if (probed.transport === 'ssh' && !s.provenance.ssh) {
1192
+ s.provenance.transport = 'ssh';
1193
+ s.provenance.ssh = probed.ssh;
1194
+ }
1195
+ if (probed.term && !s.provenance.term)
1196
+ s.provenance.term = probed.term;
1170
1197
  }));
1171
1198
  }
1199
+ /**
1200
+ * Match an SSH client IP to a registered device (pure — testable with a plain
1201
+ * registry object). Returns the device name + ssh login user when the IP is a
1202
+ * known device address.
1203
+ */
1204
+ export function matchOriginDevice(clientIp, reg) {
1205
+ for (const d of Object.values(reg)) {
1206
+ if (d.address?.ip && d.address.ip === clientIp) {
1207
+ return { device: d.name, ...(d.user ? { user: d.user } : {}) };
1208
+ }
1209
+ }
1210
+ return undefined;
1211
+ }
1212
+ /**
1213
+ * Resolve the initiating device for every ssh-transport session by matching its
1214
+ * `ssh.clientIp` against the device registry. Read-only and best-effort: a
1215
+ * registry that can't be loaded, or an IP that matches no device, leaves
1216
+ * `origin` undefined (the raw client IP is still on `ssh`). Mutates in place.
1217
+ */
1218
+ async function resolveOrigins(sessions) {
1219
+ const needing = sessions.filter((s) => s.provenance?.ssh && !s.provenance.origin);
1220
+ if (needing.length === 0)
1221
+ return;
1222
+ let reg;
1223
+ try {
1224
+ reg = await loadDevices();
1225
+ }
1226
+ catch {
1227
+ return;
1228
+ }
1229
+ for (const s of needing) {
1230
+ const match = matchOriginDevice(s.provenance.ssh.clientIp, reg);
1231
+ if (match)
1232
+ s.provenance.origin = match;
1233
+ }
1234
+ }
1172
1235
  /**
1173
1236
  * Identity for a row the scan could not tie to a session: a daemon's worker
1174
1237
  * processes (an OpenClaw gateway spawning `codex`, a supervisor pool) have no