@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.
- 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-picker.js +35 -10
- package/dist/commands/sessions.js +164 -44
- package/dist/commands/setup.js +8 -0
- package/dist/commands/ssh.js +123 -15
- package/dist/commands/sync.js +70 -14
- package/dist/lib/agents.d.ts +0 -4
- package/dist/lib/agents.js +122 -22
- 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/devices/registry.d.ts +11 -0
- package/dist/lib/devices/registry.js +53 -1
- package/dist/lib/devices/sync.d.ts +42 -0
- package/dist/lib/devices/sync.js +85 -0
- 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 +15 -0
- package/dist/lib/session/active.js +108 -19
- 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/digest.d.ts +50 -0
- package/dist/lib/session/digest.js +170 -0
- 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/render.d.ts +2 -0
- package/dist/lib/session/render.js +83 -10
- 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 +4 -0
- package/dist/lib/state.js +19 -1
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +10 -0
- 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.'));
|
|
@@ -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 (['
|
|
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
|
-
|
|
223
|
-
|
|
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('
|
|
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
|
-
/**
|
|
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 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
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
612
|
-
|
|
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, {
|
package/dist/commands/setup.js
CHANGED
|
@@ -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'));
|