@phnx-labs/agents-cli 1.20.29 → 1.20.30

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 (45) hide show
  1. package/dist/commands/computer-actions.js +6 -2
  2. package/dist/commands/computer.d.ts +12 -0
  3. package/dist/commands/computer.js +88 -13
  4. package/dist/commands/inspect.js +1 -1
  5. package/dist/commands/models.js +8 -2
  6. package/dist/commands/sessions.js +156 -44
  7. package/dist/commands/sync.js +70 -14
  8. package/dist/lib/agents.d.ts +0 -4
  9. package/dist/lib/agents.js +54 -5
  10. package/dist/lib/browser/drivers/ssh.js +4 -35
  11. package/dist/lib/computer-rpc.d.ts +6 -1
  12. package/dist/lib/computer-rpc.js +86 -3
  13. package/dist/lib/exec.js +14 -0
  14. package/dist/lib/models.js +138 -5
  15. package/dist/lib/runner.js +7 -7
  16. package/dist/lib/session/active.d.ts +13 -0
  17. package/dist/lib/session/active.js +79 -18
  18. package/dist/lib/session/cloud.js +2 -0
  19. package/dist/lib/session/db.d.ts +11 -0
  20. package/dist/lib/session/db.js +62 -5
  21. package/dist/lib/session/discover.d.ts +5 -0
  22. package/dist/lib/session/discover.js +81 -0
  23. package/dist/lib/session/parse.d.ts +15 -0
  24. package/dist/lib/session/parse.js +22 -2
  25. package/dist/lib/session/remote.d.ts +1 -1
  26. package/dist/lib/session/remote.js +8 -3
  27. package/dist/lib/session/state.d.ts +82 -0
  28. package/dist/lib/session/state.js +221 -0
  29. package/dist/lib/session/tail.d.ts +18 -0
  30. package/dist/lib/session/tail.js +57 -0
  31. package/dist/lib/session/types.d.ts +9 -0
  32. package/dist/lib/session/width.d.ts +29 -0
  33. package/dist/lib/session/width.js +91 -0
  34. package/dist/lib/shims.d.ts +17 -1
  35. package/dist/lib/shims.js +130 -6
  36. package/dist/lib/ssh-tunnel.d.ts +127 -0
  37. package/dist/lib/ssh-tunnel.js +346 -0
  38. package/dist/lib/state.d.ts +2 -0
  39. package/dist/lib/state.js +17 -1
  40. package/dist/lib/teams/agents.d.ts +11 -1
  41. package/dist/lib/teams/agents.js +16 -2
  42. package/dist/lib/types.d.ts +1 -0
  43. package/dist/lib/versions.d.ts +19 -0
  44. package/dist/lib/versions.js +84 -24
  45. package/package.json +1 -1
@@ -170,11 +170,15 @@ function emit(result, json, human) {
170
170
  console.log(human());
171
171
  }
172
172
  }
173
- // Add the shared --pid/--bundle target options to a verb.
173
+ // Add the shared --pid/--bundle/--host target options to a verb. `--host` routes
174
+ // the verb at a remote Windows device: the `computer` preAction hook hydrates
175
+ // COMPUTER_HELPER_TCP from the tunnel `start --host` recorded, so withClient's
176
+ // openComputerClient() transparently selects the TCP transport.
174
177
  function addTargetOpts(cmd) {
175
178
  return cmd
176
179
  .option('--bundle <id>', 'Bundle id of the target app (default: frontmost allow-listed app)')
177
- .option('--pid <n>', 'Target pid directly (overrides --bundle)', (v) => parseInt(v, 10));
180
+ .option('--pid <n>', 'Target pid directly (overrides --bundle)', (v) => parseInt(v, 10))
181
+ .option('--host <device>', 'Drive a remote Windows device (requires `agents computer start --host <device>` first)');
178
182
  }
179
183
  // Add the shared --id/--x/--y element-or-coords options to a verb.
180
184
  function addElementOrCoordOpts(cmd) {
@@ -1,5 +1,17 @@
1
1
  import { Command } from 'commander';
2
2
  import { resolveHelperExec, resolveSocketPath } from '../lib/computer-rpc.js';
3
+ /**
4
+ * Pure platform gate. The computer subsystem is macOS-only for LOCAL driving
5
+ * (Accessibility / launchctl). It is NOT blocked off macOS when a remote daemon
6
+ * is reachable — either a configured TCP endpoint (COMPUTER_HELPER_TCP, e.g. a
7
+ * Windows daemon over a tunnel) or a `--host <device>` remote invocation. Kept
8
+ * pure so the gating rule is unit-testable without a live command tree.
9
+ */
10
+ export declare function shouldBlockOffPlatform(opts: {
11
+ platform: NodeJS.Platform;
12
+ tcpConfigured: boolean;
13
+ host?: string;
14
+ }): boolean;
3
15
  export declare function registerComputerCommand(program: Command): void;
4
16
  export declare function registerComputerSubcommands(program: Command): void;
5
17
  export { resolveHelperExec as resolveHelperPath };
@@ -3,7 +3,8 @@ import * as fs from 'fs';
3
3
  import * as os from 'os';
4
4
  import * as path from 'path';
5
5
  import { registerCommandGroups } from '../lib/help.js';
6
- import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer-rpc.js';
6
+ import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, resolveTcpEndpoint, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer-rpc.js';
7
+ import { setupRemoteHelper, startRemoteTunnel, stopRemoteHelper, hydrateRemoteEnvFromState, } from '../lib/ssh-tunnel.js';
7
8
  import { registerActionCommands, withClient, unwrap, pickTarget } from './computer-actions.js';
8
9
  // Help groups — mirror `agents browser` so the mental model carries over.
9
10
  const COMPUTER_HELP_GROUPS = [
@@ -12,15 +13,44 @@ const COMPUTER_HELP_GROUPS = [
12
13
  { title: 'Observe', names: ['apps', 'describe', 'screenshot', 'get-text'] },
13
14
  { title: 'Interact', names: ['launch', 'raise', 'click', 'right-click', 'type', 'type-text', 'key', 'drag', 'scroll', 'ax-action', 'focus', 'wait'] },
14
15
  ];
16
+ // Subcommands that manage the `--host` remote path themselves (provisioning /
17
+ // tunnel lifecycle). Every other `--host`-bearing subcommand is a plain verb
18
+ // that just needs the TCP endpoint hydrated before it runs.
19
+ const REMOTE_LIFECYCLE = new Set(['setup', 'start', 'stop']);
20
+ /**
21
+ * Pure platform gate. The computer subsystem is macOS-only for LOCAL driving
22
+ * (Accessibility / launchctl). It is NOT blocked off macOS when a remote daemon
23
+ * is reachable — either a configured TCP endpoint (COMPUTER_HELPER_TCP, e.g. a
24
+ * Windows daemon over a tunnel) or a `--host <device>` remote invocation. Kept
25
+ * pure so the gating rule is unit-testable without a live command tree.
26
+ */
27
+ export function shouldBlockOffPlatform(opts) {
28
+ if (opts.platform === 'darwin')
29
+ return false;
30
+ if (opts.tcpConfigured)
31
+ return false; // remote (Windows) daemon over a tunnel
32
+ if (opts.host)
33
+ return false; // remote path resolves its own endpoint
34
+ return true;
35
+ }
15
36
  export function registerComputerCommand(program) {
16
37
  const computer = program
17
38
  .command('computer')
18
- .description('Drive macOS apps via Accessibility — list, screenshot, click, type (macOS only)')
19
- // The whole subsystem is macOS Accessibility / TCC. Fail fast with a clear
20
- // message on other platforms instead of a downstream ENOENT / launchctl error.
21
- .hook('preAction', () => {
22
- if (process.platform !== 'darwin') {
23
- console.error('agents computer: macOS only it drives apps via the macOS Accessibility API.');
39
+ .description('Drive macOS apps via Accessibility, or a remote Windows host with --host — list, screenshot, click, type')
40
+ // The whole subsystem is macOS Accessibility / TCC for LOCAL driving. Off
41
+ // macOS it still works against a remote daemon (COMPUTER_HELPER_TCP set, or
42
+ // a `--host <device>` invocation). Fail fast with a clear message only when
43
+ // neither remote path is available, instead of a downstream launchctl error.
44
+ .hook('preAction', async (_thisCommand, actionCommand) => {
45
+ const host = actionCommand.opts().host;
46
+ // Verbs with --host reconnect to the tunnel `start --host` recorded; this
47
+ // sets COMPUTER_HELPER_TCP so the shared client picks the TCP transport.
48
+ if (host && !REMOTE_LIFECYCLE.has(actionCommand.name())) {
49
+ hydrateRemoteEnvFromState(host);
50
+ }
51
+ if (shouldBlockOffPlatform({ platform: process.platform, tcpConfigured: resolveTcpEndpoint() != null, host })) {
52
+ console.error('agents computer: macOS only for local driving — it uses the macOS Accessibility API.');
53
+ console.error('For a remote Windows host: register it with `agents devices`, then use --host (or set COMPUTER_HELPER_TCP).');
24
54
  process.exit(1);
25
55
  }
26
56
  });
@@ -94,6 +124,7 @@ function registerScreenshotCommand(program) {
94
124
  .description('Capture a window (default: largest), enumerate windows (--list), or the whole display (--display)')
95
125
  .option('--bundle <id>', 'Bundle id to capture (default: frontmost allow-listed app)')
96
126
  .option('--pid <n>', 'Target pid directly (overrides --bundle)', (v) => parseInt(v, 10))
127
+ .option('--host <device>', 'Drive a remote Windows device (requires `agents computer start --host <device>` first)')
97
128
  .option('--list', 'List the app\'s windows (id/title/layer/bounds) instead of capturing — reveals modals/popups')
98
129
  .option('--window-id <n>', 'Capture a specific window by id (from --list)', (v) => parseInt(v, 10))
99
130
  .option('--display', 'Capture the whole display the app is on (composites stacked modals)')
@@ -181,8 +212,23 @@ function registerSetupCommand(program) {
181
212
  program
182
213
  .command('setup')
183
214
  .alias('install-helper')
184
- .description('Install ComputerHelper.app to /Applications/ (does NOT activate the daemon run `start` to enable)')
185
- .action(async () => {
215
+ .description('Install the helper — locally to /Applications/ (macOS), or to a remote Windows host with --host')
216
+ .option('--host <device>', 'Provision a remote Windows device (push the exe + register a LOGON task) instead of installing locally')
217
+ .action(async (opts) => {
218
+ if (opts.host) {
219
+ try {
220
+ const { target, taskName } = await setupRemoteHelper(opts.host);
221
+ console.log(`pushed computer-helper-win.exe to ${target}`);
222
+ console.log(`registered LOGON scheduled task "${taskName}" (interactive session, started now)`);
223
+ console.log('');
224
+ console.log(`Next: agents computer start --host ${opts.host}`);
225
+ }
226
+ catch (err) {
227
+ console.error(`error: ${err.message}`);
228
+ process.exit(1);
229
+ }
230
+ return;
231
+ }
186
232
  const srcApp = resolveHelperApp();
187
233
  if (!srcApp || !fs.existsSync(srcApp)) {
188
234
  console.error('helper not built. Run: ./packages/computer-helper/scripts/build.sh debug');
@@ -266,8 +312,24 @@ function registerSetupCommand(program) {
266
312
  function registerStartCommand(program) {
267
313
  program
268
314
  .command('start')
269
- .description('Activate the helper daemon (loads launchd, opens socket)')
270
- .action(async () => {
315
+ .description('Activate the helper daemon local launchd (macOS) or a remote Windows tunnel with --host')
316
+ .option('--host <device>', 'Open a tunnel to the remote Windows daemon and record it for --host verbs')
317
+ .action(async (opts) => {
318
+ if (opts.host) {
319
+ try {
320
+ const state = await startRemoteTunnel(opts.host);
321
+ console.log(`tunnel: 127.0.0.1:${state.localPort} -> ${state.target} (127.0.0.1:${state.remotePort})`);
322
+ console.log(`daemon: answering (ssh pid ${state.tunnelPid})`);
323
+ console.log('');
324
+ console.log(`Drive it: agents computer apps --host ${opts.host}`);
325
+ console.log(`Stop: agents computer stop --host ${opts.host}`);
326
+ }
327
+ catch (err) {
328
+ console.error(`error: ${err.message}`);
329
+ process.exit(1);
330
+ }
331
+ return;
332
+ }
271
333
  const home = os.homedir();
272
334
  const plistPath = path.join(home, 'Library', 'LaunchAgents', `${HELPER_LABEL}.plist`);
273
335
  const socketPath = resolveSocketPath();
@@ -440,8 +502,21 @@ function registerReloadCommand(program) {
440
502
  function registerStopCommand(program) {
441
503
  program
442
504
  .command('stop')
443
- .description('Deactivate the helper daemon (bootout, removes socket)')
444
- .action(async () => {
505
+ .description('Deactivate the helper daemon — local launchd (macOS) or a remote Windows tunnel with --host')
506
+ .option('--host <device>', 'Tear down the remote tunnel and unregister the scheduled task')
507
+ .action(async (opts) => {
508
+ if (opts.host) {
509
+ try {
510
+ const { tunnelKilled, taskRemoved } = await stopRemoteHelper(opts.host);
511
+ console.log(`tunnel: ${tunnelKilled ? 'closed' : 'not running'}`);
512
+ console.log(`task: ${taskRemoved ? 'unregistered' : 'not removed (device offline?)'}`);
513
+ }
514
+ catch (err) {
515
+ console.error(`error: ${err.message}`);
516
+ process.exit(1);
517
+ }
518
+ return;
519
+ }
445
520
  const home = os.homedir();
446
521
  const plistPath = path.join(home, 'Library', 'LaunchAgents', `${HELPER_LABEL}.plist`);
447
522
  const socketPath = resolveSocketPath();
@@ -1217,7 +1217,7 @@ function safeStat(p) {
1217
1217
  }
1218
1218
  }
1219
1219
  const SESSION_AGENTS = new Set([
1220
- 'claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi',
1220
+ 'claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid',
1221
1221
  ]);
1222
1222
  function safeCountSessions(agent) {
1223
1223
  if (!SESSION_AGENTS.has(agent))
@@ -11,7 +11,7 @@ import { homeDir } from '../lib/platform/index.js';
11
11
  import { resolveAgentName, formatAgentError, agentLabel, } from '../lib/agents.js';
12
12
  import { listInstalledVersions, getGlobalDefault, resolveVersion, resolveVersionAlias } from '../lib/versions.js';
13
13
  import { getModelCatalog, locateModelSource } from '../lib/models.js';
14
- const MODEL_CAPABLE_AGENTS = ['claude', 'codex', 'gemini', 'opencode', 'cursor', 'openclaw'];
14
+ const MODEL_CAPABLE_AGENTS = ['claude', 'codex', 'gemini', 'opencode', 'cursor', 'openclaw', 'antigravity', 'kimi'];
15
15
  /**
16
16
  * Agents that don't necessarily install under ~/.agents/versions (cursor ships
17
17
  * via a curl script). For these, fall back to the PATH binary and synthesize
@@ -72,8 +72,14 @@ async function resolveTargets(agentSpec) {
72
72
  if (!version && PATH_ONLY_AGENTS.has(agent)) {
73
73
  version = fallbackPathVersion(agent);
74
74
  }
75
- if (version)
75
+ if (version) {
76
76
  targets.push({ agent, version, isDefault: true });
77
+ }
78
+ else {
79
+ // Surface the gap instead of silently dropping the agent -- an
80
+ // uninstalled model-capable agent should tell the user how to add it.
81
+ console.error(chalk.gray(`${agentLabel(agent)}: not installed (run 'agents add ${agent}@latest')`));
82
+ }
77
83
  }
78
84
  if (targets.length === 0) {
79
85
  console.error(chalk.yellow('No installed agent versions found. Run `agents add claude@latest` to install one.'));
@@ -17,6 +17,7 @@ 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 { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
20
21
  import { discoverSessions, countSessionsInScope, resolveSessionById, searchContentIndex } from '../lib/session/discover.js';
21
22
  import { filterTeamSessions } from '../lib/session/team-filter.js';
22
23
  import { parseSession } from '../lib/session/parse.js';
@@ -162,43 +163,77 @@ function formatStartedAt(startedAtMs) {
162
163
  return '-';
163
164
  return formatRelativeTime(new Date(startedAtMs).toISOString());
164
165
  }
165
- /** Build a display-friendly description for an active session (label or topic). */
166
+ /**
167
+ * Build the live description for an active session: prefer the state engine's
168
+ * preview (the latest turn), then a user label, then the first-prompt topic.
169
+ */
166
170
  function buildSessionDescription(s) {
167
171
  if (s.context === 'cloud') {
168
- return `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`;
172
+ return s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`;
169
173
  }
170
174
  if (s.context === 'teams') {
171
175
  const parts = [s.teamName];
172
- if (s.label)
176
+ if (s.preview)
177
+ parts.push(s.preview);
178
+ else if (s.label)
173
179
  parts.push(s.label);
174
180
  else if (s.topic)
175
181
  parts.push(s.topic);
176
182
  return parts.filter(Boolean).join(' · ');
177
183
  }
178
- // Terminal or headless: prefer label, then topic
179
- if (s.label)
180
- return s.label;
181
- if (s.topic)
182
- return s.topic;
183
- return '';
184
+ // Terminal or headless: prefer the live preview, then label, then topic.
185
+ return s.preview || s.label || s.topic || '';
186
+ }
187
+ /** Short human word for a session's activity (falls back to the coarse status). */
188
+ function activityLabel(s) {
189
+ if (s.activity === 'waiting_input')
190
+ return 'waiting';
191
+ if (s.activity === 'working')
192
+ return 'working';
193
+ if (s.activity === 'idle')
194
+ return 'idle';
195
+ return s.status === 'input_required' ? 'waiting' : s.status;
196
+ }
197
+ /**
198
+ * Compact, colour-coded badges for the durable/awaiting signals. Text-only (no
199
+ * emoji, per repo convention): `plan` / `ask` / `perm` for why it's waiting,
200
+ * `PR#N`, `wt:slug`, `TICKET-123`.
201
+ */
202
+ function signalBadges(s) {
203
+ const parts = [];
204
+ if (s.awaitingReason === 'plan_review')
205
+ parts.push(chalk.yellow('plan'));
206
+ else if (s.awaitingReason === 'question')
207
+ parts.push(chalk.yellow('ask'));
208
+ else if (s.awaitingReason === 'permission')
209
+ parts.push(chalk.yellow('perm'));
210
+ if (s.ticket)
211
+ parts.push(chalk.cyan(s.ticket.id));
212
+ if (s.pr)
213
+ parts.push(chalk.blue(`PR#${s.pr.number ?? '?'}`));
214
+ if (s.worktree)
215
+ parts.push(chalk.magenta(`wt:${s.worktree.slug}`));
216
+ return parts.join(' ');
184
217
  }
185
218
  /**
186
219
  * Render a single agent-session row inside an already-printed group header.
187
220
  * Indent is the leading whitespace (2 spaces for flat groups, 4 inside a
188
- * window sub-group).
221
+ * window sub-group). Leads with the 8-char session id (the address to read or
222
+ * resume it); status, badges, and the live preview fill the rest, sized to the
223
+ * terminal width so the row never wraps.
189
224
  */
190
225
  function printActiveRow(s, indent) {
191
- const kindCol = colorAgent(s.kind)(padRight(truncate(s.kind, 8), 9));
192
- const hostCol = chalk.gray(padRight(truncate(s.host ?? '-', 8), 9));
193
- const statusCol = statusColor(s.status)(padRight(truncate(s.status, 7), 8));
194
- const pidCol = chalk.yellow(padRight(s.pid ? String(s.pid) : '-', 7));
195
- const desc = buildSessionDescription(s);
196
- console.log(indent +
197
- pidCol +
198
- kindCol +
199
- hostCol +
200
- statusCol +
201
- chalk.white(truncate(desc || '-', 50)));
226
+ const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', 9));
227
+ const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, 8), 9));
228
+ const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
229
+ const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
230
+ const badges = signalBadges(s);
231
+ const desc = buildSessionDescription(s) || '-';
232
+ // Fill the remaining width with the preview so nothing wraps under tmux/SSH.
233
+ const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
234
+ const room = Math.max(12, terminalWidth() - fixed - 1);
235
+ const descCol = chalk.white(truncateToWidth(desc, room));
236
+ console.log(indent + idCol + kindCol + hostCol + statusCol + (badges ? badges + ' ' : '') + descCol);
202
237
  }
203
238
  /**
204
239
  * Short label for an IDE window. The slice key in live-terminals.json is
@@ -266,14 +301,21 @@ export function groupActiveSessions(sessions) {
266
301
  return { workspaces };
267
302
  }
268
303
  /** Render the unified active-session view. */
269
- async function renderActiveSessions(asJson) {
270
- const sessions = await getActiveSessions();
304
+ async function renderActiveSessions(asJson, waitingOnly = false) {
305
+ const all = await getActiveSessions();
306
+ // --waiting: only sessions blocked on the user. Exits non-zero when any are
307
+ // present so a supervising agent or hook can poll it as a gate.
308
+ const sessions = waitingOnly
309
+ ? all.filter(s => s.status === 'input_required')
310
+ : all;
271
311
  if (asJson) {
272
312
  process.stdout.write(JSON.stringify(sessions, null, 2) + '\n');
313
+ if (waitingOnly && sessions.length > 0)
314
+ process.exitCode = 1;
273
315
  return;
274
316
  }
275
317
  if (sessions.length === 0) {
276
- console.log(chalk.gray('No active agent sessions.'));
318
+ console.log(chalk.gray(waitingOnly ? 'No sessions waiting on input.' : 'No active agent sessions.'));
277
319
  return;
278
320
  }
279
321
  const layout = groupActiveSessions(sessions);
@@ -312,6 +354,9 @@ async function renderActiveSessions(asJson) {
312
354
  if (queuedCount > 0)
313
355
  parts.push(`${queuedCount} queued`);
314
356
  console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}).`));
357
+ // Scriptable gate: a non-zero exit when anything is waiting on the user.
358
+ if (waitingOnly && sessions.length > 0)
359
+ process.exitCode = 1;
315
360
  }
316
361
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
317
362
  async function sessionsAction(query, options) {
@@ -326,7 +371,7 @@ async function sessionsAction(query, options) {
326
371
  return;
327
372
  }
328
373
  if (options.active) {
329
- await renderActiveSessions(options.json === true);
374
+ await renderActiveSessions(options.json === true, options.waiting === true);
330
375
  return;
331
376
  }
332
377
  if (options.cloud) {
@@ -455,7 +500,9 @@ async function sessionsAction(query, options) {
455
500
  }
456
501
  return;
457
502
  }
458
- if (isInteractiveTerminal()) {
503
+ // --tree is a printed grouped listing, not an interactive pick — render it
504
+ // directly even in a TTY.
505
+ if (isInteractiveTerminal() && !options.tree) {
459
506
  const message = pathFilter
460
507
  ? `Search sessions (${path.basename(pathFilter)}):`
461
508
  : formatSearchMessage(options);
@@ -468,7 +515,7 @@ async function sessionsAction(query, options) {
468
515
  }
469
516
  // Non-interactive fallback (piped output)
470
517
  const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
471
- printSessionTable(filtered, hiddenCount);
518
+ printSessionTable(filtered, hiddenCount, options.tree === true);
472
519
  }
473
520
  catch (err) {
474
521
  tracker.stop();
@@ -487,22 +534,83 @@ function teamTag(session) {
487
534
  const parts = [origin.handle, origin.mode].filter(Boolean).join(' · ');
488
535
  return parts ? `[${parts}] ` : '[team] ';
489
536
  }
490
- function printSessionTable(sessions, hiddenCount = 0) {
491
- for (const session of sessions) {
492
- const agentColor = colorAgent(session.agent);
493
- const when = formatRelativeTime(session.timestamp);
494
- const project = session.project || '-';
495
- const tag = teamTag(session);
496
- const label = session.label;
497
- const topic = tag ? `${tag}${session.topic ?? ''}` : session.topic;
498
- const versionStr = session.version || '-';
499
- console.log(chalk.white(padRight(session.shortId, 10)) +
500
- agentColor(padRight(truncate(session.agent, 8), 9)) +
501
- chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
502
- chalk.cyan(padRight(truncate(project, 14), 16)) +
503
- renderTopicCell(label, topic, '', 48, 50) +
504
- chalk.gray(when));
537
+ /** Adapt a SessionMeta's persisted signals to the badge renderer's shape. */
538
+ function metaSignals(s) {
539
+ return {
540
+ pr: s.prUrl ? { url: s.prUrl, number: s.prNumber } : undefined,
541
+ worktree: s.worktreeSlug ? { path: s.cwd ?? '', slug: s.worktreeSlug } : undefined,
542
+ ticket: s.ticketId ? { id: s.ticketId } : undefined,
543
+ };
544
+ }
545
+ /** One flat table row: shortId · agent · version · project · topic(+badges) · time. */
546
+ function flatSessionRow(session) {
547
+ const agentColor = colorAgent(session.agent);
548
+ const when = formatRelativeTime(session.timestamp);
549
+ const project = session.project || '-';
550
+ const tag = teamTag(session);
551
+ const label = session.label;
552
+ const topic = tag ? `${tag}${session.topic ?? ''}` : session.topic;
553
+ const versionStr = session.version || '-';
554
+ const badges = signalBadges(metaSignals(session));
555
+ const badgeW = badges ? stringWidth(badges) + 1 : 0;
556
+ const topicW = Math.max(16, terminalWidth() - (10 + 9 + 8 + 16) - badgeW - stringWidth(when) - 1);
557
+ return (chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
558
+ agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
559
+ chalk.yellow(padToWidth(truncateToWidth(versionStr, 7), 8)) +
560
+ chalk.cyan(padToWidth(truncateToWidth(project, 14), 16)) +
561
+ renderTopicCell(label, topic, '', topicW, topicW) +
562
+ (badges ? badges + ' ' : '') +
563
+ chalk.gray(when));
564
+ }
565
+ /** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
566
+ function treeSessionRow(session) {
567
+ const agentColor = colorAgent(session.agent);
568
+ const when = formatRelativeTime(session.timestamp);
569
+ const tag = teamTag(session);
570
+ const label = session.label;
571
+ const topic = (tag ? `${tag}${session.topic ?? ''}` : session.topic) || '-';
572
+ const badges = signalBadges(metaSignals(session));
573
+ const badgeW = badges ? stringWidth(badges) + 1 : 0;
574
+ const head = label ? `${label} · ${topic}` : topic;
575
+ const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - badgeW - stringWidth(when) - 1);
576
+ return (' ' +
577
+ chalk.dim(padToWidth(session.shortId, 9)) +
578
+ agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
579
+ (badges ? badges + ' ' : '') +
580
+ padToWidth(chalk.white(truncateToWidth(head, topicW)), topicW) +
581
+ ' ' + chalk.gray(when));
582
+ }
583
+ function printSessionTable(sessions, hiddenCount = 0, tree = false) {
584
+ if (tree) {
585
+ // Group by directory; drop the id/version columns from view. The short id
586
+ // stays as each row's leading handle (the address to read/resume it).
587
+ const byDir = new Map();
588
+ for (const s of sessions) {
589
+ const key = s.cwd || s.project || 'unknown';
590
+ (byDir.get(key) ?? byDir.set(key, []).get(key)).push(s);
591
+ }
592
+ const keys = [...byDir.keys()].sort((a, b) => {
593
+ const d = byDir.get(b).length - byDir.get(a).length;
594
+ return d !== 0 ? d : a.localeCompare(b);
595
+ });
596
+ let first = true;
597
+ for (const key of keys) {
598
+ if (!first)
599
+ console.log();
600
+ first = false;
601
+ const group = byDir.get(key);
602
+ console.log(`${chalk.cyan.bold(shortCwd(key))} ${chalk.gray(`(${group.length})`)}`);
603
+ for (const s of group)
604
+ console.log(treeSessionRow(s));
605
+ }
606
+ const dirWord = keys.length === 1 ? 'directory' : 'directories';
607
+ console.log(chalk.gray(`\n${sessions.length} session${sessions.length === 1 ? '' : 's'} across ${keys.length} ${dirWord}.`));
608
+ if (hiddenCount > 0)
609
+ console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
610
+ return;
505
611
  }
612
+ for (const session of sessions)
613
+ console.log(flatSessionRow(session));
506
614
  const countLine = `${sessions.length} session${sessions.length === 1 ? '' : 's'}.`;
507
615
  console.log(chalk.gray(`\n${countLine}`));
508
616
  if (hiddenCount > 0) {
@@ -608,8 +716,10 @@ function renderTopicCell(label, topic, query, visibleWidth, paddedWidth) {
608
716
  const tpc = (topic ?? '').trim();
609
717
  const sep = ' · ';
610
718
  const raw = lbl && tpc ? `${lbl}${sep}${tpc}` : (lbl || tpc);
611
- const visible = truncate(raw, visibleWidth);
612
- const padding = ' '.repeat(Math.max(0, paddedWidth - visible.length));
719
+ // Width-aware: measure/truncate/pad by display cells, not String.length, so
720
+ // ANSI escapes and wide (CJK/emoji) glyphs don't drift the column.
721
+ const visible = truncateToWidth(raw, visibleWidth);
722
+ const padding = ' '.repeat(Math.max(0, paddedWidth - stringWidth(visible)));
613
723
  const labelEnd = lbl ? Math.min(lbl.length, visible.length) : 0;
614
724
  let matchStart = -1, matchEnd = -1;
615
725
  const q = query.trim().toLowerCase();
@@ -1129,6 +1239,8 @@ export function registerSessionsCommands(program) {
1129
1239
  .option('--artifacts', 'List all files written or edited during a session')
1130
1240
  .option('--artifact <name>', 'Read a specific artifact by filename or path (outputs to stdout)')
1131
1241
  .option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
1242
+ .option('--waiting', 'With --active: show only sessions waiting on your input (exits non-zero if any)')
1243
+ .option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
1132
1244
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
1133
1245
  .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)');
1134
1246
  setHelpSections(sessionsCmd, {