@phnx-labs/agents-cli 1.20.33 → 1.20.35

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 (159) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +32 -2
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/computer.d.ts +23 -0
  6. package/dist/commands/computer.js +45 -3
  7. package/dist/commands/cost.js +17 -12
  8. package/dist/commands/doctor.d.ts +11 -0
  9. package/dist/commands/doctor.js +108 -14
  10. package/dist/commands/events.d.ts +16 -0
  11. package/dist/commands/events.js +173 -0
  12. package/dist/commands/exec.js +6 -1
  13. package/dist/commands/hosts.js +28 -24
  14. package/dist/commands/import.js +1 -1
  15. package/dist/commands/inspect.d.ts +2 -0
  16. package/dist/commands/inspect.js +54 -7
  17. package/dist/commands/logs.d.ts +17 -0
  18. package/dist/commands/logs.js +139 -0
  19. package/dist/commands/models.d.ts +3 -0
  20. package/dist/commands/models.js +27 -8
  21. package/dist/commands/permissions.js +9 -2
  22. package/dist/commands/repo.d.ts +34 -0
  23. package/dist/commands/repo.js +243 -65
  24. package/dist/commands/resource-view.d.ts +20 -0
  25. package/dist/commands/resource-view.js +90 -28
  26. package/dist/commands/rules.js +1 -1
  27. package/dist/commands/secrets-migrate.js +23 -11
  28. package/dist/commands/secrets.d.ts +20 -0
  29. package/dist/commands/secrets.js +93 -17
  30. package/dist/commands/sessions-resume.d.ts +2 -0
  31. package/dist/commands/sessions-resume.js +209 -0
  32. package/dist/commands/sessions-tail.d.ts +10 -0
  33. package/dist/commands/sessions-tail.js +11 -0
  34. package/dist/commands/sessions.d.ts +73 -1
  35. package/dist/commands/sessions.js +273 -53
  36. package/dist/commands/status.d.ts +12 -0
  37. package/dist/commands/status.js +81 -0
  38. package/dist/commands/teams.js +70 -6
  39. package/dist/commands/versions.js +2 -1
  40. package/dist/commands/view.d.ts +42 -0
  41. package/dist/commands/view.js +213 -83
  42. package/dist/commands/wallet.d.ts +6 -0
  43. package/dist/commands/wallet.js +22 -5
  44. package/dist/index.js +61 -32
  45. package/dist/lib/acp/harnesses.d.ts +1 -1
  46. package/dist/lib/acp/harnesses.js +2 -2
  47. package/dist/lib/agents.d.ts +12 -0
  48. package/dist/lib/agents.js +145 -41
  49. package/dist/lib/browser/cdp.js +5 -1
  50. package/dist/lib/browser/chrome.js +20 -0
  51. package/dist/lib/browser/drivers/ssh.d.ts +27 -0
  52. package/dist/lib/browser/drivers/ssh.js +94 -19
  53. package/dist/lib/browser/ipc.d.ts +3 -0
  54. package/dist/lib/browser/ipc.js +13 -9
  55. package/dist/lib/browser/service.js +71 -15
  56. package/dist/lib/daemon.d.ts +35 -0
  57. package/dist/lib/daemon.js +33 -5
  58. package/dist/lib/devices/connect.d.ts +3 -2
  59. package/dist/lib/devices/connect.js +5 -3
  60. package/dist/lib/doctor-diff.js +29 -2
  61. package/dist/lib/drift-sync.d.ts +43 -0
  62. package/dist/lib/drift-sync.js +179 -0
  63. package/dist/lib/events.d.ts +9 -2
  64. package/dist/lib/events.js +70 -11
  65. package/dist/lib/exec.d.ts +15 -0
  66. package/dist/lib/exec.js +43 -11
  67. package/dist/lib/hooks.js +8 -2
  68. package/dist/lib/hosts/dispatch.js +6 -7
  69. package/dist/lib/hosts/logs.d.ts +16 -0
  70. package/dist/lib/hosts/logs.js +45 -0
  71. package/dist/lib/hosts/progress.d.ts +66 -0
  72. package/dist/lib/hosts/progress.js +125 -17
  73. package/dist/lib/hosts/ready.d.ts +23 -2
  74. package/dist/lib/hosts/ready.js +35 -13
  75. package/dist/lib/hosts/reconcile.d.ts +53 -0
  76. package/dist/lib/hosts/reconcile.js +81 -0
  77. package/dist/lib/hosts/tasks.d.ts +8 -0
  78. package/dist/lib/hosts/tasks.js +14 -0
  79. package/dist/lib/permissions.d.ts +4 -0
  80. package/dist/lib/permissions.js +35 -0
  81. package/dist/lib/picker.d.ts +24 -0
  82. package/dist/lib/picker.js +124 -0
  83. package/dist/lib/platform/winpath.d.ts +31 -2
  84. package/dist/lib/platform/winpath.js +133 -24
  85. package/dist/lib/plugin-marketplace.d.ts +30 -0
  86. package/dist/lib/plugin-marketplace.js +215 -2
  87. package/dist/lib/plugins.d.ts +5 -0
  88. package/dist/lib/plugins.js +45 -4
  89. package/dist/lib/pwsh.d.ts +11 -0
  90. package/dist/lib/pwsh.js +13 -0
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/agent.d.ts +42 -1
  94. package/dist/lib/secrets/agent.js +89 -11
  95. package/dist/lib/secrets/bundles.js +40 -9
  96. package/dist/lib/secrets/filestore.js +31 -1
  97. package/dist/lib/secrets/index.d.ts +33 -1
  98. package/dist/lib/secrets/index.js +90 -9
  99. package/dist/lib/secrets/windows.d.ts +74 -0
  100. package/dist/lib/secrets/windows.js +440 -0
  101. package/dist/lib/session/active.d.ts +7 -0
  102. package/dist/lib/session/active.js +11 -3
  103. package/dist/lib/session/discover.js +94 -0
  104. package/dist/lib/session/parse.d.ts +6 -0
  105. package/dist/lib/session/parse.js +283 -2
  106. package/dist/lib/session/pid-registry.d.ts +21 -0
  107. package/dist/lib/session/pid-registry.js +80 -0
  108. package/dist/lib/session/remote-active.d.ts +26 -0
  109. package/dist/lib/session/remote-active.js +141 -0
  110. package/dist/lib/session/remote.js +2 -6
  111. package/dist/lib/session/render.js +1 -1
  112. package/dist/lib/session/sync/config.d.ts +8 -0
  113. package/dist/lib/session/sync/config.js +11 -2
  114. package/dist/lib/session/types.d.ts +1 -1
  115. package/dist/lib/session/types.js +1 -1
  116. package/dist/lib/shims.d.ts +30 -2
  117. package/dist/lib/shims.js +107 -50
  118. package/dist/lib/ssh-exec.d.ts +26 -3
  119. package/dist/lib/ssh-exec.js +45 -3
  120. package/dist/lib/ssh-tunnel.d.ts +24 -5
  121. package/dist/lib/ssh-tunnel.js +60 -62
  122. package/dist/lib/staleness/writers/hooks.js +1 -1
  123. package/dist/lib/startup/command-registry.d.ts +3 -0
  124. package/dist/lib/startup/command-registry.js +6 -0
  125. package/dist/lib/sync-status.d.ts +102 -0
  126. package/dist/lib/sync-status.js +135 -0
  127. package/dist/lib/teams/agents.d.ts +24 -0
  128. package/dist/lib/teams/agents.js +30 -1
  129. package/dist/lib/teams/registry.js +25 -9
  130. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  131. package/dist/lib/terminal/backends/ghostty.js +69 -0
  132. package/dist/lib/terminal/backends/index.d.ts +18 -0
  133. package/dist/lib/terminal/backends/index.js +29 -0
  134. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  135. package/dist/lib/terminal/backends/iterm.js +62 -0
  136. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  137. package/dist/lib/terminal/backends/tmux.js +23 -0
  138. package/dist/lib/terminal/engine.d.ts +39 -0
  139. package/dist/lib/terminal/engine.js +54 -0
  140. package/dist/lib/terminal/index.d.ts +14 -0
  141. package/dist/lib/terminal/index.js +5 -0
  142. package/dist/lib/terminal/policy.d.ts +11 -0
  143. package/dist/lib/terminal/policy.js +11 -0
  144. package/dist/lib/terminal/quote.d.ts +11 -0
  145. package/dist/lib/terminal/quote.js +13 -0
  146. package/dist/lib/terminal/shell.d.ts +6 -0
  147. package/dist/lib/terminal/shell.js +23 -0
  148. package/dist/lib/terminal/transport.d.ts +15 -0
  149. package/dist/lib/terminal/transport.js +40 -0
  150. package/dist/lib/terminal/types.d.ts +59 -0
  151. package/dist/lib/terminal/types.js +13 -0
  152. package/dist/lib/types.d.ts +27 -1
  153. package/dist/lib/usage.d.ts +30 -0
  154. package/dist/lib/usage.js +159 -2
  155. package/dist/lib/versions.d.ts +7 -3
  156. package/dist/lib/versions.js +10 -5
  157. package/dist/lib/whats-new.d.ts +9 -0
  158. package/dist/lib/whats-new.js +35 -0
  159. package/package.json +1 -1
@@ -18,3 +18,23 @@ export { SSH_TARGET_RE, assertValidSshTarget };
18
18
  export declare function bundleEnvToDotenv(env: Record<string, string>): string;
19
19
  /** Register the `agents secrets` command tree. */
20
20
  export declare function registerSecretsCommands(program: Command): void;
21
+ /**
22
+ * Quote one argument for a Windows `cmd.exe` command line, as built by Node's
23
+ * `spawn(..., { shell: true })` on win32 (`agents secrets exec`). cmd.exe does
24
+ * NO quoting of its own, so an unquoted arg with a space is split into several
25
+ * args, and a cmd metacharacter (`&|<>()^`) would be interpreted by the shell.
26
+ * We wrap any arg with whitespace, a quote, or a metacharacter in double quotes
27
+ * and escape embedded quotes / trailing backslashes per the CommandLineToArgvW
28
+ * rules, so the *child's* argv parse reconstructs the original argument.
29
+ *
30
+ * CAVEAT: cmd.exe expands `%VAR%` (always) and `!VAR!` (under delayed expansion)
31
+ * BEFORE argv parsing, and double-quoting does NOT suppress `%`/`!` (the
32
+ * "BatBadBut" / CVE-2024-1874 class). We deliberately do not escape `%`/`!`:
33
+ * `agents secrets exec` runs a caller-supplied command against a bundle the
34
+ * caller owns, so caller-controlled `%`/`!` is not a privilege boundary. If that
35
+ * ever changes (exec'ing an untrusted command line), route through a shell that
36
+ * disables expansion rather than relying on this quoter. An empty arg becomes
37
+ * `""`. Exported for tests. No-ops on non-Windows (the caller only invokes it
38
+ * under `process.platform === 'win32'`).
39
+ */
40
+ export declare function quoteWin32ExecArg(arg: string): string;
@@ -7,9 +7,9 @@
7
7
  */
8
8
  import { Option } from 'commander';
9
9
  import chalk from 'chalk';
10
+ import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
10
11
  import * as fs from 'fs';
11
- import { spawnSync } from 'child_process';
12
- import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
12
+ import { SSH_TARGET_RE, assertValidSshTarget, sshExec } from '../lib/ssh-exec.js';
13
13
  import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, resolveSshTarget, } from '../lib/secrets/remote.js';
14
14
  import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
15
15
  import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
@@ -270,8 +270,10 @@ function renderPolicyCol(b, held) {
270
270
  const exp = held?.get(b.name);
271
271
  return exp ? chalk.green(`daily · ${compactRemaining(exp)} left`) : chalk.gray('daily');
272
272
  }
273
+ /** Below this width the fixed date columns no longer fit; `list` uses cards. */
274
+ const SECRETS_WIDE = 96;
273
275
  /** Format a single bundle as a table row for the `secrets list` output. */
274
- function renderBundleRow(b, held) {
276
+ function renderBundleRow(b, held, cols = terminalWidth()) {
275
277
  const entries = describeBundle(b);
276
278
  const keys = entries.length;
277
279
  const expiringCount = countExpiringSoon(b.meta);
@@ -296,10 +298,26 @@ function renderBundleRow(b, held) {
296
298
  `${padVisible(used, 7)}`;
297
299
  // Mark file-backed bundles so `list` distinguishes them from keychain ones.
298
300
  const tag = b.backend === 'file' ? chalk.magenta('[file] ') : '';
299
- const desc = b.description ? chalk.gray(safePrint(b.description)) : '';
301
+ // Cap the free-form description to whatever space is left on the line so a long
302
+ // description can't push the row to 200+ chars and wrap into a smear.
303
+ const budget = cols - stringWidth(head) - 1 - stringWidth(tag);
304
+ const desc = b.description && budget > 3
305
+ ? chalk.gray(truncateToWidth(safePrint(b.description), budget))
306
+ : '';
300
307
  const trailer = `${tag}${desc}`.trimEnd();
301
308
  return trailer ? `${head} ${trailer}` : head.trimEnd();
302
309
  }
310
+ /** Narrow-terminal card: name + compact meta on one line, description below. */
311
+ function renderBundleCard(b, held, cols) {
312
+ const keys = describeBundle(b).length;
313
+ const used = b.last_used ? relativeAge(b.last_used) : (b.created_at ? 'never' : '?');
314
+ const tag = b.backend === 'file' ? chalk.magenta(' [file]') : '';
315
+ const meta = chalk.gray(`${keys} key${keys === 1 ? '' : 's'} · `) + renderPolicyCol(b, held) + chalk.gray(` · used ${used}`);
316
+ const line1 = `${chalk.cyan(b.name)} ${meta}${tag}`;
317
+ if (!b.description)
318
+ return line1;
319
+ return `${line1}\n ${chalk.gray(truncateToWidth(safePrint(b.description), cols - 4))}`;
320
+ }
303
321
  /** Colorize a variable source kind (literal, keychain, env, file, exec). */
304
322
  function kindLabel(kind) {
305
323
  switch (kind) {
@@ -519,9 +537,17 @@ export function registerSecretsCommands(program) {
519
537
  /* broker not running — render policy without the countdown */
520
538
  }
521
539
  }
522
- console.log(chalk.bold(`${'NAME'.padEnd(20)} ${'KEYS'.padEnd(5)} ${'POLICY'.padEnd(18)} ${'EXPIRING'.padEnd(9)} ${'CREATED'.padEnd(9)} ${'UPDATED'.padEnd(9)} ${'USED'.padEnd(7)} DESCRIPTION`));
523
- for (const b of bundles) {
524
- console.log(renderBundleRow(b, held));
540
+ const cols = terminalWidth();
541
+ if (cols >= SECRETS_WIDE) {
542
+ console.log(chalk.bold(`${'NAME'.padEnd(20)} ${'KEYS'.padEnd(5)} ${'POLICY'.padEnd(18)} ${'EXPIRING'.padEnd(9)} ${'CREATED'.padEnd(9)} ${'UPDATED'.padEnd(9)} ${'USED'.padEnd(7)} DESCRIPTION`));
543
+ for (const b of bundles) {
544
+ console.log(renderBundleRow(b, held, cols));
545
+ }
546
+ }
547
+ else {
548
+ for (const b of bundles) {
549
+ console.log(renderBundleCard(b, held, cols));
550
+ }
525
551
  }
526
552
  });
527
553
  cmd
@@ -1218,20 +1244,18 @@ Examples:
1218
1244
  const remoteCmd = `bash -lc ${shellQuote(remoteAgents)}`;
1219
1245
  let failures = 0;
1220
1246
  for (const host of hosts) {
1221
- const res = spawnSync('ssh', ['-o', 'BatchMode=yes', host, remoteCmd], {
1222
- input,
1223
- stdio: ['pipe', 'pipe', 'pipe'],
1224
- encoding: 'utf-8',
1225
- });
1226
- if (res.error) {
1247
+ // Routed through the shared ssh engine: full hardened options
1248
+ // (BatchMode, ConnectTimeout, keepalive) + control-socket reuse.
1249
+ const res = sshExec(host, remoteCmd, { input });
1250
+ if (res.code === null) {
1227
1251
  failures++;
1228
- console.error(chalk.red(`${host}: ${res.error.message}`));
1252
+ console.error(chalk.red(`${host}: ${res.stderr.trim() || (res.timedOut ? 'ssh timed out' : 'ssh failed')}`));
1229
1253
  continue;
1230
1254
  }
1231
- if (res.status !== 0) {
1255
+ if (res.code !== 0) {
1232
1256
  failures++;
1233
1257
  const msg = (res.stderr || res.stdout || '').trim();
1234
- console.error(chalk.red(`${host}: remote import failed (exit ${res.status ?? 'signal'})${msg ? `: ${msg}` : ''}`));
1258
+ console.error(chalk.red(`${host}: remote import failed (exit ${res.code})${msg ? `: ${msg}` : ''}`));
1235
1259
  continue;
1236
1260
  }
1237
1261
  const remoteMsg = (res.stdout || '').trim().split('\n').map((l) => l.trim()).filter(Boolean).pop();
@@ -1325,8 +1349,18 @@ Examples:
1325
1349
  secretEnv = readAndResolveBundleEnv(bundleName, { caller: `command ${cmd}` }).env;
1326
1350
  }
1327
1351
  const { spawn } = await import('child_process');
1328
- const proc = spawn(cmd, args, {
1352
+ // On Windows, spawn without a shell ENOENTs for `.cmd`/`.bat` launchers
1353
+ // (npm, yarn, most JS CLIs) and shell built-ins, so we set shell:true.
1354
+ // With shell:true Node hands cmd.exe a single command line with NO quoting
1355
+ // of its own, so args containing spaces or cmd metacharacters must be
1356
+ // quoted here (quoteWin32ExecArg) or they'd be split. See that helper for
1357
+ // the cmd.exe %VAR%/!VAR! expansion caveat.
1358
+ const useShell = process.platform === 'win32';
1359
+ const spawnCmd = useShell ? quoteWin32ExecArg(cmd) : cmd;
1360
+ const spawnArgs = useShell ? args.map(quoteWin32ExecArg) : args;
1361
+ const proc = spawn(spawnCmd, spawnArgs, {
1329
1362
  stdio: 'inherit',
1363
+ shell: useShell,
1330
1364
  env: { ...process.env, ...secretEnv },
1331
1365
  });
1332
1366
  proc.on('close', (code) => process.exit(code ?? 0));
@@ -1619,6 +1653,48 @@ function humanRemaining(expiresAt) {
1619
1653
  const days = Math.round(hours / 24);
1620
1654
  return `locks in ${days} day${days === 1 ? '' : 's'}`;
1621
1655
  }
1656
+ /**
1657
+ * Quote one argument for a Windows `cmd.exe` command line, as built by Node's
1658
+ * `spawn(..., { shell: true })` on win32 (`agents secrets exec`). cmd.exe does
1659
+ * NO quoting of its own, so an unquoted arg with a space is split into several
1660
+ * args, and a cmd metacharacter (`&|<>()^`) would be interpreted by the shell.
1661
+ * We wrap any arg with whitespace, a quote, or a metacharacter in double quotes
1662
+ * and escape embedded quotes / trailing backslashes per the CommandLineToArgvW
1663
+ * rules, so the *child's* argv parse reconstructs the original argument.
1664
+ *
1665
+ * CAVEAT: cmd.exe expands `%VAR%` (always) and `!VAR!` (under delayed expansion)
1666
+ * BEFORE argv parsing, and double-quoting does NOT suppress `%`/`!` (the
1667
+ * "BatBadBut" / CVE-2024-1874 class). We deliberately do not escape `%`/`!`:
1668
+ * `agents secrets exec` runs a caller-supplied command against a bundle the
1669
+ * caller owns, so caller-controlled `%`/`!` is not a privilege boundary. If that
1670
+ * ever changes (exec'ing an untrusted command line), route through a shell that
1671
+ * disables expansion rather than relying on this quoter. An empty arg becomes
1672
+ * `""`. Exported for tests. No-ops on non-Windows (the caller only invokes it
1673
+ * under `process.platform === 'win32'`).
1674
+ */
1675
+ export function quoteWin32ExecArg(arg) {
1676
+ if (arg.length > 0 && !/[\s"&|<>()^]/.test(arg))
1677
+ return arg;
1678
+ let result = '"';
1679
+ let backslashes = 0;
1680
+ for (const ch of arg) {
1681
+ if (ch === '\\') {
1682
+ backslashes += 1;
1683
+ continue;
1684
+ }
1685
+ if (ch === '"') {
1686
+ // Double the run of backslashes, then escape this quote.
1687
+ result += '\\'.repeat(backslashes * 2 + 1) + '"';
1688
+ backslashes = 0;
1689
+ continue;
1690
+ }
1691
+ result += '\\'.repeat(backslashes) + ch;
1692
+ backslashes = 0;
1693
+ }
1694
+ // Trailing backslashes precede the closing quote → must be doubled.
1695
+ result += '\\'.repeat(backslashes * 2) + '"';
1696
+ return result;
1697
+ }
1622
1698
  /**
1623
1699
  * Copy text to the system clipboard, cross-platform.
1624
1700
  * macOS: `pbcopy`. Windows: `clip`. Linux: tries `wl-copy` (Wayland), then
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerSessionsResumeCommand(sessionsCmd: Command): void;
@@ -0,0 +1,209 @@
1
+ /**
2
+ * `agents sessions resume` — multi-select sessions and fan each one out into a
3
+ * terminal surface via the terminal launch engine (src/lib/terminal).
4
+ *
5
+ * Unlike the single-select picker behind bare `agents sessions` (which resumes
6
+ * one session in place), this opens a checkbox picker, then asks where the
7
+ * chosen sessions should resume. By default they pack two-per-tab (session 1 in
8
+ * a new tab, session 2 split beside it, session 3 a new tab, …) in the terminal
9
+ * you're in — iTerm / Ghostty / tmux, locally or on a remote host via --host.
10
+ */
11
+ import * as fs from 'fs';
12
+ import chalk from 'chalk';
13
+ import { discoverSessions } from '../lib/session/discover.js';
14
+ import { filterTeamSessions } from '../lib/session/team-filter.js';
15
+ import { multiItemPicker, itemPicker } from '../lib/picker.js';
16
+ import { buildPreview } from './sessions-picker.js';
17
+ import { filterSessionsByQuery, formatPickerLabel, buildResumeCommand, resumeSessionInPlace, parseAgentFilter, } from './sessions.js';
18
+ import { openSurfaces, availableBackends, detectCurrentBackend, currentContext, } from '../lib/terminal/index.js';
19
+ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
20
+ import { setHelpSections } from '../lib/help.js';
21
+ import { confirm } from '@inquirer/prompts';
22
+ /** Opening more than this many live sessions at once asks for confirmation first. */
23
+ const CONFIRM_THRESHOLD = 5;
24
+ export function registerSessionsResumeCommand(sessionsCmd) {
25
+ const cmd = sessionsCmd
26
+ .command('resume')
27
+ .argument('[query]', 'Filter sessions before selecting (topic, path, or id fragment)')
28
+ .description('Multi-select sessions and resume each in a terminal tab/split (this terminal, iTerm, Ghostty, tmux; local or --host).')
29
+ .option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
30
+ .option('--all', 'Include sessions from every directory (not just current project)')
31
+ .option('--teams', 'Include team-spawned sessions (hidden by default)')
32
+ .option('--since <time>', 'Only sessions newer than this (e.g., 2h, 7d, 4w, or ISO date)')
33
+ .option('-n, --limit <n>', 'Maximum number of sessions to load into the picker', '200')
34
+ .option('--host <alias>', 'Resume on a remote host over SSH (defaults to tmux there)')
35
+ .option('--iterm', 'Force the iTerm backend')
36
+ .option('--ghostty', 'Force the Ghostty backend')
37
+ .option('--tmux', 'Force the tmux backend')
38
+ .option('--tabs', 'One tab per session (default: pack two panes per tab)');
39
+ setHelpSections(cmd, {
40
+ examples: `
41
+ # Pick several sessions; they pack two-per-tab in your current terminal
42
+ agents sessions resume
43
+
44
+ # Pre-filter the pool before selecting (space in the filter → use [query])
45
+ agents sessions resume "auth middleware"
46
+
47
+ # Force a backend / one tab each / a remote host
48
+ agents sessions resume --ghostty
49
+ agents sessions resume --tabs
50
+ agents sessions resume --host zion --tmux
51
+ `,
52
+ notes: `
53
+ - space toggles a session, enter confirms; tab toggles the preview pane.
54
+ - Layout: two-per-tab by default (session 1 → new tab, session 2 → split beside it, …). --tabs disables splitting.
55
+ - Backend: auto-detected from the terminal you're in (iTerm / Ghostty / tmux); override with --iterm/--ghostty/--tmux.
56
+ - --host <alias> resumes on a remote machine over the same SSH transport as 'sessions --host' (defaults to tmux).
57
+ - Each session opens version-pinned, in its own cwd. Non-resumable agents are skipped with a note.
58
+ `,
59
+ });
60
+ cmd.action(async (query, options) => {
61
+ await sessionsResumeAction(query, options);
62
+ });
63
+ }
64
+ async function sessionsResumeAction(query, options) {
65
+ if (!isInteractiveTerminal()) {
66
+ console.error(chalk.red('sessions resume needs an interactive terminal.'));
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+ const { agent, version } = parseAgentFilter(options.agent);
71
+ const limit = parseInt(options.limit || '200', 10);
72
+ const since = options.since ?? (options.all ? undefined : '30d');
73
+ let sessions = await discoverSessions({
74
+ agent,
75
+ version,
76
+ all: options.all,
77
+ cwd: process.cwd(),
78
+ since,
79
+ sortBy: 'timestamp',
80
+ limit,
81
+ excludeTeamOrigin: !options.teams,
82
+ });
83
+ const { visible } = filterTeamSessions(sessions, !!options.teams);
84
+ sessions = visible;
85
+ if (sessions.length === 0) {
86
+ console.log(chalk.gray('No sessions found. Try --all or a different --since window.'));
87
+ return;
88
+ }
89
+ // 1. Multi-select the sessions.
90
+ let chosen;
91
+ try {
92
+ chosen = await multiItemPicker({
93
+ message: 'Select sessions to resume:',
94
+ items: sessions,
95
+ filter: (q) => (q.trim() ? filterSessionsByQuery(sessions, q) : sessions),
96
+ labelFor: (s, q) => formatPickerLabel(s, q),
97
+ keyFor: (s) => s.id,
98
+ buildPreview,
99
+ pageSize: 15,
100
+ initialSearch: query,
101
+ emptyMessage: 'No sessions match.',
102
+ enterHint: 'resume',
103
+ });
104
+ }
105
+ catch (err) {
106
+ if (isPromptCancelled(err))
107
+ return;
108
+ throw err;
109
+ }
110
+ if (!chosen || chosen.length === 0)
111
+ return;
112
+ // 2. Split the selection into resumable surfaces and skipped agents (no silent drop).
113
+ const items = [];
114
+ for (const s of chosen) {
115
+ const command = buildResumeCommand(s);
116
+ if (!command) {
117
+ console.log(chalk.yellow(` skip ${s.shortId} — resume is not supported for ${s.agent} sessions yet`));
118
+ continue;
119
+ }
120
+ const cwd = s.cwd && fs.existsSync(s.cwd) ? s.cwd : process.cwd();
121
+ items.push({ session: s, cwd, command });
122
+ }
123
+ if (items.length === 0) {
124
+ console.log(chalk.gray('Nothing resumable in the selection.'));
125
+ return;
126
+ }
127
+ // 3. Resolve the backend (and host).
128
+ const ctx = currentContext();
129
+ const backend = await resolveBackend(options, ctx, items.length);
130
+ if (backend === 'cancel')
131
+ return;
132
+ // 4. Guard against opening a flood of live agents.
133
+ if (items.length > CONFIRM_THRESHOLD) {
134
+ const proceed = await confirm({
135
+ message: `Open ${items.length} live sessions at once?`,
136
+ default: false,
137
+ }).catch(() => false);
138
+ if (!proceed)
139
+ return;
140
+ }
141
+ // 5a. No tab-capable backend (off-macOS, not in tmux, local) — resume in place, sequentially.
142
+ if (backend === 'inplace') {
143
+ if (items.length > 1) {
144
+ console.log(chalk.gray(`Resuming ${items.length} sessions one at a time (no tab-capable terminal detected).`));
145
+ }
146
+ for (const it of items)
147
+ await resumeSessionInPlace(it.session);
148
+ return;
149
+ }
150
+ // 5b. Fan out through the engine (two-per-tab by default).
151
+ const packing = options.tabs ? 'tabs' : 'two-per-tab';
152
+ const where = options.host ? `${backend} on ${options.host}` : backend;
153
+ console.log(chalk.gray(`Opening ${items.length} session${items.length === 1 ? '' : 's'} in ${where} (${packing})…`));
154
+ const results = await openSurfaces(items.map((it) => ({ cwd: it.cwd, command: it.command })), { backend, host: options.host, packing });
155
+ let opened = 0;
156
+ results.forEach((r, i) => {
157
+ const s = items[i].session;
158
+ if (r.ok) {
159
+ opened++;
160
+ const shape = r.request.layout === 'tab' ? 'tab' : 'split';
161
+ console.log(chalk.green(` opened ${s.shortId}`) + chalk.gray(` — ${shape} — ${items[i].command.join(' ')}`));
162
+ }
163
+ else {
164
+ console.log(chalk.red(` failed ${s.shortId} — ${r.error}`));
165
+ }
166
+ });
167
+ console.log(chalk.gray(`\nOpened ${opened}/${items.length} in ${where}.`));
168
+ }
169
+ /**
170
+ * Decide which backend to launch into. Returns a concrete backend, `'inplace'`
171
+ * (resume in the current process — no GUI/tmux available), or `'cancel'` (the
172
+ * user dismissed the chooser).
173
+ */
174
+ async function resolveBackend(options, ctx, count) {
175
+ const forced = options.iterm ? 'iterm' : options.ghostty ? 'ghostty' : options.tmux ? 'tmux' : undefined;
176
+ if (forced)
177
+ return forced;
178
+ // Remote defaults to tmux (headless, no GUI session assumptions); override with a backend flag.
179
+ if (options.host)
180
+ return 'tmux';
181
+ const available = availableBackends(ctx);
182
+ if (available.length === 0)
183
+ return 'inplace';
184
+ const detected = detectCurrentBackend(ctx);
185
+ // Only one option and it's where we already are → no need to ask.
186
+ if (available.length === 1 && (!detected || detected === available[0].id))
187
+ return available[0].id;
188
+ const choices = available.map((b) => ({
189
+ id: b.id,
190
+ label: b.label,
191
+ detail: b.id === detected ? "the terminal you're in now" : `open in ${b.label}`,
192
+ }));
193
+ try {
194
+ const picked = await itemPicker({
195
+ message: `Resume ${count} session${count === 1 ? '' : 's'} where?`,
196
+ items: choices,
197
+ filter: () => choices,
198
+ labelFor: (c) => `${chalk.bold(c.label.padEnd(10))}${chalk.gray(c.detail)}`,
199
+ shortIdFor: (c) => c.label,
200
+ enterHint: 'open',
201
+ });
202
+ return picked ? picked.item.id : 'cancel';
203
+ }
204
+ catch (err) {
205
+ if (isPromptCancelled(err))
206
+ return 'cancel';
207
+ throw err;
208
+ }
209
+ }
@@ -1,4 +1,7 @@
1
1
  import type { Command } from 'commander';
2
+ import type { SessionMeta, SessionAgentId } from '../lib/session/types.js';
3
+ /** Whether a session's transcript can be live-tailed (append-only JSONL agents). */
4
+ export declare function isTailable(agent: SessionAgentId): boolean;
2
5
  export interface TailFileOptions {
3
6
  /** If true, emit every line from byte 0 first, then follow. Default false (EOF). */
4
7
  fromStart?: boolean;
@@ -15,5 +18,12 @@ export interface TailFileOptions {
15
18
  * parent dir until the file appears).
16
19
  */
17
20
  export declare function tailFile(filePath: string, onLine: (line: string) => void, ac: AbortController, opts?: TailFileOptions): Promise<void>;
21
+ /**
22
+ * Live-tail one already-resolved session's transcript to stdout until Ctrl+C.
23
+ * Shared by `agents sessions tail` and `agents logs -f`.
24
+ */
25
+ export declare function streamSessionTail(session: SessionMeta, options?: {
26
+ fromStart?: boolean;
27
+ }): Promise<void>;
18
28
  /** Attach the `tail` subcommand to an existing `sessions` command. */
19
29
  export declare function registerSessionsTailCommand(sessionsCmd: Command): void;
@@ -12,6 +12,10 @@ import chalk from 'chalk';
12
12
  import { discoverSessions, resolveSessionById } from '../lib/session/discover.js';
13
13
  import { setHelpSections } from '../lib/help.js';
14
14
  const TAIL_SUPPORTED = ['claude', 'codex'];
15
+ /** Whether a session's transcript can be live-tailed (append-only JSONL agents). */
16
+ export function isTailable(agent) {
17
+ return TAIL_SUPPORTED.includes(agent);
18
+ }
15
19
  /**
16
20
  * Tail a file: emit each newline-terminated line via onLine as it's written.
17
21
  *
@@ -182,6 +186,13 @@ async function runTail(sessionId, options) {
182
186
  }
183
187
  session = resolved;
184
188
  }
189
+ await streamSessionTail(session, { fromStart: options.fromStart });
190
+ }
191
+ /**
192
+ * Live-tail one already-resolved session's transcript to stdout until Ctrl+C.
193
+ * Shared by `agents sessions tail` and `agents logs -f`.
194
+ */
195
+ export async function streamSessionTail(session, options = {}) {
185
196
  const filePath = session.filePath.split('#')[0];
186
197
  if (process.stderr.isTTY) {
187
198
  process.stderr.write(chalk.gray(`Tailing ${session.agent} ${session.shortId} — ${filePath}\n`) +
@@ -1,7 +1,31 @@
1
1
  import type { Command } from 'commander';
2
- import type { SessionMeta } from '../lib/session/types.js';
2
+ import type { SessionAgentId, SessionMeta } from '../lib/session/types.js';
3
3
  import { type ActiveSession } from '../lib/session/active.js';
4
4
  import { type PickedSession } from './sessions-picker.js';
5
+ /**
6
+ * Index live sessions by their full session UUID so a historical `SessionMeta`
7
+ * row (`meta.id`) can be matched to the session that is still running now.
8
+ * Rows without a sessionId (some cloud/headless probes) are skipped — they
9
+ * can't be correlated back to a transcript on disk.
10
+ */
11
+ export declare function indexActiveBySessionId(active: ActiveSession[]): Map<string, ActiveSession>;
12
+ /**
13
+ * The live decoration for a listing row: a status glyph and the latest-turn
14
+ * preview, when the session is still running. `●` running / `◐` waiting on the
15
+ * user / `○` idle, colored by the same `statusColor` the --active view uses.
16
+ * Returns empty strings when there is no live match, so callers render the
17
+ * plain historical row unchanged.
18
+ */
19
+ export declare function liveGlyphAndPreview(a: ActiveSession | undefined): {
20
+ glyph: string;
21
+ preview: string;
22
+ };
23
+ /**
24
+ * The tracker/PR ref for a session's dedicated column: the ticket id when known,
25
+ * else `PR#<n>`, else empty. Pulled out of the trailing badge blob so refs align
26
+ * into a scannable column instead of jamming against a truncated topic.
27
+ */
28
+ export declare function ticketLabel(s: Pick<SessionMeta, 'ticketId' | 'prNumber'>): string;
5
29
  /** Grouped + sorted view of active sessions for the --active renderer. */
6
30
  export interface ActiveSessionsLayout {
7
31
  workspaces: Array<{
@@ -29,7 +53,49 @@ export interface ActiveSessionsLayout {
29
53
  * - sessions within a window/flat bucket: input order preserved
30
54
  */
31
55
  export declare function groupActiveSessions(sessions: ActiveSession[]): ActiveSessionsLayout;
56
+ /** One machine's active sessions, keeping the within-machine workspace layout. */
57
+ export interface MachineGroup {
58
+ /** Normalized device id (machineId() form). */
59
+ machine: string;
60
+ /** The machine this command is running on — pinned first and marked. */
61
+ isLocal: boolean;
62
+ total: number;
63
+ layout: ActiveSessionsLayout;
64
+ }
65
+ /** Active sessions grouped by the machine they run on. */
66
+ export interface MachineGroupedLayout {
67
+ machines: MachineGroup[];
68
+ }
69
+ /**
70
+ * Group active sessions by machine, then delegate each machine's sessions to the
71
+ * existing workspace/window grouping. Local machine is pinned first and flagged;
72
+ * the rest sort by session count descending, then name. Pure — `localMachine` is
73
+ * injected so the function stays testable without reading os.hostname().
74
+ */
75
+ export declare function groupSessionsByMachine(sessions: ActiveSession[], localMachine: string): MachineGroupedLayout;
76
+ /**
77
+ * Collapse duplicate sessions after a cross-machine merge. Two rows collapse
78
+ * only when they share both machine and session UUID (the same host listed
79
+ * twice, or a local/remote overlap); rows without a sessionId can't be
80
+ * correlated, so they're all kept.
81
+ */
82
+ export declare function dedupeByMachineSession(sessions: ActiveSession[]): ActiveSession[];
83
+ /**
84
+ * Render a session's full transcript to stdout — the non-follow view behind
85
+ * `agents logs <sessionId>`. Reuses the same markdown renderer as
86
+ * `agents sessions <id> --markdown`.
87
+ */
88
+ export declare function renderSessionLog(session: SessionMeta): Promise<void>;
89
+ export declare function formatPickerLabel(s: SessionMeta, query: string): string;
32
90
  export declare function pickSessionInteractive(sessions: SessionMeta[], message?: string, initialSearch?: string, hiddenCount?: number): Promise<PickedSession | null>;
91
+ /**
92
+ * Resume a session in the current terminal — a foreground takeover of this
93
+ * process. Used by the single-select picker and by `sessions resume` when the
94
+ * chosen destination is "in place" (unknown emulator / off-macOS, single pick).
95
+ * Falls back to the current version via `/continue` when the version-pinned
96
+ * binary is missing (ENOENT).
97
+ */
98
+ export declare function resumeSessionInPlace(session: SessionMeta): Promise<void>;
33
99
  /**
34
100
  * Build the shell command that resumes a picked session.
35
101
  *
@@ -42,7 +108,13 @@ export declare function pickSessionInteractive(sessions: SessionMeta[], message?
42
108
  * handler in handlePickedSession retries via buildFallbackCommand.
43
109
  */
44
110
  export declare function buildResumeCommand(session: SessionMeta): string[] | null;
111
+ interface AgentFilter {
112
+ agent?: SessionAgentId;
113
+ version?: string;
114
+ }
115
+ export declare function parseAgentFilter(agentName?: string): AgentFilter;
45
116
  /** Filter and rank sessions by a multi-term search query across metadata and content. */
46
117
  export declare function filterSessionsByQuery(sessions: SessionMeta[], query: string | undefined): SessionMeta[];
47
118
  /** Register the `agents sessions` command with all its options and help text. */
48
119
  export declare function registerSessionsCommands(program: Command): void;
120
+ export {};