@phnx-labs/agents-cli 1.20.43 → 1.20.45

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 (48) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +4 -3
  3. package/dist/commands/exec.js +84 -13
  4. package/dist/commands/hosts.js +5 -4
  5. package/dist/commands/logs.d.ts +4 -0
  6. package/dist/commands/logs.js +19 -13
  7. package/dist/commands/routines.d.ts +6 -0
  8. package/dist/commands/routines.js +70 -12
  9. package/dist/commands/secrets.d.ts +18 -0
  10. package/dist/commands/secrets.js +105 -30
  11. package/dist/commands/sessions.d.ts +6 -5
  12. package/dist/commands/sessions.js +50 -22
  13. package/dist/commands/teams.js +104 -8
  14. package/dist/lib/daemon.js +34 -9
  15. package/dist/lib/exec.d.ts +8 -0
  16. package/dist/lib/exec.js +70 -6
  17. package/dist/lib/hosts/dispatch.d.ts +29 -0
  18. package/dist/lib/hosts/dispatch.js +46 -1
  19. package/dist/lib/hosts/logs.d.ts +14 -5
  20. package/dist/lib/hosts/logs.js +39 -13
  21. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  22. package/dist/lib/hosts/remote-cmd.js +27 -0
  23. package/dist/lib/hosts/session-index.d.ts +15 -0
  24. package/dist/lib/hosts/session-index.js +28 -2
  25. package/dist/lib/redact.js +1 -0
  26. package/dist/lib/rotate.d.ts +33 -0
  27. package/dist/lib/rotate.js +37 -0
  28. package/dist/lib/secrets/remote.d.ts +14 -0
  29. package/dist/lib/secrets/remote.js +18 -1
  30. package/dist/lib/session/active.d.ts +10 -0
  31. package/dist/lib/session/active.js +12 -1
  32. package/dist/lib/session/db.d.ts +16 -9
  33. package/dist/lib/session/db.js +66 -44
  34. package/dist/lib/session/discover.d.ts +4 -0
  35. package/dist/lib/session/discover.js +84 -13
  36. package/dist/lib/session/run-names.d.ts +9 -7
  37. package/dist/lib/session/run-names.js +9 -7
  38. package/dist/lib/session/state.d.ts +29 -3
  39. package/dist/lib/session/state.js +84 -5
  40. package/dist/lib/session/types.d.ts +19 -8
  41. package/dist/lib/shims.d.ts +1 -1
  42. package/dist/lib/shims.js +17 -3
  43. package/dist/lib/teams/agents.js +25 -7
  44. package/dist/lib/tmux/session.d.ts +40 -0
  45. package/dist/lib/tmux/session.js +92 -0
  46. package/dist/lib/versions.d.ts +54 -1
  47. package/dist/lib/versions.js +138 -1
  48. package/package.json +1 -1
@@ -12,7 +12,9 @@ import * as fs from 'fs';
12
12
  import { SSH_TARGET_RE, assertValidSshTarget, sshExec } from '../lib/ssh-exec.js';
13
13
  import { quoteWin32ExecArg, composeWin32CommandLine } from '../lib/platform/index.js';
14
14
  import { ensureDaemonStarted } from '../lib/daemon.js';
15
- import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, resolveSshTarget, } from '../lib/secrets/remote.js';
15
+ import { parseHostsOption, remoteResolveEnv, remoteSecretsRaw, remoteSecretsStream, resolveSshTarget, } from '../lib/secrets/remote.js';
16
+ import { remoteShellFor, buildWindowsStdinImportCommand } from '../lib/hosts/remote-cmd.js';
17
+ import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
16
18
  import { bundleExists, bundleItemStore, bundlePolicy, deleteBundle, describeBundle, keychainItemsForBundle, keychainRef, listBundles, migrateLegacyBundles, parseDotenv, readAndResolveBundleEnv, readBundle, renameBundle, rotateBundleSecret, sanitizeProcessEnv, validateBundleName, validateEnvKey, validateExpiresFutureDated, validateSecretType, writeBundle, } from '../lib/secrets/bundles.js';
17
19
  import { getKeychainToken, getKeychainTokens, hasKeychainToken, secretsKeychainItem, setKeychainToken, } from '../lib/secrets/index.js';
18
20
  import { assertOpAvailable, createPasswordItem, deleteItemByTitle, extractSecrets, itemExistsByTitle, listItems, listVaults, } from '../lib/onepassword.js';
@@ -139,6 +141,29 @@ function readStdinSync() {
139
141
  }
140
142
  return Buffer.concat(chunks).toString('utf-8').trim();
141
143
  }
144
+ /**
145
+ * Read the raw `.env` text for `import --from <path|->`. A `-` reads the .env
146
+ * from stdin (the SSH push path: `export --host` pipes the resolved dotenv over
147
+ * ssh stdin, which has no `/dev/stdin` on a Windows remote); any other value is
148
+ * a filesystem path.
149
+ */
150
+ export function readImportDotenv(from) {
151
+ return from === '-' ? readStdinSync() : fs.readFileSync(from, 'utf-8');
152
+ }
153
+ /**
154
+ * Build the remote `agents secrets unlock` argv for `unlock --host`. `--all`
155
+ * forwards verbatim; otherwise the explicit bundle names. A `--ttl` is passed
156
+ * through as-is so the REMOTE parses its own duration (its platform rules, its
157
+ * defaults). Shared with the command action so the wiring is unit-testable
158
+ * without a live SSH session.
159
+ */
160
+ export function buildRemoteUnlockArgs(names, opts) {
161
+ return [
162
+ 'unlock',
163
+ ...(opts.all ? ['--all'] : names),
164
+ ...(opts.ttl ? ['--ttl', opts.ttl] : []),
165
+ ];
166
+ }
142
167
  // SSH target validation is defined canonically in src/lib/ssh-exec.ts and
143
168
  // re-exported here for back-compat with existing importers of these symbols.
144
169
  export { SSH_TARGET_RE, assertValidSshTarget };
@@ -1162,7 +1187,7 @@ Examples:
1162
1187
  cmd
1163
1188
  .command('import [bundle]')
1164
1189
  .description('Import keys from a .env file or a 1Password vault into a bundle. The bundle is created if it does not exist. Values are stored in the bundle\'s backend (keychain by default).')
1165
- .option('--from <path>', 'Path to a .env file')
1190
+ .option('--from <path>', 'Path to a .env file (use - to read the .env from stdin)')
1166
1191
  .option('--from-1password', 'Import secrets from a 1Password vault (requires the op CLI)')
1167
1192
  .option('--vault <name>', '1Password vault name (used with --from-1password)')
1168
1193
  .option('--all-plaintext', 'Store every imported value as a literal in the bundle metadata (skip keychain item creation)')
@@ -1227,7 +1252,7 @@ Examples:
1227
1252
  console.log(chalk.green(`Imported ${added} key(s) from 1Password vault '${vault}'${skipped ? `, skipped ${skipped} (already set, pass --force)` : ''}.`));
1228
1253
  }
1229
1254
  else {
1230
- const raw = fs.readFileSync(opts.from, 'utf-8');
1255
+ const raw = readImportDotenv(opts.from);
1231
1256
  const pairs = parseDotenv(raw);
1232
1257
  for (const [key, value] of Object.entries(pairs)) {
1233
1258
  if (!opts.force && key in bundle.vars) {
@@ -1294,34 +1319,47 @@ Examples:
1294
1319
  const { env } = readAndResolveBundleEnv(resolvedBundleName, { caller: `ssh export` });
1295
1320
  const dotenv = bundleEnvToDotenv(env);
1296
1321
  const keyCount = Object.keys(env).length;
1297
- // Drive the remote's own `agents secrets` CLI so values land in its
1298
- // chosen backend. `bash -lc` so the login PATH resolves `agents`; the
1299
- // .env (and, for file, the passphrase) flow over ssh stdin and are
1300
- // never parsed by a remote shell.
1301
- const force = opts.force ? ' --force' : '';
1302
- const backendFlag = remoteBackend === 'file' ? ' --backend file' : '';
1303
- let remoteAgents;
1304
- let input;
1305
- if (remoteBackend === 'file') {
1306
- // import --backend file auto-creates the file-backed bundle; no
1307
- // separate `create` needed.
1308
- remoteAgents =
1309
- `IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ` +
1310
- `agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${backendFlag}${force}`;
1311
- input = `${remotePassphrase}\n${dotenv}`;
1312
- }
1313
- else {
1314
- remoteAgents =
1315
- `agents secrets create ${shellQuote(resolvedBundleName)} >/dev/null 2>&1 || true; ` +
1316
- `agents secrets import ${shellQuote(resolvedBundleName)} --from /dev/stdin${force}`;
1317
- input = dotenv;
1318
- }
1319
- const remoteCmd = `bash -lc ${shellQuote(remoteAgents)}`;
1322
+ // Drive the remote's own `agents secrets import --from -` so the values
1323
+ // land in its chosen backend, reading the .env off ssh stdin (never
1324
+ // parsed by a remote shell `--from -` replaces the POSIX-only
1325
+ // `/dev/stdin`). The keychain path is built OS-aware via
1326
+ // `remoteSecretsRaw` (bash -lc on POSIX, PowerShell on Windows), so it
1327
+ // works on macOS, Linux AND Windows targets. `import` auto-creates the
1328
+ // bundle, so no separate `create` (the old `|| true` was a POSIXism
1329
+ // that broke on PowerShell: `'true' is not recognized`).
1320
1330
  let failures = 0;
1321
1331
  for (const host of hosts) {
1322
- // Routed through the shared ssh engine: full hardened options
1323
- // (BatchMode, ConnectTimeout, keepalive) + control-socket reuse.
1324
- const res = sshExec(host, remoteCmd, { input });
1332
+ let res;
1333
+ if (remoteBackend === 'file') {
1334
+ // File backend forwards AGENTS_SECRETS_PASSPHRASE as the FIRST stdin
1335
+ // line (consumed by `read`, so it never lands in argv / `ps` /
1336
+ // remote history), then the .env. That `read`/`export` prologue is
1337
+ // POSIX shell — refuse a Windows target cleanly rather than emit
1338
+ // broken PowerShell.
1339
+ if (remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell') {
1340
+ failures++;
1341
+ console.error(chalk.red(`${host}: file backend export to a Windows target is not yet supported.`));
1342
+ continue;
1343
+ }
1344
+ const remoteAgents = `IFS= read -r AGENTS_SECRETS_PASSPHRASE; export AGENTS_SECRETS_PASSPHRASE; ` +
1345
+ `agents secrets import ${shellQuote(resolvedBundleName)} --from - --backend file${opts.force ? ' --force' : ''}`;
1346
+ res = sshExec(host, `bash -lc ${shellQuote(remoteAgents)}`, { input: `${remotePassphrase}\n${dotenv}` });
1347
+ }
1348
+ else if (remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell') {
1349
+ // Keychain on a Windows target: the `agents.ps1` shim doesn't
1350
+ // forward ssh-piped stdin to node, so `--from -` would hang.
1351
+ // Bridge the piped .env through PowerShell into a temp file and
1352
+ // import `--from <file>` (deleted afterwards). Same hardened ssh
1353
+ // engine, .env still only ever crosses the wire over ssh stdin.
1354
+ res = sshExec(host, buildWindowsStdinImportCommand(resolvedBundleName, { force: opts.force }), { input: dotenv });
1355
+ }
1356
+ else {
1357
+ // Keychain on a POSIX target: OS-aware wrapping + hardened ssh
1358
+ // engine (BatchMode, ConnectTimeout, keepalive, control-socket
1359
+ // reuse) via the same path the READ inverse (`remoteResolveEnv`)
1360
+ // uses. `--from -` reads the .env off ssh stdin.
1361
+ res = remoteSecretsRaw(host, ['import', resolvedBundleName, '--from', '-', ...(opts.force ? ['--force'] : [])], { input: dotenv });
1362
+ }
1325
1363
  if (res.code === null) {
1326
1364
  failures++;
1327
1365
  console.error(chalk.red(`${host}: ${res.stderr.trim() || (res.timedOut ? 'ssh timed out' : 'ssh failed')}`));
@@ -1554,10 +1592,47 @@ Examples:
1554
1592
  });
1555
1593
  cmd
1556
1594
  .command('unlock [names...]')
1557
- .description('Hold a bundle in the secrets-agent after one Touch ID, so concurrent runs read it without re-prompting (macOS).')
1595
+ .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.')
1558
1596
  .option('--ttl <duration>', 'How long to hold it (e.g. 30m, 8h, 3d). Default 7d.')
1559
1597
  .option('--all', 'Unlock every configured bundle')
1598
+ .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>`.')
1560
1599
  .action(async (names, opts) => {
1600
+ // Single-valued (not variadic): a variadic --host greedily consumes the
1601
+ // positional bundle name (`unlock --host mac wztest` -> host=[mac,wztest],
1602
+ // names=[]). Unlock targets one remote at a time anyway.
1603
+ const hosts = opts.host ? [opts.host] : [];
1604
+ if (hosts.length > 0) {
1605
+ // Remote unlock: the REMOTE enforces its own platform rules, so the
1606
+ // local darwin-only guard below does NOT apply. Only file-backed
1607
+ // bundles are remote-unlockable — their passphrase prompt surfaces over
1608
+ // the -tt SSH TTY; a keychain/biometry bundle would trigger a local GUI
1609
+ // Touch-ID sheet that can't cross SSH.
1610
+ if (!opts.all && (!names || names.length === 0)) {
1611
+ console.error(chalk.red('Specify one or more bundle names, or --all.'));
1612
+ process.exit(1);
1613
+ }
1614
+ const unlockArgs = buildRemoteUnlockArgs(names, opts);
1615
+ let failures = 0;
1616
+ for (const h of hosts) {
1617
+ const target = await resolveSshTarget(h);
1618
+ // FOREGROUND stream (stdio inherited), NOT the piped remoteSecretsRaw:
1619
+ // the remote's passphrase prompt only surfaces if the remote process
1620
+ // sees a real TTY, which requires our local terminal to pass straight
1621
+ // through. The remote's prompt + output stream to this terminal; we get
1622
+ // back only the exit code.
1623
+ const code = remoteSecretsStream(target, unlockArgs);
1624
+ if (code === 0) {
1625
+ console.log(chalk.green(`${h}: unlocked`));
1626
+ }
1627
+ else {
1628
+ failures++;
1629
+ console.error(chalk.red(`${h}: unlock failed (exit ${code})`));
1630
+ }
1631
+ }
1632
+ if (failures > 0)
1633
+ process.exit(1);
1634
+ return;
1635
+ }
1561
1636
  if (process.platform !== 'darwin') {
1562
1637
  console.error(chalk.red('secrets-agent is macOS-only (no biometry prompt to deduplicate elsewhere).'));
1563
1638
  process.exit(1);
@@ -1,5 +1,5 @@
1
1
  import type { Command } from 'commander';
2
- import type { SessionAgentId, SessionMeta } from '../lib/session/types.js';
2
+ import type { SessionAgentId, SessionMeta, ViewMode } 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
5
  /**
@@ -149,11 +149,12 @@ export declare function buildOverviewGroups(pool: SessionMeta[], perProjectCap:
149
149
  projectCount: number;
150
150
  };
151
151
  /**
152
- * Render a session's full transcript to stdout — the non-follow view behind
153
- * `agents logs <sessionId>`. Reuses the same markdown renderer as
154
- * `agents sessions <id> --markdown`.
152
+ * Render a resolved session to stdout — the non-follow view behind
153
+ * `agents logs <sessionId>`. Defaults to the concise `summary` digest (same as
154
+ * `agents sessions <id>`); pass `'markdown'` for the full transcript
155
+ * (`agents logs <id> --full`). Reuses the shared `renderSession` renderer.
155
156
  */
156
- export declare function renderSessionLog(session: SessionMeta): Promise<void>;
157
+ export declare function renderSessionLog(session: SessionMeta, mode?: ViewMode): Promise<void>;
157
158
  /** Column-visibility flags for the picker row, computed once over the whole pool. */
158
159
  export interface PickerColumns {
159
160
  /** Render the machine column (only when the pool spans more than one machine). */
@@ -425,7 +425,14 @@ export function groupActiveSessions(sessions) {
425
425
  * to the same id form); else the local machine. Never keys off `ActiveSession.host`
426
426
  * — that is the terminal *app* (code/tmux), not the computer.
427
427
  */
428
+ /** Synthetic top-level group key for provider-sandboxed cloud tasks. */
429
+ const CLOUD_MACHINE_KEY = 'cloud';
428
430
  function machineKeyFor(s, localMachine) {
431
+ // Cloud tasks run in a provider sandbox, not on the machine they're attributed
432
+ // to for reply routing (s.machine = the querier). Surface them as their own
433
+ // top-level "cloud" group instead of nested under the local device.
434
+ if (s.context === 'cloud')
435
+ return CLOUD_MACHINE_KEY;
429
436
  if (s.machine)
430
437
  return s.machine;
431
438
  if (s.provenance?.host)
@@ -449,6 +456,11 @@ export function groupSessionsByMachine(sessions, localMachine) {
449
456
  return -1;
450
457
  if (b === localMachine)
451
458
  return 1;
459
+ // The synthetic "cloud" category sorts after all real machines.
460
+ if (a === CLOUD_MACHINE_KEY)
461
+ return 1;
462
+ if (b === CLOUD_MACHINE_KEY)
463
+ return -1;
452
464
  const ac = byMachine.get(a).length, bc = byMachine.get(b).length;
453
465
  if (ac !== bc)
454
466
  return bc - ac;
@@ -575,38 +587,48 @@ function groupTally(sessions) {
575
587
  return parts.join(' · ');
576
588
  }
577
589
  /** Print one machine's workspace tree, indented under its machine header. */
578
- function renderWorkspaceLayout(layout, base) {
590
+ function renderWorkspaceLayout(layout, base, machineKey) {
579
591
  let first = true;
580
592
  for (const ws of layout.workspaces) {
581
593
  if (!first)
582
594
  console.log();
583
595
  first = false;
584
- const header = ws.key === '__cloud__'
585
- ? chalk.magenta.bold('cloud')
586
- : ws.key === '__unknown__'
587
- ? chalk.gray.bold('unknown')
588
- : chalk.cyan.bold(shortCwd(ws.key));
589
- const wsSessions = [...ws.windows.flatMap(w => w.sessions), ...ws.flat];
590
- const tally = groupTally(wsSessions);
591
- console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}${tally ? chalk.gray(` ${tally}`) : ''}`);
596
+ // Under the top-level "cloud" machine group the __cloud__ workspace header is
597
+ // redundant ("▸ cloud" then "cloud") — render its rows flat under the machine
598
+ // header instead. Row indent collapses by one level to match.
599
+ const redundantCloud = ws.key === '__cloud__' && machineKey === CLOUD_MACHINE_KEY;
600
+ const rowBase = redundantCloud ? base : base + ' ';
601
+ if (!redundantCloud) {
602
+ const header = ws.key === '__cloud__'
603
+ ? chalk.magenta.bold('cloud')
604
+ : ws.key === '__unknown__'
605
+ ? chalk.gray.bold('unknown')
606
+ : chalk.cyan.bold(shortCwd(ws.key));
607
+ const wsSessions = [...ws.windows.flatMap(w => w.sessions), ...ws.flat];
608
+ const tally = groupTally(wsSessions);
609
+ console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}${tally ? chalk.gray(` ${tally}`) : ''}`);
610
+ }
592
611
  for (const win of ws.windows) {
593
612
  // Host is per-process, but every terminal in the same IDE window shares
594
613
  // an ancestor — take the first non-empty host as the window's label.
595
614
  const host = win.sessions.find((s) => s.host)?.host ?? 'terminal';
596
615
  const winHeader = `${chalk.gray(host)} ${chalk.gray('·')} ${chalk.gray(shortWindowLabel(win.windowId))} ${chalk.gray(`(${win.sessions.length})`)}`;
597
- console.log(base + ' ' + winHeader);
616
+ console.log(rowBase + winHeader);
598
617
  for (const s of win.sessions)
599
- printActiveRow(s, base + ' ');
618
+ printActiveRow(s, rowBase + ' ');
600
619
  }
601
620
  for (const s of ws.flat)
602
- printActiveRow(s, base + ' ');
621
+ printActiveRow(s, rowBase);
603
622
  }
604
623
  }
605
624
  /** Machine header: `▸ <name> ← this machine` for the local box (cyan), matching
606
625
  * the `ag devices list` treatment; a plain `▸ <name>` for remotes. */
607
626
  function printMachineHeader(mg) {
608
- const marker = mg.isLocal ? chalk.cyan(' ') : chalk.gray('▸ ');
609
- const name = mg.isLocal ? chalk.bold.cyan(mg.machine) : chalk.bold(mg.machine);
627
+ // The synthetic "cloud" group isn't a device — tint it magenta (matching the
628
+ // cloud row/label styling) so it reads as a category, not a machine.
629
+ const isCloud = mg.machine === CLOUD_MACHINE_KEY;
630
+ const marker = mg.isLocal ? chalk.cyan('▸ ') : isCloud ? chalk.magenta('▸ ') : chalk.gray('▸ ');
631
+ const name = mg.isLocal ? chalk.bold.cyan(mg.machine) : isCloud ? chalk.bold.magenta(mg.machine) : chalk.bold(mg.machine);
610
632
  const here = mg.isLocal ? chalk.cyan(' ← this machine') : '';
611
633
  console.log(`${marker}${name} ${chalk.gray(`(${mg.total})`)}${here}`);
612
634
  }
@@ -740,11 +762,16 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
740
762
  console.log();
741
763
  firstMachine = false;
742
764
  printMachineHeader(mg);
743
- renderWorkspaceLayout(mg.layout, ' ');
765
+ renderWorkspaceLayout(mg.layout, ' ', mg.machine);
744
766
  }
745
767
  const parts = groupTally(sessions).split(' · ').filter(Boolean);
746
- const machineWord = grouped.machines.length === 1 ? 'machine' : 'machines';
747
- console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}) across ${grouped.machines.length} ${machineWord}.`));
768
+ // The synthetic "cloud" group is a category, not a machine exclude it from the
769
+ // machine count and note it separately so the tally stays truthful.
770
+ const realMachines = grouped.machines.filter((m) => m.machine !== CLOUD_MACHINE_KEY).length;
771
+ const hasCloud = grouped.machines.some((m) => m.machine === CLOUD_MACHINE_KEY);
772
+ const machineWord = realMachines === 1 ? 'machine' : 'machines';
773
+ const cloudNote = hasCloud ? ' + cloud' : '';
774
+ console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}) across ${realMachines} ${machineWord}${cloudNote}.`));
748
775
  // Tip only when nothing else could be included and the user didn't opt out.
749
776
  if (!opts.local && !opts.hosts?.length && remoteDeviceCount === 0)
750
777
  printCrossMachineTip();
@@ -1258,12 +1285,13 @@ function resolveViewMode(options, filters) {
1258
1285
  return 'summary';
1259
1286
  }
1260
1287
  /**
1261
- * Render a session's full transcript to stdout — the non-follow view behind
1262
- * `agents logs <sessionId>`. Reuses the same markdown renderer as
1263
- * `agents sessions <id> --markdown`.
1288
+ * Render a resolved session to stdout — the non-follow view behind
1289
+ * `agents logs <sessionId>`. Defaults to the concise `summary` digest (same as
1290
+ * `agents sessions <id>`); pass `'markdown'` for the full transcript
1291
+ * (`agents logs <id> --full`). Reuses the shared `renderSession` renderer.
1264
1292
  */
1265
- export async function renderSessionLog(session) {
1266
- await renderSession(session, 'markdown', {});
1293
+ export async function renderSessionLog(session, mode = 'summary') {
1294
+ await renderSession(session, mode, {});
1267
1295
  }
1268
1296
  async function renderSession(session, mode, filters, options = {}) {
1269
1297
  // OpenCode stores sessions in SQLite; filePath is "db_path#session_id"
@@ -11,11 +11,13 @@ import { handleSpawn, handleStatus, handleStop, handleTasks, toTaskStatusSummary
11
11
  import { createTeam, ensureTeam, getTeam, loadTeams, removeTeam, teamExists, } from '../lib/teams/registry.js';
12
12
  import { setHelpSections } from '../lib/help.js';
13
13
  import { createWorktree, isGitRepo, hasUncommittedChanges, removeWorktree, } from '../lib/teams/worktree.js';
14
- import { isVersionInstalled, resolveVersionAlias, resolveVersionAliasLoose } from '../lib/versions.js';
14
+ import { isVersionInstalled, resolveVersion, resolveVersionAlias, resolveVersionAliasLoose, verifyInstalledBinaryLaunches } from '../lib/versions.js';
15
15
  import { AGENTS, warnAgentDeprecated } from '../lib/agents.js';
16
16
  import { discoverSessions, parseTimeFilter, resolveSessionById } from '../lib/session/discover.js';
17
+ import { renderSessionLog } from './sessions.js';
17
18
  import { buildPreview as buildSessionPreview } from './sessions-picker.js';
18
19
  import { parseExecEnv } from '../lib/exec.js';
20
+ import { checkRunAccountReadiness } from '../lib/rotate.js';
19
21
  import { teamPicker, printTeamTable } from './teams-picker.js';
20
22
  import { itemPicker } from '../lib/picker.js';
21
23
  import { profileExists, readProfile } from '../lib/profiles.js';
@@ -191,6 +193,51 @@ export function wireCloudDispatcher(mgr) {
191
193
  * teammate whose CLI may not be signed in. Warn-only — never blocks `start`.
192
194
  * Local teammates only; cloud teammates authenticate through their provider.
193
195
  */
196
+ /**
197
+ * Advisory line for a version-pinned teammate whose account can't serve a run
198
+ * right now. A pinned target (`agents run <agent>@<version>`) bypasses account
199
+ * rotation — the pin IS the target — so unlike a bare teammate it can't route
200
+ * around a throttled/expired account; it will launch and likely 429 at once.
201
+ */
202
+ function throttleWarningLine(agent, version, r) {
203
+ const who = `${AGENT_NAMES[agent]} ${version}`;
204
+ const acct = r.email ? ` (${r.email})` : '';
205
+ const reason = r.reason === 'out_of_credits' ? 'is out of credits'
206
+ : r.reason === 'signed_out' ? 'is not signed in'
207
+ : 'is rate-limited right now';
208
+ return (chalk.yellow(`⚠ ${who}${acct} ${reason}.`) +
209
+ chalk.gray(`\n A pinned version skips account rotation, so it will launch on this account and may immediately hit its limit.` +
210
+ `\n Use a bare \`${agent}\` teammate to let the team pick a healthy account, or pass --force to silence this.`));
211
+ }
212
+ /**
213
+ * Advisory: for each staged VERSION-PINNED teammate, warn if its account is
214
+ * rate-limited / out of credits / signed out right now — reusing the router's
215
+ * own eligibility signal (`checkRunAccountReadiness`) so the warning matches
216
+ * what the spawn would actually do. Bare teammates (rotation handles them) and
217
+ * profile/cloud teammates (account not locally checkable) are skipped. Warns,
218
+ * never blocks. Deduped by agent@version so N teammates on one account warn once.
219
+ */
220
+ async function warnThrottledTeammates(mgr, team) {
221
+ let pending;
222
+ try {
223
+ pending = (await mgr.listByTask(team)).filter((a) => a.status === 'pending' && !a.cloudProvider && !a.profileName && a.version);
224
+ }
225
+ catch {
226
+ return; // team not loadable yet — nothing to warn about
227
+ }
228
+ const seen = new Set();
229
+ for (const a of pending) {
230
+ const agent = a.agentType;
231
+ const version = a.version;
232
+ const key = `${agent}@${version}`;
233
+ if (seen.has(key) || !AGENT_NAMES[agent])
234
+ continue;
235
+ seen.add(key);
236
+ const readiness = await checkRunAccountReadiness(agent, version);
237
+ if (!readiness.ready)
238
+ console.error(throttleWarningLine(agent, version, readiness));
239
+ }
240
+ }
194
241
  async function warnUnsignedTeammates(mgr, team) {
195
242
  let pending;
196
243
  try {
@@ -966,7 +1013,7 @@ export function registerTeamsCommands(program) {
966
1013
  .option('--cloud <provider>', `Dispatch to cloud backend instead of local CLI: ${VALID_CLOUD_PROVIDERS.join('|')}`)
967
1014
  .option('--repo <owner/repo>', 'GitHub repository (required for --cloud rush)')
968
1015
  .option('--branch <name>', 'Target git branch for cloud dispatch')
969
- .option('--force', "Skip the advisory 'may not be signed in' warning (detection is unreliable)")
1016
+ .option('--force', "Skip the advisory 'may not be signed in' / 'account throttled' warnings")
970
1017
  .option('--json', 'Output machine-readable JSON')
971
1018
  .action(async (team, teammate, task, opts) => {
972
1019
  if (!VALID_MODES.includes(opts.mode)) {
@@ -1006,6 +1053,16 @@ export function registerTeamsCommands(program) {
1006
1053
  console.error(chalk.yellow(`⚠ ${AGENT_NAMES[agent]} may not be signed in (detection is unreliable). Adding anyway.`) +
1007
1054
  chalk.gray(`\n If it fails to start, run \`${AGENTS[agent].cliCommand}\` to log in, or pass --force to silence this.`));
1008
1055
  }
1056
+ // Advisory throttle check — only for a version-pinned teammate, which
1057
+ // bypasses account rotation and so can't route around a rate-limited /
1058
+ // out-of-credits / signed-out account (see throttleWarningLine). Skip bare
1059
+ // targets (rotation handles them), profiles (auth-injected account isn't
1060
+ // the version-home one we can read), and cloud dispatch. Warn, never block.
1061
+ if (!opts.force && !cloudProviderId && !profileName && version) {
1062
+ const readiness = await checkRunAccountReadiness(agent, version);
1063
+ if (!readiness.ready)
1064
+ console.error(throttleWarningLine(agent, version, readiness));
1065
+ }
1009
1066
  if (opts.name !== undefined) {
1010
1067
  if (!opts.name || !/^[A-Za-z0-9_-]+$/.test(opts.name)) {
1011
1068
  die(`Invalid teammate name '${opts.name}'. Use letters, numbers, '-', or '_'.`);
@@ -1275,7 +1332,7 @@ export function registerTeamsCommands(program) {
1275
1332
  .option('--watch', 'Keep running: poll every --interval seconds, fire new waves, exit when the DAG drains.')
1276
1333
  .option('--interval <seconds>', 'Seconds between waves in --watch mode (default 8)', '8')
1277
1334
  .option('--max-waves <n>', 'Safety cap on waves in --watch mode (default 1000)', '1000')
1278
- .option('--force', "Skip the advisory 'may not be signed in' warning for staged teammates (detection is unreliable)")
1335
+ .option('--force', "Skip the advisory 'may not be signed in' / 'account throttled' warnings for staged teammates")
1279
1336
  .action(async (team, opts) => {
1280
1337
  const mgr = mkManager();
1281
1338
  wireCloudDispatcher(mgr);
@@ -1285,8 +1342,10 @@ export function registerTeamsCommands(program) {
1285
1342
  return;
1286
1343
  team = picked;
1287
1344
  }
1288
- if (!opts.force && !isJsonMode(opts))
1345
+ if (!opts.force && !isJsonMode(opts)) {
1289
1346
  await warnUnsignedTeammates(mgr, team);
1347
+ await warnThrottledTeammates(mgr, team);
1348
+ }
1290
1349
  if (!opts.watch) {
1291
1350
  await runOneWave(mgr, team, Boolean(opts.json));
1292
1351
  return;
@@ -1651,8 +1710,9 @@ export function registerTeamsCommands(program) {
1651
1710
  teams
1652
1711
  .command('logs [teammate]')
1653
1712
  .alias('log')
1654
- .description("Read a teammate's raw log output. Accepts positional name, --teammate <name>, UUID, or UUID prefix.")
1655
- .option('-n, --tail <n>', 'Show only the last N lines instead of the full log')
1713
+ .description("Show a teammate's concise session summary. --full (or -n <lines>) for the raw stdout. Accepts positional name, --teammate <name>, UUID, or UUID prefix.")
1714
+ .option('-n, --tail <n>', 'Show the last N lines of raw stdout instead of the concise summary')
1715
+ .option('-m, --full', 'Show the full raw stdout log instead of the concise summary')
1656
1716
  .option('--team <team>', 'Disambiguate when the same name appears in multiple teams')
1657
1717
  .option('--teammate <name>', 'Teammate name (alias for the positional arg; useful for scripts)')
1658
1718
  .action(async (ref, opts) => {
@@ -1682,14 +1742,28 @@ export function registerTeamsCommands(program) {
1682
1742
  }
1683
1743
  agentId = resolved.agentId;
1684
1744
  }
1745
+ // Concise by default: a teammate's agentId IS its agent session id (passed
1746
+ // as --session-id at launch), so render the same summary digest as
1747
+ // `agents sessions <id>`. --full / -n <lines> opt into the raw stdout.log.
1748
+ if (!opts.full && !opts.tail) {
1749
+ const all = await discoverSessions({ all: true, limit: 5000 });
1750
+ const matches = resolveSessionById(all, agentId);
1751
+ if (matches.length > 0) {
1752
+ await renderSessionLog(matches[0], 'summary');
1753
+ return;
1754
+ }
1755
+ // No resolvable session (e.g. a non-Claude teammate) — fall through to a
1756
+ // bounded tail of raw stdout rather than dumping the whole file.
1757
+ }
1685
1758
  const logPath = path.join(base, agentId, 'stdout.log');
1686
1759
  try {
1687
1760
  const content = await fs.readFile(logPath, 'utf-8');
1688
- if (!opts.tail) {
1761
+ if (opts.full) {
1689
1762
  process.stdout.write(content);
1690
1763
  return;
1691
1764
  }
1692
- const n = Math.max(1, parseInt(opts.tail, 10) || 50);
1765
+ // Default tail size keeps an un-resolvable teammate's glance bounded too.
1766
+ const n = opts.tail ? Math.max(1, parseInt(opts.tail, 10) || 50) : 40;
1693
1767
  const lines = content.split('\n');
1694
1768
  process.stdout.write(lines.slice(-n).join('\n'));
1695
1769
  }
@@ -1705,6 +1779,28 @@ export function registerTeamsCommands(program) {
1705
1779
  .option('--json', 'Output machine-readable JSON')
1706
1780
  .action(async (opts) => {
1707
1781
  const info = checkAllClis();
1782
+ // Deep integrity probe. `checkAllClis` reports presence (shim + stub guard),
1783
+ // but a GUTTED native binary (JS wrapper present, platform binary missing —
1784
+ // the codex/kimi optional-dep partial-extract failure) still passes that. So
1785
+ // actually launch the resolved default version and, if it won't run, flip the
1786
+ // agent to not-installed with a repair hint — otherwise doctor says "ready"
1787
+ // and the teammate ENOENTs at spawn. Parallel; win32 is treated as healthy by
1788
+ // verifyInstalledBinaryLaunches.
1789
+ await Promise.all(Object.entries(info).map(async ([name, entry]) => {
1790
+ if (!entry.installed)
1791
+ return;
1792
+ const agent = name;
1793
+ const version = resolveVersion(agent);
1794
+ if (!version)
1795
+ return;
1796
+ const health = await verifyInstalledBinaryLaunches(agent, version);
1797
+ if (!health.ok) {
1798
+ entry.installed = false;
1799
+ entry.path = null;
1800
+ entry.error = `${AGENTS[agent]?.cliCommand ?? name}@${version} is installed but its binary won't launch`
1801
+ + `${health.detail ? ` (${health.detail})` : ''}. Repair: agents add ${agent}@${version}`;
1802
+ }
1803
+ }));
1708
1804
  // Advisory enrichment only. Sign-in detection is UNRELIABLE, so it never
1709
1805
  // changes the authoritative installed/ready column — it annotates. And an
1710
1806
  // agent that is actually running in a team is treated as signed in
@@ -19,6 +19,7 @@ import { detectOverdueJobs, notifyOverdue } from './overdue.js';
19
19
  import { BrowserService } from './browser/service.js';
20
20
  import { BrowserIPCServer } from './browser/ipc.js';
21
21
  import { readAndResolveBundleEnv } from './secrets/bundles.js';
22
+ import { redactSecrets } from './redact.js';
22
23
  const PID_FILE = 'daemon.pid';
23
24
  const LOCK_FILE = 'daemon.lock';
24
25
  const LOG_FILE = 'logs.jsonl';
@@ -207,15 +208,6 @@ export function reapStrayDaemons(keepPid = process.pid) {
207
208
  }
208
209
  return { reaped, details };
209
210
  }
210
- /** Redact values that look like tokens or credentials in a log message. */
211
- function redactSecrets(message) {
212
- let safe = message;
213
- safe = safe.replace(/eyJ[A-Za-z0-9_-]{20,}/g, '[REDACTED_TOKEN]');
214
- safe = safe.replace(/Bearer\s+\S+/gi, 'Bearer [REDACTED]');
215
- safe = safe.replace(/(sk-[a-zA-Z0-9]{20,})/g, '[REDACTED_KEY]');
216
- safe = safe.replace(/(ANTHROPIC_API_KEY|OPENAI_API_KEY|API_KEY|SECRET|TOKEN|PASSWORD)=\S+/gi, '$1=[REDACTED]');
217
- return safe;
218
- }
219
211
  function rotateLogsIfNeeded(logPath) {
220
212
  try {
221
213
  const stat = fs.statSync(logPath);
@@ -428,6 +420,37 @@ export async function runDaemon() {
428
420
  };
429
421
  const deviceProbeInterval = setInterval(() => { void runDeviceProbe(); }, 3 * 60_000);
430
422
  const deviceProbeKickoff = setTimeout(() => { void runDeviceProbe(); }, 15_000);
423
+ // tmux hook reconcile: retrofit the guarded `pane-died` hook onto managed
424
+ // `agents run` sessions a pre-fix binary left with the old unconditional hook
425
+ // (which detached the whole client — kicking the user out of the view — when
426
+ // they exited a split they'd opened). Non-destructive: set-hook only, never a
427
+ // kill or detach. A per-session schema marker makes steady-state a no-op, so
428
+ // this stays cheap at ~every 5 min, plus once ~20s after startup so a
429
+ // just-upgraded daemon heals still-running sessions without waiting for them to
430
+ // cycle or the shared server to be recycled.
431
+ let reconcilingTmux = false;
432
+ const runTmuxReconcile = async () => {
433
+ if (reconcilingTmux)
434
+ return;
435
+ reconcilingTmux = true;
436
+ try {
437
+ const { isTmuxInstalled } = await import('./tmux/binary.js');
438
+ if (!isTmuxInstalled())
439
+ return;
440
+ const { reconcileSessionHooks } = await import('./tmux/session.js');
441
+ const r = await reconcileSessionHooks();
442
+ if (r.reconciled > 0)
443
+ log('INFO', `tmux: retrofitted pane-died hook on ${r.reconciled} session(s)`);
444
+ }
445
+ catch (err) {
446
+ log('ERROR', `tmux reconcile failed: ${err.message}`);
447
+ }
448
+ finally {
449
+ reconcilingTmux = false;
450
+ }
451
+ };
452
+ const tmuxReconcileInterval = setInterval(() => { void runTmuxReconcile(); }, 5 * 60_000);
453
+ const tmuxReconcileKickoff = setTimeout(() => { void runTmuxReconcile(); }, 20_000);
431
454
  const handleReload = () => {
432
455
  log('INFO', 'Reloading jobs (SIGHUP)');
433
456
  scheduler.reloadAll();
@@ -447,6 +470,8 @@ export async function runDaemon() {
447
470
  clearTimeout(healKickoff);
448
471
  clearInterval(deviceProbeInterval);
449
472
  clearTimeout(deviceProbeKickoff);
473
+ clearInterval(tmuxReconcileInterval);
474
+ clearTimeout(tmuxReconcileKickoff);
450
475
  removeDaemonPid();
451
476
  process.exit(0);
452
477
  };
@@ -272,6 +272,14 @@ export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
272
272
  * (`BASH_FUNC_*%%`) can't make `env` choke.
273
273
  */
274
274
  export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv): string;
275
+ /**
276
+ * Trim a raw `tmux capture-pane` dump to its last `maxLines` non-empty lines
277
+ * (right-stripping each). Used by runInTmux to recap a fast-failed agent's
278
+ * output into the caller's shell so a launch crash (e.g. a gutted install that
279
+ * dies with ENOENT the instant it spawns) isn't swallowed by the bare
280
+ * `[detached]` the pane-died hook otherwise leaves behind.
281
+ */
282
+ export declare function formatPaneTail(raw: string, maxLines?: number): string;
275
283
  /** Exit code spawnAgent resolves with when a run is killed for crossing a budget cap. */
276
284
  export declare const BUDGET_KILL_EXIT_CODE = 7;
277
285
  /**