@phnx-labs/agents-cli 1.20.34 → 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 (121) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -1
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/cost.js +17 -12
  6. package/dist/commands/doctor.d.ts +1 -0
  7. package/dist/commands/doctor.js +59 -14
  8. package/dist/commands/events.d.ts +16 -0
  9. package/dist/commands/events.js +173 -0
  10. package/dist/commands/exec.js +6 -1
  11. package/dist/commands/hosts.js +28 -24
  12. package/dist/commands/inspect.d.ts +2 -0
  13. package/dist/commands/inspect.js +54 -7
  14. package/dist/commands/logs.d.ts +17 -0
  15. package/dist/commands/logs.js +139 -0
  16. package/dist/commands/models.d.ts +3 -0
  17. package/dist/commands/models.js +27 -8
  18. package/dist/commands/permissions.js +9 -2
  19. package/dist/commands/repo.d.ts +34 -0
  20. package/dist/commands/repo.js +243 -65
  21. package/dist/commands/resource-view.d.ts +20 -0
  22. package/dist/commands/resource-view.js +90 -28
  23. package/dist/commands/secrets.js +40 -16
  24. package/dist/commands/sessions-resume.d.ts +2 -0
  25. package/dist/commands/sessions-resume.js +209 -0
  26. package/dist/commands/sessions-tail.d.ts +10 -0
  27. package/dist/commands/sessions-tail.js +11 -0
  28. package/dist/commands/sessions.d.ts +73 -1
  29. package/dist/commands/sessions.js +273 -53
  30. package/dist/commands/view.d.ts +3 -0
  31. package/dist/commands/view.js +19 -8
  32. package/dist/commands/wallet.d.ts +6 -0
  33. package/dist/commands/wallet.js +22 -5
  34. package/dist/index.js +58 -31
  35. package/dist/lib/agents.js +30 -9
  36. package/dist/lib/browser/cdp.js +5 -1
  37. package/dist/lib/browser/drivers/ssh.d.ts +8 -0
  38. package/dist/lib/browser/drivers/ssh.js +76 -16
  39. package/dist/lib/browser/ipc.d.ts +3 -0
  40. package/dist/lib/browser/ipc.js +13 -9
  41. package/dist/lib/browser/service.js +71 -15
  42. package/dist/lib/daemon.d.ts +35 -0
  43. package/dist/lib/daemon.js +33 -5
  44. package/dist/lib/devices/connect.d.ts +3 -2
  45. package/dist/lib/devices/connect.js +5 -3
  46. package/dist/lib/events.d.ts +9 -2
  47. package/dist/lib/events.js +70 -11
  48. package/dist/lib/exec.js +22 -0
  49. package/dist/lib/hooks.js +8 -2
  50. package/dist/lib/hosts/dispatch.js +6 -7
  51. package/dist/lib/hosts/logs.d.ts +16 -0
  52. package/dist/lib/hosts/logs.js +45 -0
  53. package/dist/lib/hosts/progress.d.ts +66 -0
  54. package/dist/lib/hosts/progress.js +125 -17
  55. package/dist/lib/hosts/ready.d.ts +23 -2
  56. package/dist/lib/hosts/ready.js +35 -13
  57. package/dist/lib/hosts/reconcile.d.ts +53 -0
  58. package/dist/lib/hosts/reconcile.js +81 -0
  59. package/dist/lib/hosts/tasks.d.ts +8 -0
  60. package/dist/lib/hosts/tasks.js +14 -0
  61. package/dist/lib/permissions.d.ts +4 -0
  62. package/dist/lib/permissions.js +35 -0
  63. package/dist/lib/picker.d.ts +24 -0
  64. package/dist/lib/picker.js +124 -0
  65. package/dist/lib/plugin-marketplace.d.ts +30 -0
  66. package/dist/lib/plugin-marketplace.js +215 -2
  67. package/dist/lib/plugins.d.ts +5 -0
  68. package/dist/lib/plugins.js +45 -4
  69. package/dist/lib/session/active.d.ts +7 -0
  70. package/dist/lib/session/active.js +11 -3
  71. package/dist/lib/session/discover.js +94 -0
  72. package/dist/lib/session/parse.d.ts +6 -0
  73. package/dist/lib/session/parse.js +283 -2
  74. package/dist/lib/session/pid-registry.d.ts +21 -0
  75. package/dist/lib/session/pid-registry.js +80 -0
  76. package/dist/lib/session/remote-active.d.ts +26 -0
  77. package/dist/lib/session/remote-active.js +141 -0
  78. package/dist/lib/session/remote.js +2 -6
  79. package/dist/lib/session/render.js +1 -1
  80. package/dist/lib/session/sync/config.d.ts +8 -0
  81. package/dist/lib/session/sync/config.js +11 -2
  82. package/dist/lib/session/types.d.ts +1 -1
  83. package/dist/lib/session/types.js +1 -1
  84. package/dist/lib/shims.d.ts +11 -3
  85. package/dist/lib/shims.js +54 -30
  86. package/dist/lib/ssh-exec.d.ts +26 -3
  87. package/dist/lib/ssh-exec.js +45 -3
  88. package/dist/lib/ssh-tunnel.d.ts +24 -5
  89. package/dist/lib/ssh-tunnel.js +60 -62
  90. package/dist/lib/staleness/writers/hooks.js +1 -1
  91. package/dist/lib/startup/command-registry.d.ts +2 -0
  92. package/dist/lib/startup/command-registry.js +4 -0
  93. package/dist/lib/teams/registry.js +25 -9
  94. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  95. package/dist/lib/terminal/backends/ghostty.js +69 -0
  96. package/dist/lib/terminal/backends/index.d.ts +18 -0
  97. package/dist/lib/terminal/backends/index.js +29 -0
  98. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  99. package/dist/lib/terminal/backends/iterm.js +62 -0
  100. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  101. package/dist/lib/terminal/backends/tmux.js +23 -0
  102. package/dist/lib/terminal/engine.d.ts +39 -0
  103. package/dist/lib/terminal/engine.js +54 -0
  104. package/dist/lib/terminal/index.d.ts +14 -0
  105. package/dist/lib/terminal/index.js +5 -0
  106. package/dist/lib/terminal/policy.d.ts +11 -0
  107. package/dist/lib/terminal/policy.js +11 -0
  108. package/dist/lib/terminal/quote.d.ts +11 -0
  109. package/dist/lib/terminal/quote.js +13 -0
  110. package/dist/lib/terminal/shell.d.ts +6 -0
  111. package/dist/lib/terminal/shell.js +23 -0
  112. package/dist/lib/terminal/transport.d.ts +15 -0
  113. package/dist/lib/terminal/transport.js +40 -0
  114. package/dist/lib/terminal/types.d.ts +59 -0
  115. package/dist/lib/terminal/types.js +13 -0
  116. package/dist/lib/types.d.ts +7 -0
  117. package/dist/lib/versions.d.ts +7 -3
  118. package/dist/lib/versions.js +10 -5
  119. package/dist/lib/whats-new.d.ts +9 -0
  120. package/dist/lib/whats-new.js +35 -0
  121. package/package.json +1 -1
@@ -17,6 +17,8 @@ import { SESSION_AGENTS } from '../lib/session/types.js';
17
17
  import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
18
18
  import { looksLikePath, toComparablePath, homeDir } from '../lib/platform/index.js';
19
19
  import { getActiveSessions } from '../lib/session/active.js';
20
+ import { machineId, normalizeHost } from '../lib/session/sync/config.js';
21
+ import { gatherRemoteActive } from '../lib/session/remote-active.js';
20
22
  import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
21
23
  import { discoverSessions, countSessionsInScope, resolveSessionById, searchContentIndex } from '../lib/session/discover.js';
22
24
  import { filterTeamSessions } from '../lib/session/team-filter.js';
@@ -33,6 +35,7 @@ import { sessionPicker } from './sessions-picker.js';
33
35
  import { setHelpSections } from '../lib/help.js';
34
36
  import { registerSessionsTailCommand } from './sessions-tail.js';
35
37
  import { registerSessionsSyncCommand } from './sessions-sync.js';
38
+ import { registerSessionsResumeCommand } from './sessions-resume.js';
36
39
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
37
40
  const CLAUDE_RESUME_MATCH_WINDOW_MS = 10 * 60_000;
38
41
  const LOAD_VERBS = ['Loading', 'Scanning', 'Gathering', 'Indexing', 'Reading'];
@@ -194,6 +197,43 @@ function activityLabel(s) {
194
197
  return 'idle';
195
198
  return s.status === 'input_required' ? 'waiting' : s.status;
196
199
  }
200
+ /**
201
+ * Index live sessions by their full session UUID so a historical `SessionMeta`
202
+ * row (`meta.id`) can be matched to the session that is still running now.
203
+ * Rows without a sessionId (some cloud/headless probes) are skipped — they
204
+ * can't be correlated back to a transcript on disk.
205
+ */
206
+ export function indexActiveBySessionId(active) {
207
+ const byId = new Map();
208
+ for (const a of active) {
209
+ if (a.sessionId)
210
+ byId.set(a.sessionId, a);
211
+ }
212
+ return byId;
213
+ }
214
+ /**
215
+ * The live decoration for a listing row: a status glyph and the latest-turn
216
+ * preview, when the session is still running. `●` running / `◐` waiting on the
217
+ * user / `○` idle, colored by the same `statusColor` the --active view uses.
218
+ * Returns empty strings when there is no live match, so callers render the
219
+ * plain historical row unchanged.
220
+ */
221
+ export function liveGlyphAndPreview(a) {
222
+ if (!a)
223
+ return { glyph: '', preview: '' };
224
+ const waiting = a.status === 'input_required' || a.activity === 'waiting_input';
225
+ const running = a.status === 'running' || a.activity === 'working';
226
+ const shape = waiting ? '◐' : running ? '●' : '○';
227
+ return { glyph: statusColor(a.status)(shape), preview: buildSessionDescription(a) };
228
+ }
229
+ /**
230
+ * The tracker/PR ref for a session's dedicated column: the ticket id when known,
231
+ * else `PR#<n>`, else empty. Pulled out of the trailing badge blob so refs align
232
+ * into a scannable column instead of jamming against a truncated topic.
233
+ */
234
+ export function ticketLabel(s) {
235
+ return s.ticketId ?? (s.prNumber ? `PR#${s.prNumber}` : '');
236
+ }
197
237
  /**
198
238
  * Compact, colour-coded badges for the durable/awaiting signals. Text-only (no
199
239
  * emoji, per repo convention): `plan` / `ask` / `perm` for why it's waiting,
@@ -318,48 +358,148 @@ export function groupActiveSessions(sessions) {
318
358
  });
319
359
  return { workspaces };
320
360
  }
321
- /** Render the unified active-session view. */
322
- async function renderActiveSessions(asJson, waitingOnly = false) {
323
- const all = await getActiveSessions();
324
- // --waiting: only sessions blocked on the user. Exits non-zero when any are
325
- // present so a supervising agent or hook can poll it as a gate.
326
- const sessions = waitingOnly
327
- ? all.filter(s => s.status === 'input_required')
328
- : all;
329
- if (asJson) {
330
- process.stdout.write(JSON.stringify(sessions, null, 2) + '\n');
331
- if (waitingOnly && sessions.length > 0)
332
- process.exitCode = 1;
333
- return;
334
- }
335
- if (sessions.length === 0) {
336
- console.log(chalk.gray(waitingOnly ? 'No sessions waiting on input.' : 'No active agent sessions.'));
337
- return;
361
+ /**
362
+ * The machine a session belongs to: an explicit tag (set when merging
363
+ * cross-machine results) wins; else the process's provenance host (normalized
364
+ * to the same id form); else the local machine. Never keys off `ActiveSession.host`
365
+ * that is the terminal *app* (code/tmux), not the computer.
366
+ */
367
+ function machineKeyFor(s, localMachine) {
368
+ if (s.machine)
369
+ return s.machine;
370
+ if (s.provenance?.host)
371
+ return normalizeHost(s.provenance.host);
372
+ return localMachine;
373
+ }
374
+ /**
375
+ * Group active sessions by machine, then delegate each machine's sessions to the
376
+ * existing workspace/window grouping. Local machine is pinned first and flagged;
377
+ * the rest sort by session count descending, then name. Pure — `localMachine` is
378
+ * injected so the function stays testable without reading os.hostname().
379
+ */
380
+ export function groupSessionsByMachine(sessions, localMachine) {
381
+ const byMachine = new Map();
382
+ for (const s of sessions) {
383
+ const key = machineKeyFor(s, localMachine);
384
+ (byMachine.get(key) ?? byMachine.set(key, []).get(key)).push(s);
385
+ }
386
+ const keys = Array.from(byMachine.keys()).sort((a, b) => {
387
+ if (a === localMachine)
388
+ return -1;
389
+ if (b === localMachine)
390
+ return 1;
391
+ const ac = byMachine.get(a).length, bc = byMachine.get(b).length;
392
+ if (ac !== bc)
393
+ return bc - ac;
394
+ return a.localeCompare(b);
395
+ });
396
+ const machines = keys.map((machine) => ({
397
+ machine,
398
+ isLocal: machine === localMachine,
399
+ total: byMachine.get(machine).length,
400
+ layout: groupActiveSessions(byMachine.get(machine)),
401
+ }));
402
+ return { machines };
403
+ }
404
+ /**
405
+ * Collapse duplicate sessions after a cross-machine merge. Two rows collapse
406
+ * only when they share both machine and session UUID (the same host listed
407
+ * twice, or a local/remote overlap); rows without a sessionId can't be
408
+ * correlated, so they're all kept.
409
+ */
410
+ export function dedupeByMachineSession(sessions) {
411
+ const seen = new Set();
412
+ const out = [];
413
+ for (const s of sessions) {
414
+ if (!s.sessionId) {
415
+ out.push(s);
416
+ continue;
417
+ }
418
+ const key = `${s.machine ?? ''}:${s.sessionId}`;
419
+ if (seen.has(key))
420
+ continue;
421
+ seen.add(key);
422
+ out.push(s);
338
423
  }
339
- const layout = groupActiveSessions(sessions);
424
+ return out;
425
+ }
426
+ /** Print one machine's workspace tree, indented under its machine header. */
427
+ function renderWorkspaceLayout(layout, base) {
340
428
  let first = true;
341
429
  for (const ws of layout.workspaces) {
342
430
  if (!first)
343
431
  console.log();
344
432
  first = false;
345
- // Print workspace header
346
433
  const header = ws.key === '__cloud__'
347
434
  ? chalk.magenta.bold('cloud')
348
435
  : ws.key === '__unknown__'
349
436
  ? chalk.gray.bold('unknown')
350
437
  : chalk.cyan.bold(shortCwd(ws.key));
351
- console.log(`${header} ${chalk.gray(`(${ws.total})`)}`);
438
+ console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}`);
352
439
  for (const win of ws.windows) {
353
440
  // Host is per-process, but every terminal in the same IDE window shares
354
441
  // an ancestor — take the first non-empty host as the window's label.
355
442
  const host = win.sessions.find((s) => s.host)?.host ?? 'terminal';
356
443
  const winHeader = `${chalk.gray(host)} ${chalk.gray('·')} ${chalk.gray(shortWindowLabel(win.windowId))} ${chalk.gray(`(${win.sessions.length})`)}`;
357
- console.log(' ' + winHeader);
444
+ console.log(base + ' ' + winHeader);
358
445
  for (const s of win.sessions)
359
- printActiveRow(s, ' ');
446
+ printActiveRow(s, base + ' ');
360
447
  }
361
448
  for (const s of ws.flat)
362
- printActiveRow(s, ' ');
449
+ printActiveRow(s, base + ' ');
450
+ }
451
+ }
452
+ /** Machine header: `▸ <name> ← this machine` for the local box (cyan), matching
453
+ * the `ag devices list` treatment; a plain `▸ <name>` for remotes. */
454
+ function printMachineHeader(mg) {
455
+ const marker = mg.isLocal ? chalk.cyan('▸ ') : chalk.gray('▸ ');
456
+ const name = mg.isLocal ? chalk.bold.cyan(mg.machine) : chalk.bold(mg.machine);
457
+ const here = mg.isLocal ? chalk.cyan(' ← this machine') : '';
458
+ console.log(`${marker}${name} ${chalk.gray(`(${mg.total})`)}${here}`);
459
+ }
460
+ /**
461
+ * Render the unified active-session view, grouped by machine. Local sessions
462
+ * come from `getActiveSessions()`; unless `--local`, sessions from other
463
+ * machines are folded in over SSH (explicit `--host` targets, else the
464
+ * registered online devices from `ag devices`). A tip is shown when there are
465
+ * no other machines to include.
466
+ */
467
+ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
468
+ const self = machineId();
469
+ const local = await getActiveSessions();
470
+ for (const s of local)
471
+ if (!s.machine)
472
+ s.machine = self;
473
+ let remoteDeviceCount = 0;
474
+ let merged = local;
475
+ if (!opts.local) {
476
+ const remote = await gatherRemoteActive(opts.hosts);
477
+ remoteDeviceCount = remote.deviceCount;
478
+ merged = dedupeByMachineSession([...local, ...remote.sessions]);
479
+ }
480
+ // --waiting: only sessions blocked on the user. Exits non-zero when any are
481
+ // present so a supervising agent or hook can poll it as a gate.
482
+ const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
483
+ if (asJson) {
484
+ process.stdout.write(JSON.stringify(sessions, null, 2) + '\n');
485
+ if (waitingOnly && sessions.length > 0)
486
+ process.exitCode = 1;
487
+ return;
488
+ }
489
+ if (sessions.length === 0) {
490
+ console.log(chalk.gray(waitingOnly ? 'No sessions waiting on input.' : 'No active agent sessions.'));
491
+ if (!opts.local && !opts.hosts?.length && remoteDeviceCount === 0)
492
+ printCrossMachineTip();
493
+ return;
494
+ }
495
+ const grouped = groupSessionsByMachine(sessions, self);
496
+ let firstMachine = true;
497
+ for (const mg of grouped.machines) {
498
+ if (!firstMachine)
499
+ console.log();
500
+ firstMachine = false;
501
+ printMachineHeader(mg);
502
+ renderWorkspaceLayout(mg.layout, ' ');
363
503
  }
364
504
  const runningCount = sessions.filter(s => s.status === 'running').length;
365
505
  const idleCount = sessions.filter(s => s.status === 'idle').length;
@@ -371,14 +511,25 @@ async function renderActiveSessions(asJson, waitingOnly = false) {
371
511
  parts.push(`${idleCount} idle`);
372
512
  if (queuedCount > 0)
373
513
  parts.push(`${queuedCount} queued`);
374
- console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}).`));
514
+ const machineWord = grouped.machines.length === 1 ? 'machine' : 'machines';
515
+ console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}) across ${grouped.machines.length} ${machineWord}.`));
516
+ // Tip only when nothing else could be included and the user didn't opt out.
517
+ if (!opts.local && !opts.hosts?.length && remoteDeviceCount === 0)
518
+ printCrossMachineTip();
375
519
  // Scriptable gate: a non-zero exit when anything is waiting on the user.
376
520
  if (waitingOnly && sessions.length > 0)
377
521
  process.exitCode = 1;
378
522
  }
523
+ /** Nudge shown when `--active` has no other machines to fold in. */
524
+ function printCrossMachineTip() {
525
+ console.log(chalk.gray("\nTip: include sessions from your other machines — register them with 'ag devices sync', then rerun. Use --local to skip."));
526
+ }
379
527
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
380
528
  async function sessionsAction(query, options) {
381
- if (options.host && options.host.length > 0) {
529
+ // --host WITHOUT --active keeps the legacy per-host stream (each remote's raw
530
+ // stdout under a `── host ──` banner). With --active, the hosts are folded
531
+ // into the merged machine-grouped view instead (handled below).
532
+ if (options.host && options.host.length > 0 && !options.active) {
382
533
  try {
383
534
  runRemoteSessions(options.host);
384
535
  }
@@ -389,7 +540,13 @@ async function sessionsAction(query, options) {
389
540
  return;
390
541
  }
391
542
  if (options.active) {
392
- await renderActiveSessions(options.json === true, options.waiting === true);
543
+ // AGENTS_SESSIONS_LOCAL is set by a parent fan-out invocation (see
544
+ // remote-active.ts) so a peer answers for itself without recursing.
545
+ const forceLocal = options.local === true || process.env.AGENTS_SESSIONS_LOCAL === '1';
546
+ await renderActiveSessions(options.json === true, options.waiting === true, {
547
+ local: forceLocal,
548
+ hosts: options.host,
549
+ });
393
550
  return;
394
551
  }
395
552
  if (options.cloud) {
@@ -533,7 +690,8 @@ async function sessionsAction(query, options) {
533
690
  }
534
691
  // Non-interactive fallback (piped output)
535
692
  const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
536
- printSessionTable(filtered, hiddenCount, options.tree === true);
693
+ const liveIndex = await maybeLiveIndex(options);
694
+ printSessionTable(filtered, hiddenCount, options.tree === true, liveIndex);
537
695
  }
538
696
  catch (err) {
539
697
  tracker.stop();
@@ -560,45 +718,82 @@ function metaSignals(s) {
560
718
  ticket: s.ticketId ? { id: s.ticketId } : undefined,
561
719
  };
562
720
  }
563
- /** One flat table row: shortId · agent · version · project · topic(+badges) · time. */
564
- function flatSessionRow(session) {
721
+ /** One flat table row:
722
+ * shortId · agent · version · project · [glyph] label·doing · [ticket] · [wt] · time
723
+ * `doing` is the live preview when running, else the topic. The `ticket` column
724
+ * (tracker/PR ref, pulled out of the badge blob so refs align) is only rendered
725
+ * when `showTicket` — otherwise a listing with no refs would waste a column of
726
+ * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
727
+ function flatSessionRow(session, live, showTicket = false) {
565
728
  const agentColor = colorAgent(session.agent);
566
729
  const when = formatRelativeTime(session.timestamp);
567
730
  const project = session.project || '-';
568
731
  const tag = teamTag(session);
569
732
  const label = session.label;
570
- const topic = tag ? `${tag}${session.topic ?? ''}` : session.topic;
571
- const versionStr = session.version || '-';
572
- const badges = signalBadges(metaSignals(session));
573
- const badgeW = badges ? stringWidth(badges) + 1 : 0;
574
- const topicW = Math.max(16, terminalWidth() - (10 + 9 + 8 + 16) - badgeW - stringWidth(when) - 1);
733
+ const { glyph, preview } = liveGlyphAndPreview(live);
734
+ // A running session's live preview says what the agent is doing now; a
735
+ // resting one falls back to its opening topic.
736
+ const doing = preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic);
737
+ const wt = session.worktreeSlug ? chalk.magenta(`wt:${session.worktreeSlug}`) : '';
738
+ const TICKET_W = 10;
739
+ const ticketCell = showTicket
740
+ ? chalk.blue(padToWidth(truncateToWidth(ticketLabel(session) || '-', TICKET_W), TICKET_W + 1))
741
+ : '';
742
+ const glyphW = glyph ? 2 : 0;
743
+ const ticketW = showTicket ? TICKET_W + 1 : 0;
744
+ const wtW = wt ? stringWidth(wt) + 1 : 0;
745
+ const topicW = Math.max(16, terminalWidth() - (10 + 9 + 8 + 16) - glyphW - ticketW - wtW - stringWidth(when) - 1);
575
746
  return (chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
576
747
  agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
577
- chalk.yellow(padToWidth(truncateToWidth(versionStr, 7), 8)) +
748
+ chalk.yellow(padToWidth(truncateToWidth(session.version || '-', 7), 8)) +
578
749
  chalk.cyan(padToWidth(truncateToWidth(project, 14), 16)) +
579
- renderTopicCell(label, topic, '', topicW, topicW) +
580
- (badges ? badges + ' ' : '') +
750
+ (glyph ? glyph + ' ' : '') +
751
+ renderTopicCell(label, doing, '', topicW, topicW) +
752
+ ticketCell +
753
+ (wt ? wt + ' ' : '') +
581
754
  chalk.gray(when));
582
755
  }
583
756
  /** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
584
- function treeSessionRow(session) {
757
+ function treeSessionRow(session, live) {
585
758
  const agentColor = colorAgent(session.agent);
586
759
  const when = formatRelativeTime(session.timestamp);
587
760
  const tag = teamTag(session);
588
761
  const label = session.label;
589
- const topic = (tag ? `${tag}${session.topic ?? ''}` : session.topic) || '-';
762
+ const { glyph, preview } = liveGlyphAndPreview(live);
763
+ const topic = (preview || (tag ? `${tag}${session.topic ?? ''}` : session.topic)) || '-';
590
764
  const badges = signalBadges(metaSignals(session));
591
765
  const badgeW = badges ? stringWidth(badges) + 1 : 0;
592
766
  const head = label ? `${label} · ${topic}` : topic;
593
- const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - badgeW - stringWidth(when) - 1);
767
+ const glyphW = glyph ? 2 : 0;
768
+ const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - glyphW - badgeW - stringWidth(when) - 1);
594
769
  return (' ' +
595
770
  chalk.dim(padToWidth(session.shortId, 9)) +
596
771
  agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
597
772
  (badges ? badges + ' ' : '') +
773
+ (glyph ? glyph + ' ' : '') +
598
774
  padToWidth(chalk.white(truncateToWidth(head, topicW)), topicW) +
599
775
  ' ' + chalk.gray(when));
600
776
  }
601
- function printSessionTable(sessions, hiddenCount = 0, tree = false) {
777
+ /**
778
+ * Live-session index for enriching the default listing, or undefined when
779
+ * enrichment is off (`--no-live`) or irrelevant (`--json`, which serializes
780
+ * SessionMeta). Full detection (incl. the headless `ps` scan) is deliberate:
781
+ * bare-CLI and tmux agents are the common case here, and skipping them would
782
+ * leave the glyph almost never showing. The listing is a one-shot user action,
783
+ * not a hot loop, so the `ps`/`lsof` cost is acceptable; `--no-live` is the
784
+ * escape hatch. Never throws — a probe failure just yields a plain listing.
785
+ */
786
+ async function maybeLiveIndex(options) {
787
+ if (options.live === false || options.json)
788
+ return undefined;
789
+ try {
790
+ return indexActiveBySessionId(await getActiveSessions());
791
+ }
792
+ catch {
793
+ return undefined;
794
+ }
795
+ }
796
+ function printSessionTable(sessions, hiddenCount = 0, tree = false, liveIndex) {
602
797
  if (tree) {
603
798
  // Group by directory; drop the id/version columns from view. The short id
604
799
  // stays as each row's leading handle (the address to read/resume it).
@@ -619,7 +814,7 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false) {
619
814
  const group = byDir.get(key);
620
815
  console.log(`${chalk.cyan.bold(shortCwd(key))} ${chalk.gray(`(${group.length})`)}`);
621
816
  for (const s of group)
622
- console.log(treeSessionRow(s));
817
+ console.log(treeSessionRow(s, liveIndex?.get(s.id)));
623
818
  }
624
819
  const dirWord = keys.length === 1 ? 'directory' : 'directories';
625
820
  console.log(chalk.gray(`\n${sessions.length} session${sessions.length === 1 ? '' : 's'} across ${keys.length} ${dirWord}.`));
@@ -627,8 +822,11 @@ function printSessionTable(sessions, hiddenCount = 0, tree = false) {
627
822
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
628
823
  return;
629
824
  }
825
+ // Only show the ticket column when at least one row carries a ref — otherwise
826
+ // it's a column of dashes that steals width from every topic.
827
+ const showTicket = sessions.some((s) => ticketLabel(s) !== '');
630
828
  for (const session of sessions)
631
- console.log(flatSessionRow(session));
829
+ console.log(flatSessionRow(session, liveIndex?.get(session.id), showTicket));
632
830
  const countLine = `${sessions.length} session${sessions.length === 1 ? '' : 's'}.`;
633
831
  console.log(chalk.gray(`\n${countLine}`));
634
832
  if (hiddenCount > 0) {
@@ -677,6 +875,14 @@ function resolveViewMode(options, filters) {
677
875
  return 'markdown';
678
876
  return 'summary';
679
877
  }
878
+ /**
879
+ * Render a session's full transcript to stdout — the non-follow view behind
880
+ * `agents logs <sessionId>`. Reuses the same markdown renderer as
881
+ * `agents sessions <id> --markdown`.
882
+ */
883
+ export async function renderSessionLog(session) {
884
+ await renderSession(session, 'markdown', {});
885
+ }
680
886
  async function renderSession(session, mode, filters, options = {}) {
681
887
  // OpenCode stores sessions in SQLite; filePath is "db_path#session_id"
682
888
  const realPath = session.filePath.split('#')[0];
@@ -777,7 +983,7 @@ function renderTopicCell(label, topic, query, visibleWidth, paddedWidth) {
777
983
  }
778
984
  return out + padding;
779
985
  }
780
- function formatPickerLabel(s, query) {
986
+ export function formatPickerLabel(s, query) {
781
987
  const agentColor = colorAgent(s.agent);
782
988
  const when = formatRelativeTime(s.timestamp);
783
989
  const project = s.project || '-';
@@ -823,13 +1029,23 @@ async function handlePickedSession(picked) {
823
1029
  await renderSession(picked.session, 'summary', {});
824
1030
  return;
825
1031
  }
826
- const cwd = picked.session.cwd && fs.existsSync(picked.session.cwd)
827
- ? picked.session.cwd
1032
+ await resumeSessionInPlace(picked.session);
1033
+ }
1034
+ /**
1035
+ * Resume a session in the current terminal — a foreground takeover of this
1036
+ * process. Used by the single-select picker and by `sessions resume` when the
1037
+ * chosen destination is "in place" (unknown emulator / off-macOS, single pick).
1038
+ * Falls back to the current version via `/continue` when the version-pinned
1039
+ * binary is missing (ENOENT).
1040
+ */
1041
+ export async function resumeSessionInPlace(session) {
1042
+ const cwd = session.cwd && fs.existsSync(session.cwd)
1043
+ ? session.cwd
828
1044
  : process.cwd();
829
- const resume = buildResumeCommand(picked.session);
1045
+ const resume = buildResumeCommand(session);
830
1046
  if (!resume) {
831
- console.log(chalk.yellow(`Resume is not supported for ${picked.session.agent} sessions yet. Showing summary instead.`));
832
- await renderSession(picked.session, 'summary', {});
1047
+ console.log(chalk.yellow(`Resume is not supported for ${session.agent} sessions yet. Showing summary instead.`));
1048
+ await renderSession(session, 'summary', {});
833
1049
  return;
834
1050
  }
835
1051
  console.log(chalk.gray(`Resuming: ${resume.join(' ')} (cwd: ${cwd})`));
@@ -840,10 +1056,10 @@ async function handlePickedSession(picked) {
840
1056
  shell: false,
841
1057
  });
842
1058
  child.on('error', (err) => {
843
- if (err.code === 'ENOENT' && picked.session.version) {
844
- const fallback = buildFallbackCommand(picked.session);
1059
+ if (err.code === 'ENOENT' && session.version) {
1060
+ const fallback = buildFallbackCommand(session);
845
1061
  if (fallback) {
846
- console.log(chalk.gray(`Version ${picked.session.version} is not installed. Falling back to current version via /continue...`));
1062
+ console.log(chalk.gray(`Version ${session.version} is not installed. Falling back to current version via /continue...`));
847
1063
  const fb = spawn(fallback[0], fallback.slice(1), { cwd, stdio: 'inherit', shell: false });
848
1064
  fb.on('error', (e) => { console.error(chalk.red(`Failed: ${e.message}`)); resolve(); });
849
1065
  fb.on('close', () => resolve());
@@ -883,6 +1099,7 @@ export function buildResumeCommand(session) {
883
1099
  case 'opencode':
884
1100
  return ['opencode', '--session', session.id];
885
1101
  case 'gemini':
1102
+ case 'antigravity':
886
1103
  case 'openclaw':
887
1104
  case 'rush':
888
1105
  case 'hermes':
@@ -971,7 +1188,7 @@ async function runCloudSessions(query, options) {
971
1188
  // Ensure the SessionMeta points at the local cache path for renderSession.
972
1189
  await renderSession({ ...meta, filePath: cachedPath }, mode, filterOpts, options);
973
1190
  }
974
- function parseAgentFilter(agentName) {
1191
+ export function parseAgentFilter(agentName) {
975
1192
  if (!agentName)
976
1193
  return {};
977
1194
  const [name, version] = agentName.split('@', 2);
@@ -1264,8 +1481,10 @@ export function registerSessionsCommands(program) {
1264
1481
  .option('--artifacts', 'List all files written or edited during a session')
1265
1482
  .option('--artifact <name>', 'Read a specific artifact by filename or path (outputs to stdout)')
1266
1483
  .option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
1484
+ .option('--local', 'With --active: only this machine — skip the cross-machine SSH fan-out')
1267
1485
  .option('--waiting', 'With --active: show only sessions waiting on your input (exits non-zero if any)')
1268
1486
  .option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
1487
+ .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
1269
1488
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
1270
1489
  .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)');
1271
1490
  setHelpSections(sessionsCmd, {
@@ -1308,6 +1527,7 @@ export function registerSessionsCommands(program) {
1308
1527
  });
1309
1528
  registerSessionsTailCommand(sessionsCmd);
1310
1529
  registerSessionsSyncCommand(sessionsCmd);
1530
+ registerSessionsResumeCommand(sessionsCmd);
1311
1531
  }
1312
1532
  function formatNoSessionsMessage(showAll, project) {
1313
1533
  const projectQuery = project?.trim();
@@ -22,6 +22,9 @@ export interface ViewSectionFilter {
22
22
  promptcuts?: boolean;
23
23
  cli?: boolean;
24
24
  }
25
+ /** Trim a description to a column-friendly snippet. Strips newlines, collapses whitespace. */
26
+ export declare function summarizeDescription(desc: string | undefined, maxLen?: number): string;
27
+ export declare function descriptionForPrefix(desc: string | undefined, prefix: string): string;
25
28
  /** Machine-readable entry for a single installed version. */
26
29
  export interface ViewJsonVersion {
27
30
  version: string;
@@ -23,6 +23,7 @@ import { listProfiles, profileSummary } from '../lib/profiles.js';
23
23
  import { loadManifest, isStale } from '../lib/staleness/index.js';
24
24
  import { confirm } from '@inquirer/prompts';
25
25
  import { formatPath, isInteractiveTerminal, isPromptCancelled } from './utils.js';
26
+ import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
26
27
  // Shown in the email column for agents that are signed in but expose no email
27
28
  // address locally (Antigravity stores an opaque OAuth grant with no identity).
28
29
  const SIGNED_IN_LABEL = 'signed in';
@@ -67,6 +68,8 @@ function profileKindAndModel(model, planWidth) {
67
68
  return `${kind} ${model}`;
68
69
  }
69
70
  function termLink(text, filePath) {
71
+ if (!process.stdout.isTTY)
72
+ return text;
70
73
  const url = `file://${filePath}`;
71
74
  return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
72
75
  }
@@ -151,13 +154,18 @@ function shouldRenderSection(key, filter) {
151
154
  return filter[key] === true;
152
155
  }
153
156
  /** Trim a description to a column-friendly snippet. Strips newlines, collapses whitespace. */
154
- function summarizeDescription(desc, maxLen = 80) {
157
+ export function summarizeDescription(desc, maxLen = 80) {
155
158
  if (!desc)
156
159
  return '';
157
160
  const cleaned = desc.replace(/\s+/g, ' ').trim();
158
- if (cleaned.length <= maxLen)
159
- return cleaned;
160
- return cleaned.slice(0, maxLen - 1).trimEnd() + '…';
161
+ return truncateToWidth(cleaned, maxLen);
162
+ }
163
+ export function descriptionForPrefix(desc, prefix) {
164
+ if (!desc)
165
+ return '';
166
+ const visiblePrefix = prefix.replace(/\x1b]8;;[^\x1b]*(?:\x1b\\|\x07)/g, '');
167
+ const budget = Math.max(1, terminalWidth() - stringWidth(visiblePrefix));
168
+ return summarizeDescription(desc, budget);
161
169
  }
162
170
  function getProfileSummaries(filterAgentId) {
163
171
  return listProfiles()
@@ -223,8 +231,10 @@ function renderHostClisSection(cwd) {
223
231
  const status = installed ? chalk.green('installed') : chalk.red('missing ');
224
232
  const linkedName = termLink(manifest.name.padEnd(nameWidth), linkTarget(manifest.path));
225
233
  const tag = hostCliSourceTag(manifest.source);
226
- const desc = manifest.description ? chalk.gray(` ${summarizeDescription(manifest.description, 60)}`) : '';
227
- console.log(` ${status} ${chalk.cyan(linkedName)} ${tag}${desc}`);
234
+ const prefix = ` ${status} ${chalk.cyan(linkedName)} ${tag}`;
235
+ const descSnippet = descriptionForPrefix(manifest.description, `${prefix} `);
236
+ const desc = descSnippet ? chalk.gray(` ${descSnippet}`) : '';
237
+ console.log(prefix + desc);
228
238
  }
229
239
  if (anyMissing) {
230
240
  console.log(chalk.gray(' Install missing with `agents cli install`'));
@@ -771,9 +781,10 @@ async function showAgentResources(agentId, requestedVersion, filter) {
771
781
  : chalk.gray('[system]');
772
782
  display += ` ${sourceTag}`;
773
783
  const syncStr = r.syncState ? chalk.gray(` [${r.syncState}]`) : '';
774
- const descSnippet = summarizeDescription(r.description);
784
+ const prefix = ` ${display}${syncStr}`;
785
+ const descSnippet = descriptionForPrefix(r.description, `${prefix} `);
775
786
  const descStr = descSnippet ? chalk.gray(` ${descSnippet}`) : '';
776
- console.log(` ${display}${syncStr}${descStr}`);
787
+ console.log(prefix + descStr);
777
788
  }
778
789
  }
779
790
  // Render promptcuts (cross-agent, not per-version). Shortcuts are layered
@@ -11,4 +11,10 @@
11
11
  * agents-cli.secrets.wallet.<id> JSON {pan, cvc, cardholder}
12
12
  */
13
13
  import type { Command } from 'commander';
14
+ import { type CardMetadata } from '../lib/wallet/index.js';
15
+ /** Width of the id column (incl. its 2-space gap): full UUID >=104, short id >=80, none below. */
16
+ export declare function walletIdWidth(cols: number): number;
17
+ /** Nickname column width. Shrinks so the whole row fits even at the 60-col floor. */
18
+ export declare function walletNickWidth(cols: number): number;
19
+ export declare function renderRow(c: CardMetadata, cols?: number): string;
14
20
  export declare function registerWalletCommands(program: Command): void;
@@ -11,6 +11,7 @@
11
11
  * agents-cli.secrets.wallet.<id> JSON {pan, cvc, cardholder}
12
12
  */
13
13
  import chalk from 'chalk';
14
+ import { terminalWidth, truncateToWidth } from '../lib/session/width.js';
14
15
  import { addCard, detectBrand, isValidLuhn, listCards, removeCard, renameCard, showCard, } from '../lib/wallet/index.js';
15
16
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
16
17
  import { setHelpSections } from '../lib/help.js';
@@ -29,12 +30,25 @@ function brandLabel(b) {
29
30
  function formatExp(month, year) {
30
31
  return `${month}/${year.slice(-2)}`;
31
32
  }
32
- function renderRow(c) {
33
- const nick = c.nickname.padEnd(24);
33
+ /** Width of the id column (incl. its 2-space gap): full UUID >=104, short id >=80, none below. */
34
+ export function walletIdWidth(cols) {
35
+ return cols >= 104 ? 2 + 36 : cols >= 80 ? 2 + 8 : 0;
36
+ }
37
+ /** Nickname column width. Shrinks so the whole row fits even at the 60-col floor. */
38
+ export function walletNickWidth(cols) {
39
+ const rest = 2 + 1 + 18 + 1 + 10 + 1 + 9 + walletIdWidth(cols); // everything but the nickname
40
+ return Math.max(8, Math.min(24, cols - rest));
41
+ }
42
+ export function renderRow(c, cols = terminalWidth()) {
43
+ const nickW = walletNickWidth(cols);
44
+ const nick = truncateToWidth(c.nickname, nickW).padEnd(nickW);
34
45
  const brand = brandLabel(c.brand).padEnd(18);
35
46
  const last4 = `•••• ${c.last4}`.padEnd(10);
36
47
  const exp = formatExp(c.exp_month, c.exp_year);
37
- return ` ${chalk.cyan(nick)} ${brand} ${last4} ${chalk.gray('exp ' + exp)} ${chalk.gray(c.id)}`;
48
+ // Full 36-char UUID pushed rows to ~106 chars; size the id to the terminal.
49
+ const id = cols >= 104 ? c.id : cols >= 80 ? c.id.slice(0, 8) : '';
50
+ const idCol = id ? ` ${chalk.gray(id)}` : '';
51
+ return ` ${chalk.cyan(nick)} ${brand} ${last4} ${chalk.gray('exp ' + exp)}${idCol}`;
38
52
  }
39
53
  async function promptString(message, validate) {
40
54
  const { input } = await import('@inquirer/prompts');
@@ -131,9 +145,12 @@ export function registerWalletCommands(program) {
131
145
  console.log(chalk.gray('No cards in wallet. Try: agents wallet add'));
132
146
  return;
133
147
  }
134
- console.log(chalk.bold(` ${'NICKNAME'.padEnd(24)} ${'BRAND'.padEnd(18)} ${'LAST4'.padEnd(10)} EXP ID`));
148
+ const walletCols = terminalWidth();
149
+ const nickW = walletNickWidth(walletCols);
150
+ const idHeader = walletIdWidth(walletCols) ? ' ID' : '';
151
+ console.log(chalk.bold(` ${'NICKNAME'.padEnd(nickW)} ${'BRAND'.padEnd(18)} ${'LAST4'.padEnd(10)} EXP${idHeader}`));
135
152
  for (const c of cards)
136
- console.log(renderRow(c));
153
+ console.log(renderRow(c, walletCols));
137
154
  }
138
155
  catch (err) {
139
156
  console.error(chalk.red(err.message));