@phnx-labs/agents-cli 1.22.23 → 1.22.25

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 (143) hide show
  1. package/CHANGELOG.md +359 -0
  2. package/README.md +19 -8
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/attach.d.ts +2 -0
  5. package/dist/commands/attach.js +32 -7
  6. package/dist/commands/cloud.js +9 -5
  7. package/dist/commands/defaults.js +2 -0
  8. package/dist/commands/doctor.d.ts +24 -0
  9. package/dist/commands/doctor.js +105 -9
  10. package/dist/commands/exec.js +99 -44
  11. package/dist/commands/feed.d.ts +23 -0
  12. package/dist/commands/feed.js +65 -2
  13. package/dist/commands/focus.d.ts +34 -3
  14. package/dist/commands/focus.js +301 -57
  15. package/dist/commands/go.d.ts +14 -1
  16. package/dist/commands/go.js +49 -5
  17. package/dist/commands/insights.js +6 -2
  18. package/dist/commands/inspect.js +39 -5
  19. package/dist/commands/menubar.js +14 -1
  20. package/dist/commands/models.js +1 -0
  21. package/dist/commands/modes.d.ts +12 -0
  22. package/dist/commands/modes.js +147 -0
  23. package/dist/commands/routines.d.ts +3 -0
  24. package/dist/commands/routines.js +70 -47
  25. package/dist/commands/run-cloud.js +1 -1
  26. package/dist/commands/secrets-sync.js +11 -13
  27. package/dist/commands/secrets.d.ts +2 -0
  28. package/dist/commands/secrets.js +54 -17
  29. package/dist/commands/sessions-browser.d.ts +36 -1
  30. package/dist/commands/sessions-browser.js +165 -19
  31. package/dist/commands/sessions-resume.d.ts +2 -0
  32. package/dist/commands/sessions-resume.js +28 -12
  33. package/dist/commands/sessions.d.ts +23 -1
  34. package/dist/commands/sessions.js +58 -61
  35. package/dist/commands/setup-secrets.js +1 -1
  36. package/dist/commands/setup-watchdog.js +5 -10
  37. package/dist/commands/setup.js +1 -1
  38. package/dist/commands/sync.js +246 -42
  39. package/dist/commands/teams.js +3 -2
  40. package/dist/commands/view.js +2 -0
  41. package/dist/commands/watchdog.d.ts +3 -4
  42. package/dist/commands/watchdog.js +26 -66
  43. package/dist/index.js +38 -2
  44. package/dist/lib/agent-modes.d.ts +49 -0
  45. package/dist/lib/agent-modes.js +70 -0
  46. package/dist/lib/agents.js +126 -21
  47. package/dist/lib/cloud/cursor.d.ts +79 -0
  48. package/dist/lib/cloud/cursor.js +228 -0
  49. package/dist/lib/cloud/registry.js +2 -0
  50. package/dist/lib/cloud/types.d.ts +7 -2
  51. package/dist/lib/cloud/types.js +14 -0
  52. package/dist/lib/crabbox/cli.d.ts +2 -2
  53. package/dist/lib/crabbox/config.d.ts +7 -8
  54. package/dist/lib/crabbox/config.js +14 -14
  55. package/dist/lib/crabbox/lease.d.ts +11 -4
  56. package/dist/lib/crabbox/lease.js +40 -8
  57. package/dist/lib/crabbox/setup-copy.d.ts +5 -0
  58. package/dist/lib/crabbox/setup-copy.js +17 -1
  59. package/dist/lib/daemon.js +27 -1
  60. package/dist/lib/device-config.js +7 -0
  61. package/dist/lib/devices/doctor-findings.d.ts +7 -1
  62. package/dist/lib/devices/doctor-findings.js +40 -1
  63. package/dist/lib/doctor-diff.d.ts +3 -0
  64. package/dist/lib/doctor-diff.js +15 -13
  65. package/dist/lib/event-stream.d.ts +3 -1
  66. package/dist/lib/event-stream.js +14 -1
  67. package/dist/lib/events.d.ts +9 -0
  68. package/dist/lib/events.js +58 -0
  69. package/dist/lib/exec.d.ts +15 -3
  70. package/dist/lib/exec.js +112 -19
  71. package/dist/lib/feed-outcome.d.ts +3 -0
  72. package/dist/lib/feed-outcome.js +18 -10
  73. package/dist/lib/feed.d.ts +4 -0
  74. package/dist/lib/hooks/cache.js +36 -3
  75. package/dist/lib/hooks.d.ts +19 -7
  76. package/dist/lib/hooks.js +88 -38
  77. package/dist/lib/hosts/passthrough.d.ts +21 -0
  78. package/dist/lib/hosts/passthrough.js +39 -12
  79. package/dist/lib/hosts/session-index.d.ts +4 -0
  80. package/dist/lib/hosts/session-index.js +7 -0
  81. package/dist/lib/manifest.d.ts +12 -2
  82. package/dist/lib/manifest.js +60 -5
  83. package/dist/lib/mcp.js +5 -1
  84. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  85. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
  86. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  87. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  88. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  89. package/dist/lib/menubar/install-menubar.d.ts +52 -2
  90. package/dist/lib/menubar/install-menubar.js +128 -6
  91. package/dist/lib/menubar/snapshot.d.ts +15 -0
  92. package/dist/lib/menubar/snapshot.js +40 -0
  93. package/dist/lib/plugins.js +13 -1
  94. package/dist/lib/refresh.d.ts +5 -0
  95. package/dist/lib/refresh.js +37 -33
  96. package/dist/lib/resource-inventory.d.ts +79 -0
  97. package/dist/lib/resource-inventory.js +122 -0
  98. package/dist/lib/resources/mcp.js +3 -0
  99. package/dist/lib/resources.js +8 -5
  100. package/dist/lib/routine-process-cleanup.d.ts +9 -0
  101. package/dist/lib/routine-process-cleanup.js +73 -0
  102. package/dist/lib/run-defaults.d.ts +2 -0
  103. package/dist/lib/run-defaults.js +23 -2
  104. package/dist/lib/runner.js +27 -22
  105. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  106. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  107. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  108. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  109. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  110. package/dist/lib/secrets/bundles.js +158 -85
  111. package/dist/lib/secrets/filestore.d.ts +7 -3
  112. package/dist/lib/secrets/filestore.js +25 -8
  113. package/dist/lib/secrets/index.js +34 -8
  114. package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
  115. package/dist/lib/secrets/rc-hygiene.js +14 -1
  116. package/dist/lib/secrets/reaper.d.ts +10 -0
  117. package/dist/lib/secrets/reaper.js +50 -15
  118. package/dist/lib/secrets/sync-passphrase.d.ts +27 -0
  119. package/dist/lib/secrets/sync-passphrase.js +78 -0
  120. package/dist/lib/session/active.d.ts +4 -0
  121. package/dist/lib/session/db.js +8 -2
  122. package/dist/lib/session/recovery.d.ts +37 -0
  123. package/dist/lib/session/recovery.js +95 -0
  124. package/dist/lib/session/remote-list.d.ts +2 -0
  125. package/dist/lib/session/remote-list.js +1 -0
  126. package/dist/lib/session/session-cache.d.ts +4 -4
  127. package/dist/lib/session/session-cache.js +4 -4
  128. package/dist/lib/shims.d.ts +2 -2
  129. package/dist/lib/shims.js +45 -6
  130. package/dist/lib/signin-badge.js +2 -0
  131. package/dist/lib/startup/command-registry.d.ts +16 -0
  132. package/dist/lib/startup/command-registry.js +44 -0
  133. package/dist/lib/sync-umbrella.d.ts +5 -0
  134. package/dist/lib/sync-umbrella.js +5 -4
  135. package/dist/lib/teams/agents.js +1 -1
  136. package/dist/lib/teams/parsers.d.ts +1 -1
  137. package/dist/lib/tmux/session.d.ts +4 -2
  138. package/dist/lib/tmux/session.js +5 -5
  139. package/dist/lib/types.d.ts +3 -1
  140. package/dist/lib/versions.js +16 -1
  141. package/dist/lib/watchdog/service.d.ts +17 -0
  142. package/dist/lib/watchdog/service.js +39 -0
  143. package/package.json +1 -1
@@ -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 { SYNC_PASSPHRASE_ENV, resolveSyncPassphraseFromEnv } from '../lib/secrets/sync-passphrase.js';
34
35
  import { getBundleUsage, getAllBundleUsage, getUsageHistory, SECRET_USAGE_EVENTS, } from '../lib/secrets/usage-db.js';
35
36
  import { frequentlyPromptedBundles } from '../lib/secrets/unlock-hints.js';
36
37
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
@@ -266,11 +267,31 @@ function maybePrintSyncedHint(name, stillPresent) {
266
267
  * defaults). Shared with the command action so the wiring is unit-testable
267
268
  * without a live SSH session.
268
269
  */
270
+ export function resolveUnlockTtlMs(ttl, until, now = Date.now()) {
271
+ if (ttl && until)
272
+ throw new Error('--ttl and --until are mutually exclusive.');
273
+ if (until) {
274
+ const expiresAt = Date.parse(until);
275
+ if (!Number.isFinite(expiresAt))
276
+ throw new Error("Invalid --until '" + until + "'. Use an ISO date or timestamp.");
277
+ if (expiresAt <= now)
278
+ throw new Error("Invalid --until '" + until + "': date must be in the future.");
279
+ return expiresAt - now;
280
+ }
281
+ if (ttl) {
282
+ const secs = parseDuration(ttl);
283
+ if (!secs)
284
+ throw new Error("Invalid --ttl '" + ttl + "'. Use e.g. 30m, 2h, 8h, 3d.");
285
+ return secs * 1000;
286
+ }
287
+ return secretsHoldMs();
288
+ }
269
289
  export function buildRemoteUnlockArgs(names, opts) {
270
290
  return [
271
291
  'unlock',
272
292
  ...(opts.all ? ['--all'] : names),
273
293
  ...(opts.ttl ? ['--ttl', opts.ttl] : []),
294
+ ...(opts.until ? ['--until', opts.until] : []),
274
295
  // Forward --durable so a remote unlock honors it too; without this the remote
275
296
  // silently falls back to its own secrets.agent.durable default (off).
276
297
  ...(opts.durable ? ['--durable'] : []),
@@ -1957,7 +1978,7 @@ Examples:
1957
1978
  .option('--synced', 'When creating the bundle, store it in the age-encrypted synced secrets file')
1958
1979
  .option('--force', 'Overwrite an existing key in the bundle')
1959
1980
  .option('--purge', 'With --from icloud: delete the iCloud copies after a successful import (iCloud propagates the deletion to your other devices)')
1960
- .option('--from-file <path>', 'Import from an AES-256-GCM encrypted offline bundle file (needs AGENTS_SECRETS_PASSPHRASE; symmetric counterpart of export --to-file)')
1981
+ .option('--from-file <path>', `Import from an AES-256-GCM encrypted offline bundle file (needs ${SYNC_PASSPHRASE_ENV}; symmetric counterpart of export --to-file)`)
1961
1982
  .option('--from-ssh', 'Pull the bundle from a fleet peer over SSH and import it locally (requires --host)')
1962
1983
  .option('--host <peer>', 'SSH peer to pull from when using --from-ssh (host alias or user@host)')
1963
1984
  .action(async (bundleName, opts) => {
@@ -1973,9 +1994,10 @@ Examples:
1973
1994
  throw new Error('--from-file, --from-ssh, and --from/--from-1password are mutually exclusive; pick one import source.');
1974
1995
  }
1975
1996
  if (opts.fromFile) {
1976
- const passphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
1997
+ // Transport, not the local store's master key — see lib/secrets/sync-passphrase.ts.
1998
+ const passphrase = resolveSyncPassphraseFromEnv().value ?? '';
1977
1999
  if (!passphrase) {
1978
- throw new Error('--from-file needs AGENTS_SECRETS_PASSPHRASE set to decrypt the bundle file.');
2000
+ throw new Error(`--from-file needs ${SYNC_PASSPHRASE_ENV} set to decrypt the bundle file.`);
1979
2001
  }
1980
2002
  const env = importBundleFromFile(opts.fromFile, passphrase);
1981
2003
  const resolvedBundleName = bundleName ?? (await pickBundleName('import into'));
@@ -2063,7 +2085,7 @@ Examples:
2063
2085
  .option('--remote-backend <backend>', 'Backend for the bundle on the remote (with --host): keychain (default) or file. file is headless-readable via the remote\'s machine-local key; it forwards AGENTS_SECRETS_PASSPHRASE over stdin only if set (opt-in).', 'keychain')
2064
2086
  .option('--force', 'Overwrite existing keys/items on the target (used with --to-1password and --host)')
2065
2087
  .option('--format <shell|json>', 'Output for --plaintext export: shell (default) or json (lossless, machine-readable; used by remote resolve)', 'shell')
2066
- .option('--to-file <path>', 'Write the bundle as an AES-256-GCM encrypted offline file (needs AGENTS_SECRETS_PASSPHRASE; symmetric counterpart of import --from-file)')
2088
+ .option('--to-file <path>', `Write the bundle as an AES-256-GCM encrypted offline file (needs ${SYNC_PASSPHRASE_ENV}; symmetric counterpart of import --from-file)`)
2067
2089
  .action(async (bundleName, opts) => {
2068
2090
  try {
2069
2091
  // `--device` is an alias for `--host` (fleet vocabulary parity with
@@ -2074,9 +2096,10 @@ Examples:
2074
2096
  const { readAndResolveBundleEnv, bundleToEnvPrefix, isReservedEnvName } = await import('../lib/secrets/bundles.js');
2075
2097
  const resolvedBundleName = bundleName ?? (await pickBundleName('export'));
2076
2098
  if (opts.toFile) {
2077
- const passphrase = process.env.AGENTS_SECRETS_PASSPHRASE ?? '';
2099
+ // Transport, not the local store's master key — see lib/secrets/sync-passphrase.ts.
2100
+ const passphrase = resolveSyncPassphraseFromEnv().value ?? '';
2078
2101
  if (!passphrase) {
2079
- throw new Error('--to-file needs AGENTS_SECRETS_PASSPHRASE set to encrypt the bundle. ' +
2102
+ throw new Error(`--to-file needs ${SYNC_PASSPHRASE_ENV} set to encrypt the bundle. ` +
2080
2103
  'Set it for this command, then supply the same value when importing.');
2081
2104
  }
2082
2105
  const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: 'export --to-file', keyMode: 'storage', agentOnly: true });
@@ -2483,11 +2506,16 @@ Examples:
2483
2506
  .command('unlock [names...]')
2484
2507
  .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.')
2485
2508
  .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
2509
+ .option('--until <date>', 'Hold until this absolute date or timestamp (for example 2026-08-06T12:00:00Z). Mutually exclusive with --ttl.')
2486
2510
  .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.')
2487
2511
  .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.')
2488
2512
  .option('--all', 'Unlock every configured bundle')
2489
2513
  .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>`.')
2490
2514
  .action(async (names, opts) => {
2515
+ if (opts.ttl && opts.until) {
2516
+ console.error(chalk.red('--ttl and --until are mutually exclusive.'));
2517
+ process.exit(1);
2518
+ }
2491
2519
  // Single-valued (not variadic): a variadic --host greedily consumes the
2492
2520
  // positional bundle name (`unlock --host mac wztest` -> host=[mac,wztest],
2493
2521
  // names=[]). Unlock targets one remote at a time anyway.
@@ -2524,6 +2552,15 @@ Examples:
2524
2552
  process.exit(1);
2525
2553
  return;
2526
2554
  }
2555
+ if (opts.until) {
2556
+ try {
2557
+ resolveUnlockTtlMs(undefined, opts.until);
2558
+ }
2559
+ catch (err) {
2560
+ console.error(chalk.red(err.message));
2561
+ process.exit(1);
2562
+ }
2563
+ }
2527
2564
  if (process.platform !== 'darwin') {
2528
2565
  // No broker + no biometry prompt off darwin: secrets already resolve
2529
2566
  // durably from the OS store (libsecret / Credential Manager) on every
@@ -2537,15 +2574,15 @@ Examples:
2537
2574
  console.error(chalk.red('Specify one or more bundle names, or --all.'));
2538
2575
  process.exit(1);
2539
2576
  }
2540
- let ttlMs = secretsHoldMs(); // default hold, capped by secrets.agent.holdMs
2541
- if (opts.ttl) {
2542
- const secs = parseDuration(opts.ttl);
2543
- if (!secs) {
2544
- console.error(chalk.red(`Invalid --ttl '${opts.ttl}'. Use e.g. 30m, 2h, 8h, 3d.`));
2545
- process.exit(1);
2546
- }
2547
- ttlMs = secs * 1000;
2577
+ let ttlMs;
2578
+ try {
2579
+ ttlMs = resolveUnlockTtlMs(opts.ttl, opts.until);
2580
+ }
2581
+ catch (err) {
2582
+ console.error(chalk.red(err.message));
2583
+ process.exit(1);
2548
2584
  }
2585
+ const expiresAt = Date.now() + ttlMs;
2549
2586
  if (!(await ensureAgentRunning())) {
2550
2587
  console.error(chalk.red('Could not start the secrets broker.'));
2551
2588
  process.exit(1);
@@ -2571,7 +2608,7 @@ Examples:
2571
2608
  noAgent: true,
2572
2609
  caller: 'unlock secrets',
2573
2610
  agent: harness,
2574
- duration: humanRemaining(Date.now() + ttlMs),
2611
+ duration: humanRemaining(expiresAt),
2575
2612
  keyMode: 'storage',
2576
2613
  });
2577
2614
  // Migrations are authorized only by this explicit unlock. The bundle
@@ -2587,7 +2624,7 @@ Examples:
2587
2624
  saveSession(name, {
2588
2625
  bundle,
2589
2626
  env,
2590
- expiresAt: Date.now() + ttlMs,
2627
+ expiresAt,
2591
2628
  sleepPersist: durable,
2592
2629
  harness,
2593
2630
  });
@@ -2606,7 +2643,7 @@ Examples:
2606
2643
  agent: harness,
2607
2644
  ttlMs,
2608
2645
  });
2609
- console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(Date.now() + ttlMs)})`)}`);
2646
+ console.log(`${chalk.green('unlocked')} ${chalk.cyan(name)} ${chalk.gray(`(${Object.keys(env).length} keys, ${humanRemaining(expiresAt)})`)}`);
2610
2647
  }
2611
2648
  else {
2612
2649
  console.error(chalk.red(`Failed to load '${name}' into the agent.`));
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { type SessionMeta } from '../lib/session/types.js';
13
13
  import type { ActiveSession } from '../lib/session/active.js';
14
+ import { type LiveStatusFilter } from './sessions.js';
14
15
  /**
15
16
  * The single canonical filter state. Every field has a flag equivalent, so the
16
17
  * same view is reachable interactively (hotkeys) or from the command line (flags).
@@ -32,6 +33,23 @@ export interface BrowserFilter {
32
33
  projectScope: 'repo' | 'all';
33
34
  /** time window (undefined = all time) — the `W` key / `--since`. */
34
35
  window?: string;
36
+ /** individual live states; an OR-union and, like the CLI flags, running-only. */
37
+ statuses: LiveStatusFilter[];
38
+ /** named project scope, independent of the repo/all hotkey. */
39
+ project?: string;
40
+ /** upper time bound from --until. */
41
+ until?: string;
42
+ /** retain only routine-origin sessions. */
43
+ routine: boolean;
44
+ /** indexed resource filters shared by the flag and focus surfaces. */
45
+ skill?: string;
46
+ plugin?: string;
47
+ /** discovery cap. */
48
+ limit: number;
49
+ /** include unmanaged native-home transcripts. */
50
+ unmanaged: boolean;
51
+ /** candidate order shared with --sort. */
52
+ sort: 'timestamp' | 'cost' | 'duration';
35
53
  }
36
54
  /**
37
55
  * Complete a seed into the filter the picker actually runs on.
@@ -97,6 +115,9 @@ export declare function bareBrowserSeed(opts: {
97
115
  inTeam?: string;
98
116
  favorites?: boolean;
99
117
  }): Partial<BrowserFilter>;
118
+ /** Build the static peer query. Keeping it pure pins the per-device alias
119
+ * contract: latest/oldest travel unresolved and are resolved by each peer. */
120
+ export declare function remotePoolArgs(f: BrowserFilter, fixedFilters: boolean): string[];
100
121
  /**
101
122
  * A live session's stable row key: its session id when the agent reported one,
102
123
  * else a per-machine handle so an id-less live session (a just-booted harness, a
@@ -126,7 +147,7 @@ export declare function liveSessionToMeta(a: ActiveSession, self: string): Sessi
126
147
  * `--active --json` listed it. Live sessions the pool lacks are appended as their
127
148
  * own rows, then the whole set is grouped local-machine-first.
128
149
  */
129
- export declare function mergeLiveIntoPool(rows: SessionMeta[], live: Map<string, ActiveSession>, self: string): SessionMeta[];
150
+ export declare function mergeLiveIntoPool(rows: SessionMeta[], live: Map<string, ActiveSession>, self: string, includeUnindexed?: boolean): SessionMeta[];
130
151
  /**
131
152
  * Whether to render the host-program column. It belongs to the running view
132
153
  * only, so this gates on the FILTER — not on the live index being populated.
@@ -135,6 +156,20 @@ export declare function mergeLiveIntoPool(rows: SessionMeta[], live: Map<string,
135
156
  * listing that has no live rows to explain it.
136
157
  */
137
158
  export declare function shouldShowHostColumn(f: BrowserFilter, live: Map<string, ActiveSession> | null, rows: SessionMeta[]): boolean;
159
+ /** Apply the cheap in-memory filters (agent / device / project / running / favorites). */
160
+ export declare function applyFilters(rows: SessionMeta[], live: Map<string, ActiveSession>, f: BrowserFilter, self: string, favorites: Set<string>): SessionMeta[];
161
+ /** One-shot form of the browser's canonical candidate pipeline. Focus uses this
162
+ * instead of maintaining a second discovery/filter implementation. */
163
+ export declare function collectSessionCandidates(initial: Partial<BrowserFilter>, opts?: {
164
+ local?: boolean;
165
+ hosts?: string[];
166
+ includeLive?: boolean;
167
+ }): Promise<{
168
+ sessions: SessionMeta[];
169
+ liveById: Map<string, ActiveSession>;
170
+ self: string;
171
+ unreachable: string[];
172
+ }>;
138
173
  /**
139
174
  * Launch the interactive session browser. `initial` seeds the filter (e.g.
140
175
  * `{ running: true }` for `--active`). Resolves after the user resumes a session
@@ -16,11 +16,13 @@ import { dynamicPicker } from '../lib/picker.js';
16
16
  import { isSessionTrackedAgent } from '../lib/session/types.js';
17
17
  import { discoverSessions } from '../lib/session/discover.js';
18
18
  import { gatherRemoteList } from '../lib/session/remote-list.js';
19
+ import { resolveVersionAliasLoose } from '../lib/versions.js';
20
+ import { AGENTS } from '../lib/agents.js';
19
21
  import { enrichTeamOrigins, safeTeamText } from '../lib/session/team-filter.js';
20
22
  import { listFavorites, toggleFavorite } from '../lib/session/favorites.js';
21
23
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
22
24
  import { buildPreview } from './sessions-picker.js';
23
- import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, formatLiveStatusHeadline, } from './sessions.js';
25
+ import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, matchesTeam, formatLiveStatusHeadline, isRunningLiveSession, matchesLiveStatus, parseAgentFilter, } from './sessions.js';
24
26
  /**
25
27
  * Complete a seed into the filter the picker actually runs on.
26
28
  *
@@ -44,6 +46,15 @@ export function buildInitialFilter(initial) {
44
46
  team: initial.team,
45
47
  projectScope: initial.projectScope ?? 'repo',
46
48
  window: 'window' in initial ? initial.window : '30d',
49
+ statuses: initial.statuses ?? [],
50
+ project: initial.project,
51
+ until: initial.until,
52
+ routine: initial.routine ?? false,
53
+ skill: initial.skill,
54
+ plugin: initial.plugin,
55
+ limit: initial.limit ?? 500,
56
+ unmanaged: initial.unmanaged ?? false,
57
+ sort: initial.sort ?? 'timestamp',
47
58
  };
48
59
  }
49
60
  /** Cache key for the transcript pool: every filter that changes what is FETCHED
@@ -54,10 +65,16 @@ function poolCacheKey(f) {
54
65
  // fetches the same deep pool and is then narrowed in memory. Keying on the name
55
66
  // would make `t` the one hotkey that re-fans-out the fleet on every step of the
56
67
  // cycle, at up to REMOTE_TIMEOUT_MS per unreachable peer.
57
- return `${f.window ?? 'all'}|${f.teams}|${f.team ? 'team' : ''}`;
68
+ const versionScoped = f.agent?.includes('@') ? f.agent : '';
69
+ return [
70
+ f.window ?? 'all', f.until ?? '', f.teams, f.team ? 'team' : '', versionScoped,
71
+ f.project ?? '', f.routine, f.skill ?? '', f.plugin ?? '', f.limit, f.unmanaged, f.sort,
72
+ ].join('|');
58
73
  }
59
74
  /** Pool size when a team filter is active; one team's rows can sit anywhere. */
60
75
  const WHOLE_TEAM_POOL_LIMIT = 5000;
76
+ /** A cold peer may need to resolve an installed-version alias and scan its index. */
77
+ const BROWSER_PEER_TIMEOUT_MS = 30_000;
61
78
  /** Ordered window cycle for the `W` key. `undefined` = all time. */
62
79
  const WINDOW_CYCLE = [undefined, '1d', '7d', '30d'];
63
80
  /** Return the next value in `[undefined, ...options]`, wrapping. */
@@ -105,8 +122,10 @@ export function sessionMatchesQuery(s, query) {
105
122
  */
106
123
  export function browserFilterToArgv(f, query = '') {
107
124
  const a = ['sessions'];
108
- if (f.running)
125
+ if (f.statuses.length === 0 && f.running)
109
126
  a.push('--active');
127
+ for (const status of f.statuses)
128
+ a.push(`--${status === 'orphaned' ? 'orphan' : status}`);
110
129
  if (f.teams)
111
130
  a.push('--teams');
112
131
  if (f.favorites)
@@ -121,6 +140,22 @@ export function browserFilterToArgv(f, query = '') {
121
140
  a.push('--all');
122
141
  if (f.window)
123
142
  a.push('--since', f.window);
143
+ if (f.until)
144
+ a.push('--until', f.until);
145
+ if (f.project)
146
+ a.push('--project', f.project);
147
+ if (f.routine)
148
+ a.push('--routine');
149
+ if (f.skill)
150
+ a.push('--skill', f.skill);
151
+ if (f.plugin)
152
+ a.push('--plugin', f.plugin);
153
+ if (f.unmanaged)
154
+ a.push('--unmanaged');
155
+ if (f.sort !== 'timestamp')
156
+ a.push('--sort', f.sort === 'cost' ? 'cost' : 'duration');
157
+ if (f.limit !== 500)
158
+ a.push('--limit', String(f.limit));
124
159
  const q = query.trim();
125
160
  if (q)
126
161
  a.push(JSON.stringify(q));
@@ -151,6 +186,7 @@ export function activeBrowserSeed(opts) {
151
186
  // --all widens the window to all-time (project is already 'all' here);
152
187
  // --since still overrides.
153
188
  window: opts.since ?? (opts.all ? undefined : '30d'),
189
+ statuses: [],
154
190
  };
155
191
  }
156
192
  /**
@@ -184,6 +220,7 @@ export function bareBrowserSeed(opts) {
184
220
  // --all maxes every non-status filter: all dirs AND all-time. --since wins.
185
221
  projectScope: opts.all || scoped || wholeTeam ? 'all' : 'repo',
186
222
  window: opts.since ?? (opts.all || wholeTeam ? undefined : '30d'),
223
+ statuses: [],
187
224
  };
188
225
  }
189
226
  /** Copy text to the OS clipboard (best-effort; silently no-ops if unavailable). */
@@ -214,8 +251,13 @@ function copyToClipboard(text) {
214
251
  * `hosts` is the explicit `--host`/`--device` scope (if any): it restricts which
215
252
  * peers are dialed and whether local is included, honoring the flag's "scope,
216
253
  * not add" contract instead of always sweeping the whole fleet. */
217
- async function fetchRawPool(f, self, local, hosts) {
254
+ async function fetchRawPool(f, self, local, hosts, fixedFilters = false) {
218
255
  const since = f.window;
256
+ const selectedAgent = f.agent ? parseAgentFilter(f.agent) : {};
257
+ const parsedAgent = fixedFilters ? selectedAgent : {};
258
+ const localAgentVersion = parsedAgent.agent && parsedAgent.agent in AGENTS
259
+ ? resolveVersionAliasLoose(parsedAgent.agent, parsedAgent.version)
260
+ : parsedAgent.version;
219
261
  let unreachable = [];
220
262
  // Local pool: wide (every directory) — device/agent/project are applied in
221
263
  // memory so a hotkey toggle is instant and doesn't re-hit the disk. Skipped
@@ -223,16 +265,33 @@ async function fetchRawPool(f, self, local, hosts) {
223
265
  let rows = shouldIncludeLocal(hosts, self)
224
266
  ? await discoverSessions({
225
267
  all: true,
268
+ agent: parsedAgent.agent,
269
+ version: localAgentVersion,
270
+ includeUnmanaged: f.unmanaged,
226
271
  cwd: process.cwd(),
227
272
  since,
273
+ until: f.until,
274
+ project: f.project,
275
+ origin: f.routine ? 'routine' : undefined,
276
+ skill: f.skill,
277
+ plugin: f.plugin,
228
278
  excludeTeamOrigin: !f.teams,
229
279
  // A team filter reaches back past the usual browse window, so the pool it
230
280
  // draws from has to as well — otherwise the newest 500 rows decide which
231
281
  // teams exist.
232
- limit: f.team ? WHOLE_TEAM_POOL_LIMIT : 500,
233
- sortBy: 'timestamp',
282
+ limit: f.team ? WHOLE_TEAM_POOL_LIMIT : f.limit,
283
+ sortBy: f.sort,
234
284
  })
235
285
  : [];
286
+ // A synced mirror knows which VERSION produced a session, but it cannot know
287
+ // which version is currently installed as latest/oldest on the origin device.
288
+ // For an alias selector, keep only this device's rows here and accept peer
289
+ // rows only from the live query below, where that peer resolves its own
290
+ // inventory. If the peer is unavailable, an empty/partial result is honest;
291
+ // widening to cached sessions from every version is not.
292
+ if (selectedAgent.version === 'latest' || selectedAgent.version === 'oldest') {
293
+ rows = rows.filter((row) => (row.machine ?? self) === self);
294
+ }
236
295
  // Fleet: fold in peers' own indexes over SSH (no sync), same as the flag path.
237
296
  // Skipped under --local. An explicit --host/--device scopes exactly which peers
238
297
  // are dialed (undefined = sweep every online device). Best-effort — a fan-out
@@ -251,12 +310,10 @@ async function fetchRawPool(f, self, local, hosts) {
251
310
  // the same bug one hop out. A numeric --limit is forwarded rather than
252
311
  // --in-team itself, which a peer on an older build would reject as an
253
312
  // unknown option and fail the whole fan-out.
254
- const forwarded = ['sessions', '--all', '--json', '--limit', String(f.team ? WHOLE_TEAM_POOL_LIMIT : 500)];
255
- if (since)
256
- forwarded.push('--since', since);
257
- if (f.teams)
258
- forwarded.push('--teams');
259
- const remoteResult = await gatherRemoteList(forwarded, remoteHosts);
313
+ const forwarded = remotePoolArgs(f, fixedFilters);
314
+ const remoteResult = await gatherRemoteList(forwarded, remoteHosts, {
315
+ timeoutMs: BROWSER_PEER_TIMEOUT_MS,
316
+ });
260
317
  unreachable = remoteResult.unreachable;
261
318
  if (remoteResult.sessions.length > 0)
262
319
  rows = mergeLocalFirst([...rows, ...remoteResult.sessions], self);
@@ -272,6 +329,32 @@ async function fetchRawPool(f, self, local, hosts) {
272
329
  rows = enrichTeamOrigins(rows);
273
330
  return { key: poolCacheKey(f), rows, unreachable };
274
331
  }
332
+ /** Build the static peer query. Keeping it pure pins the per-device alias
333
+ * contract: latest/oldest travel unresolved and are resolved by each peer. */
334
+ export function remotePoolArgs(f, fixedFilters) {
335
+ const forwarded = ['sessions', '--all', '--json', '--limit', String(f.team ? WHOLE_TEAM_POOL_LIMIT : f.limit)];
336
+ if (f.window)
337
+ forwarded.push('--since', f.window);
338
+ if (f.until)
339
+ forwarded.push('--until', f.until);
340
+ if (f.project)
341
+ forwarded.push('--project', f.project);
342
+ if (f.routine)
343
+ forwarded.push('--routine');
344
+ if (f.skill)
345
+ forwarded.push('--skill', f.skill);
346
+ if (f.plugin)
347
+ forwarded.push('--plugin', f.plugin);
348
+ if (f.unmanaged)
349
+ forwarded.push('--unmanaged');
350
+ if (f.sort !== 'timestamp')
351
+ forwarded.push('--sort', f.sort === 'cost' ? 'cost' : 'duration');
352
+ if (f.agent && (fixedFilters || f.agent.includes('@')))
353
+ forwarded.push('--agent', f.agent);
354
+ if (f.teams)
355
+ forwarded.push('--teams');
356
+ return forwarded;
357
+ }
275
358
  /**
276
359
  * A live session's stable row key: its session id when the agent reported one,
277
360
  * else a per-machine handle so an id-less live session (a just-booted harness, a
@@ -344,7 +427,12 @@ export function liveSessionToMeta(a, self) {
344
427
  * `--active --json` listed it. Live sessions the pool lacks are appended as their
345
428
  * own rows, then the whole set is grouped local-machine-first.
346
429
  */
347
- export function mergeLiveIntoPool(rows, live, self) {
430
+ export function mergeLiveIntoPool(rows, live, self, includeUnindexed = true) {
431
+ // A latest/oldest selector is resolved by the peer's transcript query. A live
432
+ // row absent from that result has no proof it belongs to the resolved version,
433
+ // so callers using an alias fail closed instead of widening the selector.
434
+ if (!includeUnindexed)
435
+ return rows;
348
436
  const known = new Set(rows.map((r) => r.id));
349
437
  const extra = [];
350
438
  for (const [key, a] of live) {
@@ -366,27 +454,82 @@ export function shouldShowHostColumn(f, live, rows) {
366
454
  return rows.some((r) => liveHostLabel(live.get(r.id)) !== '');
367
455
  }
368
456
  /** Apply the cheap in-memory filters (agent / device / project / running / favorites). */
369
- function applyFilters(rows, live, f, self, favorites) {
457
+ export function applyFilters(rows, live, f, self, favorites) {
370
458
  let out = rows;
371
459
  // A projected live row is keyed by pid/task when it has no session id, and a
372
460
  // favorite is always keyed by a real session id — so an id-less row can never
373
461
  // be favorited and correctly drops out here.
374
462
  if (f.favorites)
375
463
  out = out.filter((r) => favorites.has(r.id));
376
- if (f.agent)
377
- out = out.filter((r) => r.agent === f.agent);
464
+ if (f.agent) {
465
+ const { agent, version: rawVersion } = parseAgentFilter(f.agent);
466
+ const localVersion = agent && agent in AGENTS
467
+ ? resolveVersionAliasLoose(agent, rawVersion)
468
+ : rawVersion;
469
+ const peerResolvedAlias = rawVersion === 'latest' || rawVersion === 'oldest';
470
+ out = out.filter((r) => {
471
+ if (r.agent !== agent)
472
+ return false;
473
+ // Only a LIVE peer result may claim it resolved latest/oldest against that
474
+ // device's installed inventory. A synced mirror is historical data, not a
475
+ // version-inventory oracle.
476
+ if (peerResolvedAlias && (r.machine ?? self) !== self)
477
+ return r._remote === true;
478
+ if (!localVersion)
479
+ return true;
480
+ return r.version === localVersion;
481
+ });
482
+ }
378
483
  if (f.device)
379
484
  out = out.filter((r) => (r.machine ?? self) === f.device);
380
485
  if (f.team)
381
486
  out = out.filter((r) => matchesTeam(r, f.team));
487
+ if (f.project) {
488
+ const q = f.project.toLowerCase();
489
+ out = out.filter((r) => (r.project ?? '').toLowerCase().includes(q) || (r.cwd ?? '').toLowerCase().includes(q));
490
+ }
382
491
  if (f.projectScope === 'repo') {
383
492
  const cwd = process.cwd();
384
493
  out = out.filter((r) => !!r.cwd && (r.cwd === cwd || r.cwd.startsWith(cwd + '/')));
385
494
  }
386
- if (f.running)
387
- out = out.filter((r) => live.has(r.id));
495
+ // The live registry retains dead rows so explicit --closed/--crashed filters
496
+ // can recover them. Bare --active means currently active; a lifecycle filter
497
+ // intentionally replaces that default predicate with its exact status union.
498
+ if (f.running && f.statuses.length === 0) {
499
+ out = out.filter((r) => {
500
+ const active = live.get(r.id);
501
+ return !!active && isRunningLiveSession(active);
502
+ });
503
+ }
504
+ if (f.statuses.length > 0) {
505
+ out = out.filter((r) => {
506
+ const active = live.get(r.id);
507
+ return !!active && f.statuses.some((status) => matchesLiveStatus(active, status));
508
+ });
509
+ }
388
510
  return out;
389
511
  }
512
+ /** One-shot form of the browser's canonical candidate pipeline. Focus uses this
513
+ * instead of maintaining a second discovery/filter implementation. */
514
+ export async function collectSessionCandidates(initial, opts = {}) {
515
+ const self = machineId();
516
+ const filter = buildInitialFilter(initial);
517
+ if (filter.statuses.length > 0)
518
+ filter.running = true;
519
+ const hosts = opts.hosts && opts.hosts.length > 0 ? opts.hosts : undefined;
520
+ const pool = await fetchRawPool(filter, self, opts.local ?? false, hosts, true);
521
+ let liveById = new Map();
522
+ if (filter.running || opts.includeLive) {
523
+ const { sessions } = await gatherActiveSessions({ local: opts.local ?? false, hosts });
524
+ liveById = indexLiveRows(sessions, self);
525
+ }
526
+ const includeUnindexedLive = !filter.agent?.match(/@(latest|oldest)$/);
527
+ const rows = filter.running || opts.includeLive
528
+ ? mergeLiveIntoPool(pool.rows, liveById, self, includeUnindexedLive)
529
+ : pool.rows;
530
+ const sessions = applyFilters(rows, liveById, filter, self, listFavorites());
531
+ return { sessions, liveById, self, unreachable: pool.unreachable };
532
+ }
390
533
  /** Derive the SSH-launch origin tag for a picker row from the live index. Set
391
534
  * only when the live session's provenance is ssh transport; `device` is the
392
535
  * resolved origin device (absent → the row shows a bare `ssh`). Rows without a
@@ -492,7 +635,10 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
492
635
  liveCache = live;
493
636
  // Live sessions the transcript pool lacks become rows of their own, so the
494
637
  // running view lists every active session, not just the ones already indexed.
495
- const rows = f.running && live ? mergeLiveIntoPool(pool.rows, live, self) : pool.rows;
638
+ const includeUnindexedLive = !f.agent?.match(/@(latest|oldest)$/);
639
+ const rows = f.running && live
640
+ ? mergeLiveIntoPool(pool.rows, live, self, includeUnindexedLive)
641
+ : pool.rows;
496
642
  agentsInPool = distinct(rows.map((r) => r.agent));
497
643
  devicesInPool = distinct(rows.map((r) => r.machine ?? self));
498
644
  // Both ends of the lineage seed the cycle: teams a row spawned, and teams a
@@ -1,4 +1,5 @@
1
1
  import type { Command } from 'commander';
2
+ import type { SessionMeta } from '../lib/session/types.js';
2
3
  import { type Backend, type EngineContext, type Packing } from '../lib/terminal/index.js';
3
4
  /** Opening more than this many live sessions at once asks for confirmation first. */
4
5
  export declare const CONFIRM_THRESHOLD = 5;
@@ -30,6 +31,7 @@ export declare function resumeSelectorInPlace(selector: string): Promise<void>;
30
31
  export declare function dispatchSessionLifecycleInPlace(selector: string, hosts?: string[]): Promise<void>;
31
32
  export declare function buildSessionLifecycleArgs(selector: string, hosts?: string[]): string[];
32
33
  export declare function resolveResumePacking(options: Pick<ResumeOptions, 'splits'>): Packing;
34
+ export declare function resumeHostMismatch(session: Pick<SessionMeta, 'shortId' | 'machine'>, requestedHost: string, self?: string): string | null;
33
35
  /**
34
36
  * Decide which backend to launch into. Returns a concrete backend, `'inplace'`
35
37
  * (resume in the current process — no GUI/tmux available), or `'cancel'` (the