@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.
- package/dist/commands/computer-actions.js +6 -2
- package/dist/commands/computer.d.ts +12 -0
- package/dist/commands/computer.js +88 -13
- package/dist/commands/inspect.js +1 -1
- package/dist/commands/models.js +8 -2
- package/dist/commands/sessions.js +156 -44
- package/dist/commands/sync.js +70 -14
- package/dist/lib/agents.d.ts +0 -4
- package/dist/lib/agents.js +54 -5
- package/dist/lib/browser/drivers/ssh.js +4 -35
- package/dist/lib/computer-rpc.d.ts +6 -1
- package/dist/lib/computer-rpc.js +86 -3
- package/dist/lib/exec.js +14 -0
- package/dist/lib/models.js +138 -5
- package/dist/lib/runner.js +7 -7
- package/dist/lib/session/active.d.ts +13 -0
- package/dist/lib/session/active.js +79 -18
- package/dist/lib/session/cloud.js +2 -0
- package/dist/lib/session/db.d.ts +11 -0
- package/dist/lib/session/db.js +62 -5
- package/dist/lib/session/discover.d.ts +5 -0
- package/dist/lib/session/discover.js +81 -0
- package/dist/lib/session/parse.d.ts +15 -0
- package/dist/lib/session/parse.js +22 -2
- package/dist/lib/session/remote.d.ts +1 -1
- package/dist/lib/session/remote.js +8 -3
- package/dist/lib/session/state.d.ts +82 -0
- package/dist/lib/session/state.js +221 -0
- package/dist/lib/session/tail.d.ts +18 -0
- package/dist/lib/session/tail.js +57 -0
- package/dist/lib/session/types.d.ts +9 -0
- package/dist/lib/session/width.d.ts +29 -0
- package/dist/lib/session/width.js +91 -0
- package/dist/lib/shims.d.ts +17 -1
- package/dist/lib/shims.js +130 -6
- package/dist/lib/ssh-tunnel.d.ts +127 -0
- package/dist/lib/ssh-tunnel.js +346 -0
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +17 -1
- package/dist/lib/teams/agents.d.ts +11 -1
- package/dist/lib/teams/agents.js +16 -2
- package/dist/lib/types.d.ts +1 -0
- package/dist/lib/versions.d.ts +19 -0
- package/dist/lib/versions.js +84 -24
- 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
|
|
19
|
-
// The whole subsystem is macOS Accessibility / TCC
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
185
|
-
.
|
|
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
|
|
270
|
-
.
|
|
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 (
|
|
444
|
-
.
|
|
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();
|
package/dist/commands/inspect.js
CHANGED
|
@@ -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))
|
package/dist/commands/models.js
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
192
|
-
const
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
612
|
-
|
|
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, {
|