@phnx-labs/agents-cli 1.20.76 → 1.20.78

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 (119) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. package/package.json +1 -1
@@ -18,16 +18,18 @@ import { ensureDaemonStarted, isDaemonRunning } from '../lib/daemon.js';
18
18
  import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveHostSshTarget, } 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
- import { 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';
21
+ import { bundleBackend, bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, isHeadlessSecretsContext, readBundle, readBundleIfDecryptable, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, writeBundleWithItems, } from '../lib/secrets/bundles.js';
22
22
  import { encryptForFallback, decryptForFallback } from '../lib/secrets/filestore.js';
23
23
  import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
24
24
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
25
+ import { GLOBAL_HARNESS } from '../lib/secrets/scope.js';
25
26
  import { secretsHoldMs, secretsAgentDurable, agentLoad, agentLock, agentPing, agentStatus, ensureAgentRunning, runAgentLoadFromStdin, runSecretsAgent, uninstallSecretsAgentService, } from '../lib/secrets/agent.js';
26
- import { saveSession, deleteSession, deleteAllSessions } from '../lib/secrets/session-store.js';
27
+ import { saveSession, deleteBundleSessions, deleteAllSessions } from '../lib/secrets/session-store.js';
27
28
  import { getCliVersionFresh } from '../lib/version.js';
28
29
  import { readMeta } from '../lib/state.js';
29
30
  import { parseDuration } from '../lib/hooks/cache.js';
30
- import { emit } from '../lib/events.js';
31
+ import { emit, query } from '../lib/events.js';
32
+ import { frequentlyPromptedBundles } from '../lib/secrets/unlock-hints.js';
31
33
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
32
34
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
33
35
  import { discoverSyncedBundles, importSyncedBundle, } from '../lib/secrets/icloud-import.js';
@@ -1096,8 +1098,9 @@ export function registerSecretsCommands(program) {
1096
1098
  process.exit(1);
1097
1099
  }
1098
1100
  // `secrets get` is the scriptable automation primitive ($(agents secrets
1099
- // get bundle KEY)); when embedded in a headless routine/CI script it must
1100
- // not pop an unwatched Touch ID prompt. Interactive use still prompts.
1101
+ // get bundle KEY)); when embedded in a headless routine/CI script or run
1102
+ // beneath any agent, which inherits AGENTS_RUNTIME it must not pop an
1103
+ // unwatched Touch ID prompt. Typed in a plain shell it still prompts.
1101
1104
  const { env } = readAndResolveBundleEnv(item, { caller: 'secrets get', keys: [key], keyMode: 'storage', agentOnly: isHeadlessSecretsContext() });
1102
1105
  if (!(key in env)) {
1103
1106
  console.error(chalk.red(`Key '${key}' not in bundle '${item}'.`));
@@ -1816,10 +1819,10 @@ Examples:
1816
1819
  process.exit(1);
1817
1820
  }
1818
1821
  // `agents secrets export --plaintext` is what release/CI scripts eval.
1819
- // When it runs detached (both stdio non-TTY) or under a headless agent,
1820
- // resolve broker-only so it can never pop a Touch ID sheet on the
1821
- // interactive user's screen. An interactive `eval "$(...)"` keeps its
1822
- // terminal stdin, so it is not headless and still prompts.
1822
+ // When it runs detached (both stdio non-TTY) or beneath ANY agent — which
1823
+ // inherits AGENTS_RUNTIME — resolve broker-only so it can never pop a Touch
1824
+ // ID sheet on the interactive user's screen. An `eval "$(...)"` typed in a
1825
+ // plain shell carries no AGENTS_RUNTIME, so it is not headless and still prompts.
1823
1826
  const { env } = readAndResolveBundleEnv(resolvedBundleName, {
1824
1827
  caller: `export to shell`,
1825
1828
  keyMode: 'process',
@@ -2055,6 +2058,7 @@ Examples:
2055
2058
  .description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS). With --host, unlock FILE-backed bundle(s) on a remote (the passphrase prompt surfaces over the SSH TTY); keychain/biometry bundles are GUI-only and can\'t be remote-unlocked.')
2056
2059
  .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
2057
2060
  .option('--durable', 'Keep the unlock across sleep + reboot too (default: survives upgrade/restart but re-locks on sleep). Set secrets.agent.durable in agents.yaml to make this the default.')
2061
+ .option('--for <agent>', 'Narrow the unlock to ONE harness type (for example claude, codex, or kimi). Default: the grant is global — every harness and a plain shell can read it, so one Touch ID covers them all.')
2058
2062
  .option('--all', 'Unlock every configured bundle')
2059
2063
  .option('--host <target>', 'Unlock the bundle(s) on this remote machine over SSH instead of locally (file-backed bundles only — the remote\'s passphrase prompt surfaces on your terminal over a -tt session). Single-valued (NOT variadic) so it never swallows the bundle name: `unlock <name> --host <machine>`.')
2060
2064
  .action(async (names, opts) => {
@@ -2127,12 +2131,24 @@ Examples:
2127
2131
  // (single-instance start lock, #414) and best-effort — never blocks unlock.
2128
2132
  ensureDaemonStarted();
2129
2133
  let loaded = 0;
2134
+ // An unlock is a deliberate act, so it grants GLOBALLY unless `--for`
2135
+ // narrows it to one harness. It must NOT inherit the ambient
2136
+ // AGENTS_AGENT_NAME: that silently scoped a terminal unlock to whichever
2137
+ // agent happened to launch the shell, leaving the grant unreadable to
2138
+ // every other reader for its whole TTL.
2139
+ const harness = opts.for || GLOBAL_HARNESS;
2130
2140
  for (const name of targets) {
2131
2141
  try {
2132
2142
  // noAgent: read the real keychain (one Touch ID) rather than the
2133
2143
  // agent we're about to populate.
2134
- const { bundle, env } = readAndResolveBundleEnv(name, { noAgent: true, caller: 'unlock', keyMode: 'storage' });
2135
- if (await agentLoad(name, bundle, env, ttlMs)) {
2144
+ const { bundle, env } = readAndResolveBundleEnv(name, {
2145
+ noAgent: true,
2146
+ caller: 'unlock secrets',
2147
+ agent: harness,
2148
+ duration: humanRemaining(Date.now() + ttlMs),
2149
+ keyMode: 'storage',
2150
+ });
2151
+ if (await agentLoad(name, bundle, env, ttlMs, harness)) {
2136
2152
  loaded++;
2137
2153
  // Persist a durable session snapshot so the unlock survives a daemon
2138
2154
  // restart / upgrade (and sleep too, with --durable). session-store.ts.
@@ -2141,6 +2157,7 @@ Examples:
2141
2157
  env,
2142
2158
  expiresAt: Date.now() + ttlMs,
2143
2159
  sleepPersist: opts.durable ?? secretsAgentDurable(),
2160
+ harness,
2144
2161
  });
2145
2162
  console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(Date.now() + ttlMs)})`)}`);
2146
2163
  }
@@ -2170,7 +2187,7 @@ Examples:
2170
2187
  let total = 0;
2171
2188
  for (const name of names) {
2172
2189
  total += await agentLock(name);
2173
- deleteSession(name); // also drop the durable snapshot, or a restart re-warms it
2190
+ deleteBundleSessions(name); // drop every harness scope
2174
2191
  }
2175
2192
  console.log(total > 0 ? chalk.green(`Locked ${total} bundle(s).`) : chalk.gray('Nothing to lock.'));
2176
2193
  }
@@ -2216,16 +2233,47 @@ Examples:
2216
2233
  } })();
2217
2234
  console.log(chalk.gray(`hold: ${holdStr}${configured ? ' (secrets.agent.holdMs)' : ' (default)'} — a daily bundle prompts once, then stays silent for this long or until sleep/logout.`));
2218
2235
  const entries = await agentStatus();
2236
+ const held = new Set(entries.map((e) => e.name));
2219
2237
  if (entries.length === 0) {
2220
2238
  console.log(chalk.gray('No bundles held. The next read of each daily bundle will prompt once, then hold.'));
2221
2239
  console.log(chalk.gray('Pre-warm now with: agents secrets unlock <bundle> (or --all)'));
2222
- return;
2223
2240
  }
2224
- console.log(chalk.bold(`${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(5)} LOCKS IN`));
2225
- for (const e of entries) {
2226
- console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}`);
2241
+ else {
2242
+ console.log(chalk.bold(`${'BUNDLE'.padEnd(24)} ${'KEYS'.padEnd(5)} LOCKS IN`));
2243
+ for (const e of entries) {
2244
+ console.log(`${chalk.cyan(e.name.padEnd(24))} ${String(e.keyCount).padEnd(5)} ${humanRemaining(e.expiresAt)}`);
2245
+ }
2246
+ console.log(chalk.gray('Reads of held bundles are silent; any bundle not listed prompts once on its next read.'));
2247
+ }
2248
+ // Usage hint: bundles you keep getting a Touch ID prompt for — a keychain
2249
+ // read that fell through to biometry (not served by the broker/session)
2250
+ // >= a few times in the last week, and not currently held. Unlocking each
2251
+ // once silences it for the hold window. This runs even when NO bundle is
2252
+ // held — that user re-prompts on every read and most needs the nudge.
2253
+ // Excludes `never`/no-ACL bundles (never prompt) and file/vault bundles
2254
+ // (resolve by passphrase, not the keychain broker — `unlock` is a no-op).
2255
+ try {
2256
+ const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
2257
+ const reads = query({ eventTypes: ['secrets.get'], startDate: weekAgo });
2258
+ const hot = frequentlyPromptedBundles(reads, held, { minReads: 3 }).filter((h) => {
2259
+ try {
2260
+ return bundleBackend(h.name) === 'keychain' && bundlePolicy(readBundle(h.name)) !== 'never';
2261
+ }
2262
+ catch {
2263
+ return false; // bundle gone / unreadable metadata — nothing to suggest
2264
+ }
2265
+ });
2266
+ if (hot.length > 0) {
2267
+ console.log();
2268
+ console.log(chalk.bold('Prompted often — unlock once to silence:'));
2269
+ for (const h of hot) {
2270
+ console.log(`${chalk.cyan(h.name.padEnd(24))} ${chalk.gray(`${h.count}× in the last 7d`)} → ${chalk.green(`agents secrets unlock ${h.name}`)}`);
2271
+ }
2272
+ }
2273
+ }
2274
+ catch {
2275
+ // Best-effort hint — never let usage analysis break `secrets status`.
2227
2276
  }
2228
- console.log(chalk.gray('Reads of held bundles are silent; any bundle not listed prompts once on its next read.'));
2229
2277
  });
2230
2278
  cmd
2231
2279
  .command('policy <bundle> [policy]')
@@ -9,7 +9,8 @@
9
9
  * Built on {@link dynamicPicker}; every data source (discover, fleet, live index,
10
10
  * preview, resume dispatch) is reused from the existing sessions plumbing.
11
11
  */
12
- import type { SessionMeta } from '../lib/session/types.js';
12
+ import { type SessionMeta } from '../lib/session/types.js';
13
+ import type { ActiveSession } from '../lib/session/active.js';
13
14
  /**
14
15
  * The single canonical filter state. Every field has a flag equivalent, so the
15
16
  * same view is reachable interactively (hotkeys) or from the command line (flags).
@@ -59,11 +60,14 @@ export declare function activeBrowserSeed(opts: {
59
60
  agent?: string;
60
61
  host?: string[];
61
62
  since?: string;
63
+ all?: boolean;
62
64
  }): Partial<BrowserFilter>;
63
65
  /**
64
66
  * The initial filter for the bare interactive listing: current-repo subtree by
65
- * default (matches the static overview's cwd scoping), `--all` widens to every
66
- * directory, `--since` seeds the window.
67
+ * default (matches the static overview's cwd scoping). `--all` sets every
68
+ * non-status filter to its "all" value — every directory (project) AND all-time
69
+ * (window) — so one flag maxes the view; `--since` still overrides the window and
70
+ * `-a`/`--device` still narrow their axis.
67
71
  */
68
72
  export declare function bareBrowserSeed(opts: {
69
73
  teams?: boolean;
@@ -71,6 +75,44 @@ export declare function bareBrowserSeed(opts: {
71
75
  all?: boolean;
72
76
  since?: string;
73
77
  }): Partial<BrowserFilter>;
78
+ /**
79
+ * A live session's stable row key: its session id when the agent reported one,
80
+ * else a per-machine handle so an id-less live session (a just-booted harness, a
81
+ * cloud task) still gets exactly one row instead of being dropped. Cloud rows key
82
+ * on the task id because they have no pid — keying them all on the machine alone
83
+ * would collapse two cloud tasks into one row, the same silent-drop this whole
84
+ * change exists to remove.
85
+ */
86
+ export declare function liveRowKey(a: ActiveSession, self: string): string;
87
+ /** Index live sessions by {@link liveRowKey} — the join key between the live scan
88
+ * and the transcript pool. Id-carrying rows key on the session id, so a live row
89
+ * and its transcript row collapse to one. */
90
+ export declare function indexLiveRows(rows: ActiveSession[], self: string): Map<string, ActiveSession>;
91
+ /**
92
+ * Project a live session onto the picker's row shape, for a session the transcript
93
+ * pool doesn't carry — a peer's session, a transcript outside the current window,
94
+ * or an agent that has not written one yet. `filePath` is the live transcript path
95
+ * when the scan resolved one and empty otherwise, which is what
96
+ * {@link handlePickedSession} keys "there is nothing to open yet" off.
97
+ */
98
+ export declare function liveSessionToMeta(a: ActiveSession, self: string): SessionMeta;
99
+ /**
100
+ * Fold the live scan INTO the transcript pool. The running filter used to be a
101
+ * pure intersection (`pool ∩ live`), which meant a session had to already be in
102
+ * the pool to be shown as running — so every session on another machine, and
103
+ * every local one outside the pool's window, was invisible in the browser while
104
+ * `--active --json` listed it. Live sessions the pool lacks are appended as their
105
+ * own rows, then the whole set is grouped local-machine-first.
106
+ */
107
+ export declare function mergeLiveIntoPool(rows: SessionMeta[], live: Map<string, ActiveSession>, self: string): SessionMeta[];
108
+ /**
109
+ * Whether to render the host-program column. It belongs to the running view
110
+ * only, so this gates on the FILTER — not on the live index being populated.
111
+ * `liveCache` outlives a toggle of the `r` hotkey, so testing `live` alone would
112
+ * keep the column after running is turned back off, widening a plain transcript
113
+ * listing that has no live rows to explain it.
114
+ */
115
+ export declare function shouldShowHostColumn(f: BrowserFilter, live: Map<string, ActiveSession> | null, rows: SessionMeta[]): boolean;
74
116
  /**
75
117
  * Launch the interactive session browser. `initial` seeds the filter (e.g.
76
118
  * `{ running: true }` for `--active`). Resolves after the user resumes a session
@@ -9,14 +9,15 @@
9
9
  * Built on {@link dynamicPicker}; every data source (discover, fleet, live index,
10
10
  * preview, resume dispatch) is reused from the existing sessions plumbing.
11
11
  */
12
+ import path from 'path';
12
13
  import { spawnSync } from 'child_process';
13
14
  import { dynamicPicker } from '../lib/picker.js';
14
- import { getActiveSessions } from '../lib/session/active.js';
15
+ import { isSessionTrackedAgent } from '../lib/session/types.js';
15
16
  import { discoverSessions } from '../lib/session/discover.js';
16
17
  import { gatherRemoteList } from '../lib/session/remote-list.js';
17
18
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
18
19
  import { buildPreview } from './sessions-picker.js';
19
- import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, indexActiveBySessionId, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, } from './sessions.js';
20
+ import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, } from './sessions.js';
20
21
  /** Ordered window cycle for the `W` key. `undefined` = all time. */
21
22
  const WINDOW_CYCLE = [undefined, '1d', '7d', '30d'];
22
23
  /** Return the next value in `[undefined, ...options]`, wrapping. */
@@ -102,20 +103,25 @@ export function activeBrowserSeed(opts) {
102
103
  agent: opts.agent,
103
104
  projectScope: 'all',
104
105
  device: normalizeDeviceSeed(opts.host?.[0]),
105
- window: opts.since ?? '30d',
106
+ // --all widens the window to all-time (project is already 'all' here);
107
+ // --since still overrides.
108
+ window: opts.since ?? (opts.all ? undefined : '30d'),
106
109
  };
107
110
  }
108
111
  /**
109
112
  * The initial filter for the bare interactive listing: current-repo subtree by
110
- * default (matches the static overview's cwd scoping), `--all` widens to every
111
- * directory, `--since` seeds the window.
113
+ * default (matches the static overview's cwd scoping). `--all` sets every
114
+ * non-status filter to its "all" value — every directory (project) AND all-time
115
+ * (window) — so one flag maxes the view; `--since` still overrides the window and
116
+ * `-a`/`--device` still narrow their axis.
112
117
  */
113
118
  export function bareBrowserSeed(opts) {
114
119
  return {
115
120
  teams: !!opts.teams,
116
121
  agent: opts.agent,
122
+ // --all maxes every non-status filter: all dirs AND all-time. --since wins.
117
123
  projectScope: opts.all ? 'all' : 'repo',
118
- window: opts.since ?? '30d',
124
+ window: opts.since ?? (opts.all ? undefined : '30d'),
119
125
  };
120
126
  }
121
127
  /** Copy text to the OS clipboard (best-effort; silently no-ops if unavailable). */
@@ -182,6 +188,99 @@ async function fetchRawPool(f, self, local, hosts) {
182
188
  }
183
189
  return { key: `${since ?? 'all'}|${f.teams}`, rows };
184
190
  }
191
+ /**
192
+ * A live session's stable row key: its session id when the agent reported one,
193
+ * else a per-machine handle so an id-less live session (a just-booted harness, a
194
+ * cloud task) still gets exactly one row instead of being dropped. Cloud rows key
195
+ * on the task id because they have no pid — keying them all on the machine alone
196
+ * would collapse two cloud tasks into one row, the same silent-drop this whole
197
+ * change exists to remove.
198
+ */
199
+ export function liveRowKey(a, self) {
200
+ if (a.sessionId)
201
+ return a.sessionId;
202
+ const handle = a.cloudTaskId ?? (a.pid != null ? String(a.pid) : 'unknown');
203
+ return `${LIVE_ROW_PREFIX}${a.machine ?? self}:${handle}`;
204
+ }
205
+ /** Index live sessions by {@link liveRowKey} — the join key between the live scan
206
+ * and the transcript pool. Id-carrying rows key on the session id, so a live row
207
+ * and its transcript row collapse to one. */
208
+ export function indexLiveRows(rows, self) {
209
+ const byKey = new Map();
210
+ for (const a of rows)
211
+ byKey.set(liveRowKey(a, self), a);
212
+ return byKey;
213
+ }
214
+ /**
215
+ * Project a live session onto the picker's row shape, for a session the transcript
216
+ * pool doesn't carry — a peer's session, a transcript outside the current window,
217
+ * or an agent that has not written one yet. `filePath` is the live transcript path
218
+ * when the scan resolved one and empty otherwise, which is what
219
+ * {@link handlePickedSession} keys "there is nothing to open yet" off.
220
+ */
221
+ export function liveSessionToMeta(a, self) {
222
+ const machine = a.machine ?? self;
223
+ const started = a.startedAtMs ?? a.lastActivityMs;
224
+ const topic = a.topic ?? a.preview;
225
+ return {
226
+ id: liveRowKey(a, self),
227
+ // No session id to short — name the row by what it IS (a cloud task, a pid),
228
+ // so the id column still identifies the process you'd go looking for.
229
+ shortId: a.sessionId
230
+ ? a.sessionId.slice(0, 8)
231
+ : a.cloudTaskId
232
+ ? a.cloudTaskId.slice(0, 8)
233
+ : `p:${a.pid ?? '?'}`,
234
+ agent: isSessionTrackedAgent(a.kind) ? a.kind : 'claude',
235
+ timestamp: new Date(started ?? Date.now()).toISOString(),
236
+ lastActivity: a.lastActivityMs ? new Date(a.lastActivityMs).toISOString() : undefined,
237
+ project: a.cwd ? path.basename(a.cwd) : undefined,
238
+ cwd: a.cwd,
239
+ filePath: a.sessionFile ?? '',
240
+ // A live topic is raw transcript text: a newline in it would break the row
241
+ // into two and misalign every column after it. Indexed rows are already
242
+ // cleaned at scan time, so this puts projected rows on the same footing.
243
+ topic: topic ? cleanPreview(topic) : undefined,
244
+ label: a.label ? cleanPreview(a.label) : undefined,
245
+ machine,
246
+ // Reading/resuming a peer's session hops back over SSH — same contract the
247
+ // cross-machine listing sets, so handlePickedSession routes it correctly.
248
+ _remote: machine !== self,
249
+ prUrl: a.pr?.url,
250
+ prNumber: a.pr?.number,
251
+ ticketId: a.ticket?.id,
252
+ worktreeSlug: a.worktree?.slug,
253
+ };
254
+ }
255
+ /**
256
+ * Fold the live scan INTO the transcript pool. The running filter used to be a
257
+ * pure intersection (`pool ∩ live`), which meant a session had to already be in
258
+ * the pool to be shown as running — so every session on another machine, and
259
+ * every local one outside the pool's window, was invisible in the browser while
260
+ * `--active --json` listed it. Live sessions the pool lacks are appended as their
261
+ * own rows, then the whole set is grouped local-machine-first.
262
+ */
263
+ export function mergeLiveIntoPool(rows, live, self) {
264
+ const known = new Set(rows.map((r) => r.id));
265
+ const extra = [];
266
+ for (const [key, a] of live) {
267
+ if (!known.has(key))
268
+ extra.push(liveSessionToMeta(a, self));
269
+ }
270
+ return extra.length === 0 ? rows : mergeLocalFirst([...rows, ...extra], self);
271
+ }
272
+ /**
273
+ * Whether to render the host-program column. It belongs to the running view
274
+ * only, so this gates on the FILTER — not on the live index being populated.
275
+ * `liveCache` outlives a toggle of the `r` hotkey, so testing `live` alone would
276
+ * keep the column after running is turned back off, widening a plain transcript
277
+ * listing that has no live rows to explain it.
278
+ */
279
+ export function shouldShowHostColumn(f, live, rows) {
280
+ if (!f.running || !live)
281
+ return false;
282
+ return rows.some((r) => liveHostLabel(live.get(r.id)) !== '');
283
+ }
185
284
  /** Apply the cheap in-memory filters (agent / device / project / running). */
186
285
  function applyFilters(rows, live, f, self) {
187
286
  let out = rows;
@@ -197,6 +296,16 @@ function applyFilters(rows, live, f, self) {
197
296
  out = out.filter((r) => live.has(r.id));
198
297
  return out;
199
298
  }
299
+ /** Derive the SSH-launch origin tag for a picker row from the live index. Set
300
+ * only when the live session's provenance is ssh transport; `device` is the
301
+ * resolved origin device (absent → the row shows a bare `ssh`). Rows without a
302
+ * live entry (the running filter off) get no tag — provenance is live-only. */
303
+ function sshOriginTagFor(live, id) {
304
+ const p = live?.get(id)?.provenance;
305
+ if (p?.transport !== 'ssh')
306
+ return undefined;
307
+ return p.origin?.device ? { device: p.origin.device } : {};
308
+ }
200
309
  function headerFor(f) {
201
310
  const bits = [
202
311
  `device:${f.device ?? 'all'}`,
@@ -259,11 +368,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
259
368
  return []; // superseded — don't touch shared state
260
369
  pool = fetched;
261
370
  }
262
- // Only pay for the live scan when the running filter needs it.
371
+ // Only pay for the live scan when the running filter needs it. Same fleet
372
+ // sweep the static `--active` view uses, so the two never disagree about
373
+ // what is running.
263
374
  let live = liveCache;
264
375
  if (f.running && !live) {
265
376
  try {
266
- live = indexActiveBySessionId(await getActiveSessions());
377
+ const { sessions } = await gatherActiveSessions({ local, hosts });
378
+ live = indexLiveRows(sessions, self);
267
379
  }
268
380
  catch {
269
381
  live = new Map();
@@ -276,10 +388,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
276
388
  rawCache = pool;
277
389
  if (live)
278
390
  liveCache = live;
279
- agentsInPool = distinct(pool.rows.map((r) => r.agent));
280
- devicesInPool = distinct(pool.rows.map((r) => r.machine ?? self));
281
- const filtered = applyFilters(pool.rows, live ?? new Map(), f, self);
391
+ // Live sessions the transcript pool lacks become rows of their own, so the
392
+ // running view lists every active session, not just the ones already indexed.
393
+ const rows = f.running && live ? mergeLiveIntoPool(pool.rows, live, self) : pool.rows;
394
+ agentsInPool = distinct(rows.map((r) => r.agent));
395
+ devicesInPool = distinct(rows.map((r) => r.machine ?? self));
396
+ const filtered = applyFilters(rows, live ?? new Map(), f, self);
282
397
  cols = pickerColumnsFor(filtered);
398
+ cols.showHost = shouldShowHostColumn(f, live, filtered);
283
399
  return filtered;
284
400
  };
285
401
  const picked = await dynamicPicker({
@@ -287,7 +403,7 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
287
403
  initialFilter,
288
404
  load,
289
405
  keyFor: (s) => s.id,
290
- labelFor: (s, q) => formatPickerLabel(s, q, cols),
406
+ labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id))),
291
407
  matches: sessionMatchesQuery,
292
408
  buildPreview,
293
409
  headerFor,
@@ -1,2 +1,5 @@
1
1
  import type { Command } from 'commander';
2
+ import type { SessionMeta } from '../lib/session/types.js';
2
3
  export declare function registerSessionsExportCommand(sessionsCmd: Command): void;
4
+ /** ids > query > everything-in-scope. Exported for the id-resolution test. */
5
+ export declare function selectSessions(metas: SessionMeta[], selectors: string[]): SessionMeta[];
@@ -20,7 +20,7 @@
20
20
  import * as fs from 'fs';
21
21
  import * as path from 'path';
22
22
  import chalk from 'chalk';
23
- import { discoverSessions, resolveSessionById, isCompleteSessionId } from '../lib/session/discover.js';
23
+ import { discoverSessions, resolveSessionById, looksLikeSessionId } from '../lib/session/discover.js';
24
24
  import { findSessionsById } from '../lib/session/db.js';
25
25
  import { filterSessionsByQuery, parseAgentFilter } from './sessions.js';
26
26
  import { listLocalTranscripts, SYNC_AGENTS } from '../lib/session/sync/agents.js';
@@ -222,8 +222,8 @@ function resolveAgentShorthand(g) {
222
222
  return 'antigravity';
223
223
  return undefined;
224
224
  }
225
- /** ids > query > everything-in-scope. */
226
- function selectSessions(metas, selectors) {
225
+ /** ids > query > everything-in-scope. Exported for the id-resolution test. */
226
+ export function selectSessions(metas, selectors) {
227
227
  if (selectors.length === 0)
228
228
  return metas;
229
229
  const byId = [];
@@ -231,9 +231,11 @@ function selectSessions(metas, selectors) {
231
231
  for (const sel of selectors) {
232
232
  const trimmed = sel.trim();
233
233
  // Same reason as resolveSessionQuery: the discovered pool is a minority of
234
- // the index, so a complete id absent from it may still be indexed here.
234
+ // the index, so an id-shaped selector absent from it may still be indexed
235
+ // here. Gate on looksLikeSessionId, not isCompleteSessionId, so a SHORT id
236
+ // ("d3470b57") also resolves through the index instead of falling to content.
235
237
  const hits = resolveSessionById(metas, trimmed);
236
- const resolved = hits.length > 0 || !isCompleteSessionId(trimmed) ? hits : findSessionsById(trimmed);
238
+ const resolved = hits.length > 0 || !looksLikeSessionId(trimmed) ? hits : findSessionsById(trimmed);
237
239
  if (resolved.length > 0)
238
240
  byId.push(...resolved);
239
241
  else
@@ -243,11 +245,12 @@ function selectSessions(metas, selectors) {
243
245
  const seen = new Set();
244
246
  return byId.filter(s => (seen.has(s.id) ? false : (seen.add(s.id), true)));
245
247
  }
246
- // A selector that is a COMPLETE session id and still missed cannot be widened:
247
- // the id is unique, so the text query below could only bundle sessions that
248
- // merely mention it. Bundling those would ship unrelated transcripts to
249
- // whoever receives the export, so select nothing and let the caller report it.
250
- const missingIds = unmatched.filter(isCompleteSessionId);
248
+ // A selector that is an id (complete OR a short hex id/prefix) and still missed
249
+ // cannot be widened: the id is unique, so the text query below could only bundle
250
+ // sessions that merely mention it. Bundling those would ship unrelated
251
+ // transcripts to whoever receives the export, so select nothing and let the
252
+ // caller report it — a short id like "d3470b57" must never content-search.
253
+ const missingIds = unmatched.filter(looksLikeSessionId);
251
254
  if (missingIds.length > 0) {
252
255
  process.stderr.write(chalk.red(`No session with id ${missingIds.join(', ')} on this machine.\n`));
253
256
  return [];
@@ -1,4 +1,18 @@
1
- import type { SessionMeta } from '../lib/session/types.js';
1
+ import type { SessionEvent, SessionMeta, TodoProgress } from '../lib/session/types.js';
2
+ import { classifyFileChanges } from '../lib/session/digest.js';
3
+ /**
4
+ * Compact checklist tally for list rows and previews (RUSH-2045).
5
+ * Example: `✓6/8 · A5 wiring runner`. Empty string when there is no list.
6
+ * Consumes `SessionMeta.todos` / `ActiveSession.todos` as populated by the
7
+ * state engine — does not re-parse transcripts.
8
+ */
9
+ export declare function formatTodoCompact(todos?: Pick<TodoProgress, 'done' | 'total' | 'activeForm'> | null): string;
10
+ /**
11
+ * Best-effort GitHub repo URL from a checkout path shaped like
12
+ * `…/github.com/<owner>/<repo>/…`. Used to make the project name clickable
13
+ * when no Linear project URL is available.
14
+ */
15
+ export declare function githubRepoUrlFromCwd(cwd?: string): string | undefined;
2
16
  export interface PickedSession {
3
17
  session: SessionMeta;
4
18
  action: 'resume' | 'view';
@@ -17,5 +31,18 @@ export interface SessionPickerConfig {
17
31
  }
18
32
  /** Build a cached multi-line preview string for display in the session picker. */
19
33
  export declare function buildPreview(session: SessionMeta): string;
34
+ /** Optional dirs-touched field the parser teammate may attach; we prefer it. */
35
+ type SessionMetaWithDirs = SessionMeta & {
36
+ dirsTouched?: string[];
37
+ };
38
+ /**
39
+ * Unique directories the session touched, compact and human-readable.
40
+ * Prefer `session.dirsTouched` when the parser teammate has populated it;
41
+ * otherwise derive from file-change + tool paths already available here.
42
+ */
43
+ export declare function directoriesTouched(session: SessionMetaWithDirs, events: SessionEvent[], changes: ReturnType<typeof classifyFileChanges>): string[];
44
+ /** Relativize a file path to its parent dir, short enough for one preview line. */
45
+ export declare function relativizeDir(filePath: string, cwd?: string): string | undefined;
20
46
  /** Show an interactive session picker and return the selected session with its action (resume or view). */
21
47
  export declare function sessionPicker(config: SessionPickerConfig): Promise<PickedSession | null>;
48
+ export {};