@phnx-labs/agents-cli 1.20.29 → 1.20.31

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 (58) 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-picker.js +35 -10
  7. package/dist/commands/sessions.js +164 -44
  8. package/dist/commands/setup.js +8 -0
  9. package/dist/commands/ssh.js +123 -15
  10. package/dist/commands/sync.js +70 -14
  11. package/dist/lib/agents.d.ts +0 -4
  12. package/dist/lib/agents.js +122 -22
  13. package/dist/lib/browser/drivers/ssh.js +4 -35
  14. package/dist/lib/computer-rpc.d.ts +6 -1
  15. package/dist/lib/computer-rpc.js +86 -3
  16. package/dist/lib/devices/registry.d.ts +11 -0
  17. package/dist/lib/devices/registry.js +53 -1
  18. package/dist/lib/devices/sync.d.ts +42 -0
  19. package/dist/lib/devices/sync.js +85 -0
  20. package/dist/lib/exec.js +14 -0
  21. package/dist/lib/models.js +138 -5
  22. package/dist/lib/runner.js +7 -7
  23. package/dist/lib/session/active.d.ts +15 -0
  24. package/dist/lib/session/active.js +108 -19
  25. package/dist/lib/session/cloud.js +2 -0
  26. package/dist/lib/session/db.d.ts +11 -0
  27. package/dist/lib/session/db.js +62 -5
  28. package/dist/lib/session/digest.d.ts +50 -0
  29. package/dist/lib/session/digest.js +170 -0
  30. package/dist/lib/session/discover.d.ts +5 -0
  31. package/dist/lib/session/discover.js +81 -0
  32. package/dist/lib/session/parse.d.ts +15 -0
  33. package/dist/lib/session/parse.js +22 -2
  34. package/dist/lib/session/remote.d.ts +1 -1
  35. package/dist/lib/session/remote.js +8 -3
  36. package/dist/lib/session/render.d.ts +2 -0
  37. package/dist/lib/session/render.js +83 -10
  38. package/dist/lib/session/state.d.ts +82 -0
  39. package/dist/lib/session/state.js +221 -0
  40. package/dist/lib/session/tail.d.ts +18 -0
  41. package/dist/lib/session/tail.js +57 -0
  42. package/dist/lib/session/types.d.ts +9 -0
  43. package/dist/lib/session/width.d.ts +29 -0
  44. package/dist/lib/session/width.js +91 -0
  45. package/dist/lib/shims.d.ts +17 -1
  46. package/dist/lib/shims.js +130 -6
  47. package/dist/lib/ssh-tunnel.d.ts +127 -0
  48. package/dist/lib/ssh-tunnel.js +346 -0
  49. package/dist/lib/state.d.ts +4 -0
  50. package/dist/lib/state.js +19 -1
  51. package/dist/lib/sync-umbrella.d.ts +5 -0
  52. package/dist/lib/sync-umbrella.js +10 -0
  53. package/dist/lib/teams/agents.d.ts +11 -1
  54. package/dist/lib/teams/agents.js +16 -2
  55. package/dist/lib/types.d.ts +1 -0
  56. package/dist/lib/versions.d.ts +19 -0
  57. package/dist/lib/versions.js +84 -24
  58. 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.'));
@@ -11,6 +11,7 @@ import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.j
11
11
  import { linkPath, relativeToCwd } from '../lib/session/render.js';
12
12
  import { renderMarkdown } from '../lib/markdown.js';
13
13
  import { itemPicker } from '../lib/picker.js';
14
+ import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
14
15
  /**
15
16
  * SessionMeta originates in discover.ts (gitBranch, cwd, label, etc. read from
16
17
  * untrusted session files). parseSession sanitizes event payloads at its
@@ -176,8 +177,8 @@ const TODOS_MAX_ITEMS = 5;
176
177
  function formatCompactPreview(events, session) {
177
178
  let firstUser = '';
178
179
  let lastAssistant = '';
179
- const filesModified = new Set();
180
180
  const filesRead = new Set();
181
+ const toolCounts = {};
181
182
  let toolCalls = 0;
182
183
  let planFile = '';
183
184
  let latestTodos = null;
@@ -195,10 +196,7 @@ function formatCompactPreview(events, session) {
195
196
  else if (event.type === 'tool_use' && !event._local) {
196
197
  const tool = event.tool || '';
197
198
  const p = event.path || event.args?.file_path || event.args?.path || '';
198
- if (['Write', 'Edit', 'write_file', 'edit_file', 'create_file', 'replace', 'patch'].includes(tool) && p) {
199
- filesModified.add(p);
200
- }
201
- else if (['Read', 'read_file', 'view_file', 'cat_file', 'get_file'].includes(tool) && p) {
199
+ if (['Read', 'read_file', 'view_file', 'cat_file', 'get_file'].includes(tool) && p) {
202
200
  filesRead.add(p);
203
201
  }
204
202
  if (!planFile && p && /\/plans\/[^/]+\.md$/.test(p)) {
@@ -207,9 +205,14 @@ function formatCompactPreview(events, session) {
207
205
  if (tool === 'TodoWrite' && Array.isArray(event.args?.todos)) {
208
206
  latestTodos = event.args.todos;
209
207
  }
208
+ if (tool)
209
+ toolCounts[tool] = (toolCounts[tool] ?? 0) + 1;
210
210
  toolCalls++;
211
211
  }
212
212
  }
213
+ // Digest signals folded into the preview: change lifecycle, tool mix, tests.
214
+ const changes = classifyFileChanges(events);
215
+ const chg = changeCounts(changes);
213
216
  const lines = [];
214
217
  const termWidth = process.stdout.columns || 80;
215
218
  if (firstUser) {
@@ -219,14 +222,36 @@ function formatCompactPreview(events, session) {
219
222
  }
220
223
  }
221
224
  const activity = [];
222
- if (filesModified.size)
223
- activity.push(`${filesModified.size} modified`);
225
+ const changed = chg.created + chg.modified + chg.deleted;
226
+ if (changed) {
227
+ const parts = [
228
+ chg.created ? chalk.green(`+${chg.created}`) : '',
229
+ chg.modified ? chalk.yellow(`~${chg.modified}`) : '',
230
+ chg.deleted ? chalk.red(`−${chg.deleted}`) : '',
231
+ ].filter(Boolean).join(' ');
232
+ activity.push(`${parts} ${chalk.gray('changed')}`);
233
+ }
224
234
  if (filesRead.size)
225
- activity.push(`${filesRead.size} read`);
235
+ activity.push(chalk.gray(`${filesRead.size} read`));
226
236
  if (toolCalls)
227
- activity.push(`${toolCalls} tool${toolCalls === 1 ? '' : 's'}`);
237
+ activity.push(chalk.gray(`${toolCalls} tool${toolCalls === 1 ? '' : 's'}`));
228
238
  if (activity.length) {
229
- lines.push(chalk.cyan('Activity: ') + chalk.gray(activity.join(' · ')));
239
+ lines.push(chalk.cyan('Changes: ') + activity.join(chalk.gray(' · ')));
240
+ }
241
+ // Tool mix (top 4) — what kind of work this was.
242
+ const hist = toolHistogram(toolCounts, 4);
243
+ if (hist.length) {
244
+ lines.push(chalk.cyan('Tools: ') + chalk.gray(hist.map(h => `${h.tool} ${h.count}`).join(' · ')));
245
+ }
246
+ // Last test/build verdict.
247
+ const test = detectTestResult(events);
248
+ if (test?.ok) {
249
+ const bits = [
250
+ test.passed !== undefined ? chalk.green(`${test.passed} pass`) : '',
251
+ test.failed ? chalk.red(`${test.failed} fail`) : '',
252
+ ].filter(Boolean).join(chalk.gray(' · '));
253
+ const mark = test.failed ? chalk.red('✗') : chalk.green('✓');
254
+ lines.push(chalk.cyan('Tests: ') + `${mark} ${test.runner}${bits ? ' ' + bits : ''}`);
230
255
  }
231
256
  if (planFile) {
232
257
  const basename = planFile.split('/').pop() || planFile;
@@ -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,78 @@ 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 fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
231
+ const badges = (fork ? fork : '') + signalBadges(s);
232
+ const desc = buildSessionDescription(s) || '-';
233
+ // Fill the remaining width with the preview so nothing wraps under tmux/SSH.
234
+ const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
235
+ const room = Math.max(12, terminalWidth() - fixed - 1);
236
+ const descCol = chalk.white(truncateToWidth(desc, room));
237
+ console.log(indent + idCol + kindCol + hostCol + statusCol + (badges ? badges + ' ' : '') + descCol);
202
238
  }
203
239
  /**
204
240
  * Short label for an IDE window. The slice key in live-terminals.json is
@@ -266,14 +302,21 @@ export function groupActiveSessions(sessions) {
266
302
  return { workspaces };
267
303
  }
268
304
  /** Render the unified active-session view. */
269
- async function renderActiveSessions(asJson) {
270
- const sessions = await getActiveSessions();
305
+ async function renderActiveSessions(asJson, waitingOnly = false) {
306
+ const all = await getActiveSessions();
307
+ // --waiting: only sessions blocked on the user. Exits non-zero when any are
308
+ // present so a supervising agent or hook can poll it as a gate.
309
+ const sessions = waitingOnly
310
+ ? all.filter(s => s.status === 'input_required')
311
+ : all;
271
312
  if (asJson) {
272
313
  process.stdout.write(JSON.stringify(sessions, null, 2) + '\n');
314
+ if (waitingOnly && sessions.length > 0)
315
+ process.exitCode = 1;
273
316
  return;
274
317
  }
275
318
  if (sessions.length === 0) {
276
- console.log(chalk.gray('No active agent sessions.'));
319
+ console.log(chalk.gray(waitingOnly ? 'No sessions waiting on input.' : 'No active agent sessions.'));
277
320
  return;
278
321
  }
279
322
  const layout = groupActiveSessions(sessions);
@@ -312,6 +355,9 @@ async function renderActiveSessions(asJson) {
312
355
  if (queuedCount > 0)
313
356
  parts.push(`${queuedCount} queued`);
314
357
  console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}).`));
358
+ // Scriptable gate: a non-zero exit when anything is waiting on the user.
359
+ if (waitingOnly && sessions.length > 0)
360
+ process.exitCode = 1;
315
361
  }
316
362
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
317
363
  async function sessionsAction(query, options) {
@@ -326,7 +372,7 @@ async function sessionsAction(query, options) {
326
372
  return;
327
373
  }
328
374
  if (options.active) {
329
- await renderActiveSessions(options.json === true);
375
+ await renderActiveSessions(options.json === true, options.waiting === true);
330
376
  return;
331
377
  }
332
378
  if (options.cloud) {
@@ -455,7 +501,9 @@ async function sessionsAction(query, options) {
455
501
  }
456
502
  return;
457
503
  }
458
- if (isInteractiveTerminal()) {
504
+ // --tree is a printed grouped listing, not an interactive pick — render it
505
+ // directly even in a TTY.
506
+ if (isInteractiveTerminal() && !options.tree) {
459
507
  const message = pathFilter
460
508
  ? `Search sessions (${path.basename(pathFilter)}):`
461
509
  : formatSearchMessage(options);
@@ -468,7 +516,7 @@ async function sessionsAction(query, options) {
468
516
  }
469
517
  // Non-interactive fallback (piped output)
470
518
  const filtered = searchQuery ? filterSessionsByQuery(sessions, searchQuery) : sessions;
471
- printSessionTable(filtered, hiddenCount);
519
+ printSessionTable(filtered, hiddenCount, options.tree === true);
472
520
  }
473
521
  catch (err) {
474
522
  tracker.stop();
@@ -487,22 +535,83 @@ function teamTag(session) {
487
535
  const parts = [origin.handle, origin.mode].filter(Boolean).join(' · ');
488
536
  return parts ? `[${parts}] ` : '[team] ';
489
537
  }
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));
538
+ /** Adapt a SessionMeta's persisted signals to the badge renderer's shape. */
539
+ function metaSignals(s) {
540
+ return {
541
+ pr: s.prUrl ? { url: s.prUrl, number: s.prNumber } : undefined,
542
+ worktree: s.worktreeSlug ? { path: s.cwd ?? '', slug: s.worktreeSlug } : undefined,
543
+ ticket: s.ticketId ? { id: s.ticketId } : undefined,
544
+ };
545
+ }
546
+ /** One flat table row: shortId · agent · version · project · topic(+badges) · time. */
547
+ function flatSessionRow(session) {
548
+ const agentColor = colorAgent(session.agent);
549
+ const when = formatRelativeTime(session.timestamp);
550
+ const project = session.project || '-';
551
+ const tag = teamTag(session);
552
+ const label = session.label;
553
+ const topic = tag ? `${tag}${session.topic ?? ''}` : session.topic;
554
+ const versionStr = session.version || '-';
555
+ const badges = signalBadges(metaSignals(session));
556
+ const badgeW = badges ? stringWidth(badges) + 1 : 0;
557
+ const topicW = Math.max(16, terminalWidth() - (10 + 9 + 8 + 16) - badgeW - stringWidth(when) - 1);
558
+ return (chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
559
+ agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
560
+ chalk.yellow(padToWidth(truncateToWidth(versionStr, 7), 8)) +
561
+ chalk.cyan(padToWidth(truncateToWidth(project, 14), 16)) +
562
+ renderTopicCell(label, topic, '', topicW, topicW) +
563
+ (badges ? badges + ' ' : '') +
564
+ chalk.gray(when));
565
+ }
566
+ /** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
567
+ function treeSessionRow(session) {
568
+ const agentColor = colorAgent(session.agent);
569
+ const when = formatRelativeTime(session.timestamp);
570
+ const tag = teamTag(session);
571
+ const label = session.label;
572
+ const topic = (tag ? `${tag}${session.topic ?? ''}` : session.topic) || '-';
573
+ const badges = signalBadges(metaSignals(session));
574
+ const badgeW = badges ? stringWidth(badges) + 1 : 0;
575
+ const head = label ? `${label} · ${topic}` : topic;
576
+ const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - badgeW - stringWidth(when) - 1);
577
+ return (' ' +
578
+ chalk.dim(padToWidth(session.shortId, 9)) +
579
+ agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
580
+ (badges ? badges + ' ' : '') +
581
+ padToWidth(chalk.white(truncateToWidth(head, topicW)), topicW) +
582
+ ' ' + chalk.gray(when));
583
+ }
584
+ function printSessionTable(sessions, hiddenCount = 0, tree = false) {
585
+ if (tree) {
586
+ // Group by directory; drop the id/version columns from view. The short id
587
+ // stays as each row's leading handle (the address to read/resume it).
588
+ const byDir = new Map();
589
+ for (const s of sessions) {
590
+ const key = s.cwd || s.project || 'unknown';
591
+ (byDir.get(key) ?? byDir.set(key, []).get(key)).push(s);
592
+ }
593
+ const keys = [...byDir.keys()].sort((a, b) => {
594
+ const d = byDir.get(b).length - byDir.get(a).length;
595
+ return d !== 0 ? d : a.localeCompare(b);
596
+ });
597
+ let first = true;
598
+ for (const key of keys) {
599
+ if (!first)
600
+ console.log();
601
+ first = false;
602
+ const group = byDir.get(key);
603
+ console.log(`${chalk.cyan.bold(shortCwd(key))} ${chalk.gray(`(${group.length})`)}`);
604
+ for (const s of group)
605
+ console.log(treeSessionRow(s));
606
+ }
607
+ const dirWord = keys.length === 1 ? 'directory' : 'directories';
608
+ console.log(chalk.gray(`\n${sessions.length} session${sessions.length === 1 ? '' : 's'} across ${keys.length} ${dirWord}.`));
609
+ if (hiddenCount > 0)
610
+ console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
611
+ return;
505
612
  }
613
+ for (const session of sessions)
614
+ console.log(flatSessionRow(session));
506
615
  const countLine = `${sessions.length} session${sessions.length === 1 ? '' : 's'}.`;
507
616
  console.log(chalk.gray(`\n${countLine}`));
508
617
  if (hiddenCount > 0) {
@@ -577,6 +686,13 @@ async function renderSession(session, mode, filters, options = {}) {
577
686
  const modelStr = stats.models.length > 0 ? chalk.yellow(` ${stats.models.join(', ')}`) : '';
578
687
  const branchStr = session.gitBranch ? chalk.gray(` (${session.gitBranch})`) : '';
579
688
  const absTime = formatAbsoluteTime(session.timestamp);
689
+ // Auto-inferred title headline (user /rename > Claude ai-title > first-prompt
690
+ // topic) — the fastest way to recognize which task this session is.
691
+ const title = session.label || session.topic;
692
+ if (title) {
693
+ const badges = signalBadges(metaSignals(session));
694
+ console.log(chalk.bold.white(title) + (badges ? ' ' + badges : ''));
695
+ }
580
696
  console.log(agentColor(session.agent) +
581
697
  (session.version ? chalk.yellow(` ${session.version}`) : '') +
582
698
  modelStr +
@@ -608,8 +724,10 @@ function renderTopicCell(label, topic, query, visibleWidth, paddedWidth) {
608
724
  const tpc = (topic ?? '').trim();
609
725
  const sep = ' · ';
610
726
  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));
727
+ // Width-aware: measure/truncate/pad by display cells, not String.length, so
728
+ // ANSI escapes and wide (CJK/emoji) glyphs don't drift the column.
729
+ const visible = truncateToWidth(raw, visibleWidth);
730
+ const padding = ' '.repeat(Math.max(0, paddedWidth - stringWidth(visible)));
613
731
  const labelEnd = lbl ? Math.min(lbl.length, visible.length) : 0;
614
732
  let matchStart = -1, matchEnd = -1;
615
733
  const q = query.trim().toLowerCase();
@@ -1129,6 +1247,8 @@ export function registerSessionsCommands(program) {
1129
1247
  .option('--artifacts', 'List all files written or edited during a session')
1130
1248
  .option('--artifact <name>', 'Read a specific artifact by filename or path (outputs to stdout)')
1131
1249
  .option('--active', 'Show only sessions running right now across terminals, teams, cloud, and headless agents')
1250
+ .option('--waiting', 'With --active: show only sessions waiting on your input (exits non-zero if any)')
1251
+ .option('--tree', 'Group the listing by directory; drops the id/version columns for readability')
1132
1252
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
1133
1253
  .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)');
1134
1254
  setHelpSections(sessionsCmd, {
@@ -109,6 +109,14 @@ export async function runSetup(program, options = {}) {
109
109
  spinner.succeed(`Cloned ${systemRepoSlug(systemRepo)} (${result.commit})`);
110
110
  }
111
111
  }
112
+ // Populate the device registry from the tailnet on first setup. Soft mode is
113
+ // guaranteed non-throwing (no tailscale / corrupt file / lock contention all
114
+ // resolve to ok:false), so this can never block setup.
115
+ const { runDeviceSync } = await import('../lib/devices/sync.js');
116
+ const dev = await runDeviceSync({ soft: true });
117
+ if (dev.ok && dev.synced > 0) {
118
+ console.log(chalk.gray(`Discovered ${dev.synced} device${dev.synced === 1 ? '' : 's'} on your tailnet (agents devices list).`));
119
+ }
112
120
  // Offer to import existing unmanaged installations
113
121
  if (unmanaged.length > 0 && isInteractiveTerminal()) {
114
122
  console.log(chalk.bold('\nFound existing installations:\n'));