@phnx-labs/agents-cli 1.20.30 → 1.20.32
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/CHANGELOG.md +15 -0
- package/dist/commands/sessions-picker.js +35 -10
- package/dist/commands/sessions.js +9 -1
- package/dist/commands/setup.js +8 -0
- package/dist/commands/ssh.js +145 -14
- package/dist/commands/view.js +11 -1
- package/dist/lib/agents.js +69 -18
- package/dist/lib/daemon.js +32 -0
- package/dist/lib/devices/pending.d.ts +18 -0
- package/dist/lib/devices/pending.js +103 -0
- package/dist/lib/devices/registry.d.ts +11 -0
- package/dist/lib/devices/registry.js +53 -1
- package/dist/lib/devices/sync.d.ts +61 -0
- package/dist/lib/devices/sync.js +101 -0
- package/dist/lib/session/active.d.ts +2 -0
- package/dist/lib/session/active.js +29 -1
- package/dist/lib/session/digest.d.ts +50 -0
- package/dist/lib/session/digest.js +170 -0
- package/dist/lib/session/render.d.ts +2 -0
- package/dist/lib/session/render.js +83 -10
- package/dist/lib/state.d.ts +4 -0
- package/dist/lib/state.js +4 -0
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +14 -0
- package/dist/lib/versions.d.ts +11 -0
- package/dist/lib/versions.js +20 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 1.20.31
|
|
6
|
+
|
|
7
|
+
**`agents sessions <id>`: a catch-up digest for switching between many agents (#502)**
|
|
8
|
+
|
|
9
|
+
- Opening a single session now leads with its auto-inferred title (user `/rename` > Claude `ai-title` > first-prompt topic) and PR / worktree / ticket badges, then a **Changes** section that groups touched files by directory and tags each as created / modified / deleted (with a `+N ~N -N` summary) instead of the old flat "Modified" list, a **Tools** histogram (per-tool call counts), and a **Tests** verdict parsed from the last `vitest` / `jest` / `pytest` / `go test` / `cargo test` / `tsc` run. The same signals are folded into the interactive picker preview.
|
|
10
|
+
- `agents sessions --active` now collapses the many subagent/fork PIDs of one session into a single row with a `×N` count instead of printing dozens of identical lines. Source: `src/lib/session/digest.ts`, `src/lib/session/render.ts`, `src/lib/session/active.ts`, `src/commands/sessions.ts`, `src/commands/sessions-picker.ts`.
|
|
11
|
+
|
|
12
|
+
## 1.20.30
|
|
13
|
+
|
|
14
|
+
**`agents sessions` live state engine: waiting / PR / worktree / ticket detection + reliable preview (#494)**
|
|
15
|
+
|
|
16
|
+
- `agents sessions --active` infers real activity from each transcript's tail — **working** / **waiting** / **idle** — rather than the old mtime-only running/idle guess, using structural signals (Claude `ExitPlanMode` / `AskUserQuestion`) plus a question + mtime heuristic for Codex. It detects and badges a PR opened during the session (`gh pr create` + the resulting pull URL), a git worktree (`.agents/worktrees/<slug>/`), and a Linear/Jira ticket (from the prompt or branch), and shows the latest turn as the preview instead of the first prompt.
|
|
17
|
+
- `--waiting` filters `--active` to only sessions blocked on your input and exits non-zero (a scriptable gate); `--tree` groups the listing by directory, dropping the id/version columns while keeping the short-id handle.
|
|
18
|
+
- The preview line is now width-correct: measurement is ANSI- and wide-char-aware and reads `$COLUMNS` first, so it no longer wraps or drifts under tmux or over `--host` SSH (the remote is handed the caller's width). Session index schema v7 persists the PR / worktree / ticket signals so historical listings carry them too. Source: `src/lib/session/state.ts`, `src/lib/session/tail.ts`, `src/lib/session/width.ts`, `src/lib/session/{discover,db,active}.ts`, `src/commands/sessions.ts`.
|
|
19
|
+
|
|
5
20
|
**`agents sessions --host <machine>`: query a remote machine's sessions live over SSH**
|
|
6
21
|
|
|
7
22
|
- `agents sessions "<query>" --host <alias|user@host>` runs the same session query on a remote machine's own index over SSH and streams the result back — repeat `--host` (or pass several) to fan out across machines. SSH access is the only auth; there's no daemon or shared store. Targets are validated against a strict allowlist (`SSH_TARGET_RE`) to block flag-smuggling, and the forwarded invocation is double-quoted (`shellQuote`) so a query like `$(whoami)` survives as a literal string on both shell layers. Source: `src/lib/session/remote.ts`, `src/commands/sessions.ts`, `docs/05-sessions.md`.
|
|
@@ -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;
|
|
@@ -227,7 +227,8 @@ function printActiveRow(s, indent) {
|
|
|
227
227
|
const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, 8), 9));
|
|
228
228
|
const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
|
|
229
229
|
const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
|
|
230
|
-
const
|
|
230
|
+
const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
|
|
231
|
+
const badges = (fork ? fork : '') + signalBadges(s);
|
|
231
232
|
const desc = buildSessionDescription(s) || '-';
|
|
232
233
|
// Fill the remaining width with the preview so nothing wraps under tmux/SSH.
|
|
233
234
|
const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
|
|
@@ -685,6 +686,13 @@ async function renderSession(session, mode, filters, options = {}) {
|
|
|
685
686
|
const modelStr = stats.models.length > 0 ? chalk.yellow(` ${stats.models.join(', ')}`) : '';
|
|
686
687
|
const branchStr = session.gitBranch ? chalk.gray(` (${session.gitBranch})`) : '';
|
|
687
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
|
+
}
|
|
688
696
|
console.log(agentColor(session.agent) +
|
|
689
697
|
(session.version ? chalk.yellow(` ${session.version}`) : '') +
|
|
690
698
|
modelStr +
|
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'));
|
package/dist/commands/ssh.js
CHANGED
|
@@ -16,8 +16,12 @@ import * as path from 'path';
|
|
|
16
16
|
import chalk from 'chalk';
|
|
17
17
|
import ora from 'ora';
|
|
18
18
|
import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
|
|
19
|
-
import {
|
|
19
|
+
import { machineId } from '../lib/session/sync/config.js';
|
|
20
|
+
import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, } from '../lib/devices/registry.js';
|
|
20
21
|
import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from '../lib/devices/tailscale.js';
|
|
22
|
+
import { planDeviceReconciliation, runDeviceSync } from '../lib/devices/sync.js';
|
|
23
|
+
import { clearPendingSentinel } from '../lib/devices/pending.js';
|
|
24
|
+
import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
21
25
|
import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
|
|
22
26
|
import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
|
|
23
27
|
/** Parse `user@host` or `host` into pieces. */
|
|
@@ -27,8 +31,9 @@ function parseTarget(target) {
|
|
|
27
31
|
return { host: target };
|
|
28
32
|
return { user: target.slice(0, at), host: target.slice(at + 1) };
|
|
29
33
|
}
|
|
30
|
-
/** One-line summary of a device for `list`.
|
|
31
|
-
|
|
34
|
+
/** One-line summary of a device for `list`. `isSelf` marks the machine this
|
|
35
|
+
* command is running on so it stands out from the rest of the tailnet. */
|
|
36
|
+
function deviceSummary(d, isSelf = false) {
|
|
32
37
|
const addr = hostNameFor(d) ?? chalk.gray('no address');
|
|
33
38
|
const online = d.tailscale
|
|
34
39
|
? d.tailscale.online
|
|
@@ -36,7 +41,10 @@ function deviceSummary(d) {
|
|
|
36
41
|
: chalk.gray('offline')
|
|
37
42
|
: chalk.gray('unknown');
|
|
38
43
|
const reach = d.tailscale?.online && !d.tailscale.direct ? chalk.yellow(' (relayed)') : '';
|
|
39
|
-
|
|
44
|
+
const marker = isSelf ? chalk.cyan('▸ ') : ' ';
|
|
45
|
+
const name = isSelf ? chalk.bold.cyan(d.name.padEnd(16)) : chalk.bold(d.name.padEnd(16));
|
|
46
|
+
const here = isSelf ? chalk.cyan(' ← this machine') : '';
|
|
47
|
+
return `${marker}${name} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}${here}`;
|
|
40
48
|
}
|
|
41
49
|
/** Resolve a device or exit with a clear error. */
|
|
42
50
|
async function mustGetDevice(name) {
|
|
@@ -47,6 +55,72 @@ async function mustGetDevice(name) {
|
|
|
47
55
|
}
|
|
48
56
|
return d;
|
|
49
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Interactive `agents devices sync`: discover tailscale nodes, present a
|
|
60
|
+
* checkbox pre-checked with what's already registered, and reconcile the
|
|
61
|
+
* choice. Checked = registered (and un-ignored). Unchecked = removed from the
|
|
62
|
+
* registry AND added to the ignore-list, so auto-discovery never re-suggests
|
|
63
|
+
* it — this is the "click to register/unregister" surface, with dismissals that
|
|
64
|
+
* stick.
|
|
65
|
+
*/
|
|
66
|
+
async function runInteractiveDeviceSync() {
|
|
67
|
+
const spinner = ora('Reading tailscale status...').start();
|
|
68
|
+
let nodes;
|
|
69
|
+
try {
|
|
70
|
+
nodes = parseTailscaleStatus(tailscaleStatusJson());
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
spinner.fail(err.message);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
const [reg, ignored] = await Promise.all([loadDevices(), loadIgnored()]);
|
|
77
|
+
const registered = new Set(Object.keys(reg));
|
|
78
|
+
spinner.stop();
|
|
79
|
+
if (nodes.length === 0) {
|
|
80
|
+
console.log(chalk.gray('No tailscale nodes found.'));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const { checkbox } = await import('@inquirer/prompts');
|
|
84
|
+
let selected;
|
|
85
|
+
try {
|
|
86
|
+
selected = await checkbox({
|
|
87
|
+
// Everything not already dismissed starts checked, so pressing Enter keeps
|
|
88
|
+
// the fleet as-is (matching what auto-sync would register). Unchecking a
|
|
89
|
+
// device removes it AND dismisses it so auto-sync never re-adds it.
|
|
90
|
+
message: 'Your fleet — uncheck a device to remove and stop suggesting it:',
|
|
91
|
+
pageSize: Math.min(nodes.length, 20),
|
|
92
|
+
choices: nodes.map((n) => {
|
|
93
|
+
const flags = [n.platform, n.online ? undefined : 'offline', ignored.has(n.name) ? 'ignored' : undefined]
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.join(', ');
|
|
96
|
+
return { value: n.name, name: `${n.name} ${chalk.gray(`(${flags})`)}`, checked: !ignored.has(n.name) };
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
if (isPromptCancelled(err)) {
|
|
102
|
+
console.log(chalk.gray('Cancelled — no changes.'));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
const byName = new Map(nodes.map((n) => [n.name, n]));
|
|
108
|
+
const plan = planDeviceReconciliation(byName.keys(), selected, registered, ignored);
|
|
109
|
+
for (const name of plan.toRegister)
|
|
110
|
+
await upsertDevice(name, nodeToDeviceInput(byName.get(name)));
|
|
111
|
+
for (const name of plan.toUnignore)
|
|
112
|
+
await removeIgnored(name);
|
|
113
|
+
for (const name of plan.toRemove)
|
|
114
|
+
await removeDevice(name);
|
|
115
|
+
for (const name of plan.toIgnore)
|
|
116
|
+
await addIgnored(name);
|
|
117
|
+
const parts = [
|
|
118
|
+
chalk.green(`${plan.toRegister.length} registered`),
|
|
119
|
+
plan.toRemove.length ? chalk.yellow(`${plan.toRemove.length} removed`) : null,
|
|
120
|
+
plan.toIgnore.length ? chalk.gray(`${plan.toIgnore.length} ignored`) : null,
|
|
121
|
+
].filter(Boolean);
|
|
122
|
+
console.log(parts.join(chalk.gray(' · ')));
|
|
123
|
+
}
|
|
50
124
|
/** Register the `agents devices` command tree. */
|
|
51
125
|
function registerDevicesCommands(program) {
|
|
52
126
|
const devicesCmd = program
|
|
@@ -54,43 +128,100 @@ function registerDevicesCommands(program) {
|
|
|
54
128
|
.description('Registry of SSH device profiles (platform, user, address, auth), self-populated from Tailscale.')
|
|
55
129
|
.addHelpText('after', `
|
|
56
130
|
Typical workflow:
|
|
57
|
-
agents devices sync #
|
|
131
|
+
agents devices sync # curate: pick which tailscale nodes to keep (TTY)
|
|
132
|
+
agents devices sync --yes # non-interactive: register all non-ignored nodes
|
|
58
133
|
agents devices list # see what's registered
|
|
134
|
+
agents devices ignore ipad165 # dismiss a node so it's never re-suggested
|
|
59
135
|
agents devices set win-mini --auth password --bundle muqsit
|
|
60
136
|
agents devices render --write # write ~/.ssh/config.d/agents include
|
|
61
137
|
`);
|
|
62
138
|
devicesCmd
|
|
63
139
|
.command('sync')
|
|
64
|
-
.description('Ingest `tailscale status --json`
|
|
65
|
-
.
|
|
140
|
+
.description('Ingest `tailscale status --json` into device profiles. In a terminal, opens a checkbox to register/unregister nodes; with --yes, registers every non-ignored node.')
|
|
141
|
+
.option('--yes', 'skip the picker; register all discovered non-ignored nodes')
|
|
142
|
+
.action(async (opts) => {
|
|
143
|
+
if (isInteractiveTerminal() && !opts.yes) {
|
|
144
|
+
await runInteractiveDeviceSync();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
66
147
|
const spinner = ora('Reading tailscale status...').start();
|
|
148
|
+
try {
|
|
149
|
+
const res = await runDeviceSync();
|
|
150
|
+
const extra = res.pending.length ? chalk.gray(` (${res.pending.length} new)`) : '';
|
|
151
|
+
spinner.succeed(`Synced ${res.synced} device${res.synced === 1 ? '' : 's'} from Tailscale${extra}`);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
spinner.fail(err.message);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
devicesCmd
|
|
159
|
+
.command('register <name>')
|
|
160
|
+
.description('Register a discovered (pending) node by name — used by the menu-bar "NEW DEVICES → Register" action.')
|
|
161
|
+
.action(async (name) => {
|
|
67
162
|
try {
|
|
68
163
|
const nodes = parseTailscaleStatus(tailscaleStatusJson());
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
164
|
+
const node = nodes.find((n) => n.name === name);
|
|
165
|
+
if (!node) {
|
|
166
|
+
console.error(chalk.red(`'${name}' is not a current tailscale node. See 'agents devices sync'.`));
|
|
167
|
+
process.exit(1);
|
|
72
168
|
}
|
|
73
|
-
|
|
169
|
+
await removeIgnored(name); // a re-registered node is no longer dismissed
|
|
170
|
+
const d = await upsertDevice(name, nodeToDeviceInput(node));
|
|
171
|
+
clearPendingSentinel(name); // drop the notification immediately
|
|
172
|
+
console.log(chalk.green(`Registered '${name}'`) + chalk.gray(` (${d.platform})`));
|
|
74
173
|
}
|
|
75
174
|
catch (err) {
|
|
76
|
-
|
|
175
|
+
console.error(chalk.red(err.message));
|
|
77
176
|
process.exit(1);
|
|
78
177
|
}
|
|
79
178
|
});
|
|
179
|
+
devicesCmd
|
|
180
|
+
.command('ignore <name>')
|
|
181
|
+
.description('Dismiss a node from auto-discovery so it is never re-suggested (and remove it from the registry if present).')
|
|
182
|
+
.action(async (name) => {
|
|
183
|
+
try {
|
|
184
|
+
await removeDevice(name);
|
|
185
|
+
await addIgnored(name);
|
|
186
|
+
clearPendingSentinel(name); // drop the notification immediately
|
|
187
|
+
console.log(chalk.green(`Ignored '${name}'`) + chalk.gray(" — it won't be suggested again. Undo with `agents devices unignore`."));
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
console.error(chalk.red(err.message));
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
devicesCmd
|
|
195
|
+
.command('unignore <name>')
|
|
196
|
+
.description('Undo `ignore`: allow a node to be discovered and registered again.')
|
|
197
|
+
.action(async (name) => {
|
|
198
|
+
const ok = await removeIgnored(name);
|
|
199
|
+
if (!ok) {
|
|
200
|
+
console.error(chalk.gray(`'${name}' was not ignored.`));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
console.log(chalk.green(`No longer ignoring '${name}'`) + chalk.gray(' — run `agents devices sync` to register it.'));
|
|
204
|
+
});
|
|
80
205
|
devicesCmd
|
|
81
206
|
.command('list')
|
|
82
207
|
.alias('ls')
|
|
83
208
|
.description('List registered devices with platform, address, and reachability.')
|
|
84
|
-
.
|
|
209
|
+
.option('--json', 'output the registry as a JSON array (for scripts and hooks)')
|
|
210
|
+
.action(async (opts) => {
|
|
85
211
|
const reg = await loadDevices();
|
|
86
212
|
const names = Object.keys(reg).sort();
|
|
213
|
+
if (opts.json) {
|
|
214
|
+
process.stdout.write(JSON.stringify(names.map((n) => reg[n]), null, 2) + '\n');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
87
217
|
if (names.length === 0) {
|
|
88
218
|
console.log(chalk.gray("No devices. Run 'agents devices sync' or 'agents devices add <name> <user@host>'."));
|
|
89
219
|
return;
|
|
90
220
|
}
|
|
221
|
+
const self = machineId();
|
|
91
222
|
console.log(chalk.bold(`Devices (${names.length})`));
|
|
92
223
|
for (const name of names)
|
|
93
|
-
console.log(deviceSummary(reg[name]));
|
|
224
|
+
console.log(deviceSummary(reg[name], name === self));
|
|
94
225
|
});
|
|
95
226
|
devicesCmd
|
|
96
227
|
.command('show <name>')
|
package/dist/commands/view.js
CHANGED
|
@@ -5,7 +5,7 @@ import * as path from 'path';
|
|
|
5
5
|
import { AGENTS, ALL_AGENT_IDS, getAllCliStates, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
|
|
6
6
|
import { deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
|
|
7
7
|
import { readManifest } from '../lib/manifest.js';
|
|
8
|
-
import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, resolveVersionAlias, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, } from '../lib/versions.js';
|
|
8
|
+
import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, resolveVersionAlias, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, } from '../lib/versions.js';
|
|
9
9
|
import { ensureVersionedAliasCurrent, removeShim, } from '../lib/shims.js';
|
|
10
10
|
import { getAgentResources } from '../lib/resources.js';
|
|
11
11
|
import { listCliStatus } from '../lib/cli-resources.js';
|
|
@@ -1262,6 +1262,16 @@ export async function viewAction(agentArg, options) {
|
|
|
1262
1262
|
cli: options?.cli,
|
|
1263
1263
|
};
|
|
1264
1264
|
const filterIsSet = SECTION_KEYS.some((k) => filter[k]);
|
|
1265
|
+
// RUSH-1320: fold any stale literal `latest` version-home into its concrete
|
|
1266
|
+
// version before rendering, so it stops appearing as a bogus "version" next
|
|
1267
|
+
// to the real ones. Best-effort — must never break `agents view`. Scoped to
|
|
1268
|
+
// the queried agent when one is given (cheap no-op for agents with no
|
|
1269
|
+
// `latest` dir, i.e. almost all of them).
|
|
1270
|
+
{
|
|
1271
|
+
const target = agentArg ? resolveAgentName(agentArg.split('@')[0]) : null;
|
|
1272
|
+
const toReconcile = agentArg ? (target ? [target] : []) : ALL_AGENT_IDS;
|
|
1273
|
+
await Promise.all(toReconcile.map((a) => reconcileStaleLatestForAgent(a).catch(() => { })));
|
|
1274
|
+
}
|
|
1265
1275
|
if (!agentArg) {
|
|
1266
1276
|
if (prune) {
|
|
1267
1277
|
await pruneDuplicates(undefined, yes, dryRun);
|
package/dist/lib/agents.js
CHANGED
|
@@ -802,6 +802,37 @@ function resolveAccountCredentialPath(base, ...segments) {
|
|
|
802
802
|
}
|
|
803
803
|
return null;
|
|
804
804
|
}
|
|
805
|
+
let cachedAgyKeychainSignedIn;
|
|
806
|
+
/**
|
|
807
|
+
* Antigravity (`agy`, a Codeium/Windsurf-based CLI) stores its OAuth token in
|
|
808
|
+
* the macOS keychain — service `gemini`, account `antigravity` — NOT a file.
|
|
809
|
+
* The file path (`antigravity-oauth-token`) only exists on Linux, where the Go
|
|
810
|
+
* keyring falls back to disk. Probe the keychain for existence (metadata only;
|
|
811
|
+
* `-w` omitted so it never prompts). Cached per process — the keychain is
|
|
812
|
+
* account-global, so one probe covers every installed version. Returns false on
|
|
813
|
+
* non-macOS (the file path handles those).
|
|
814
|
+
*/
|
|
815
|
+
async function antigravityKeychainSignedIn() {
|
|
816
|
+
if (cachedAgyKeychainSignedIn !== undefined)
|
|
817
|
+
return cachedAgyKeychainSignedIn;
|
|
818
|
+
// Test isolation: the real macOS keychain can't be sandboxed per-test, so
|
|
819
|
+
// allow suites asserting "signed out" to opt out of the probe (same spirit as
|
|
820
|
+
// AGENTS_REAL_HOME). Not cached, so tests can toggle it.
|
|
821
|
+
if (process.env.AGENTS_NO_KEYCHAIN_PROBE === '1')
|
|
822
|
+
return false;
|
|
823
|
+
if (process.platform !== 'darwin') {
|
|
824
|
+
cachedAgyKeychainSignedIn = false;
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
try {
|
|
828
|
+
await execFileAsync('security', ['find-generic-password', '-s', 'gemini', '-a', 'antigravity'], { timeout: 3000 });
|
|
829
|
+
cachedAgyKeychainSignedIn = true;
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
cachedAgyKeychainSignedIn = false;
|
|
833
|
+
}
|
|
834
|
+
return cachedAgyKeychainSignedIn;
|
|
835
|
+
}
|
|
805
836
|
export async function getAccountInfo(agentId, home) {
|
|
806
837
|
const base = home || os.homedir();
|
|
807
838
|
const empty = {
|
|
@@ -938,32 +969,52 @@ export async function getAccountInfo(agentId, home) {
|
|
|
938
969
|
return { ...empty, email, signedIn: !!email, lastActive };
|
|
939
970
|
}
|
|
940
971
|
case 'grok': {
|
|
941
|
-
// Grok stores auth in ~/.grok/auth.json
|
|
972
|
+
// Grok stores auth in ~/.grok/auth.json as a map keyed by
|
|
973
|
+
// "<oidc_issuer>::<client_id>" -> { email, user_id, refresh_token,
|
|
974
|
+
// create_time, expires_at, team_id, ... }. (Older builds wrote a flat
|
|
975
|
+
// object with a top-level email.) The old code only read a TOP-LEVEL
|
|
976
|
+
// `email`, so the current nested format always looked signed-out even
|
|
977
|
+
// when logged in. Read the newest account record: a refresh token means
|
|
978
|
+
// signed in, and we surface the email/ids like claude/codex.
|
|
979
|
+
const authPath = resolveAccountCredentialPath(base, '.grok', 'auth.json');
|
|
980
|
+
if (!authPath)
|
|
981
|
+
return { ...empty, lastActive };
|
|
942
982
|
try {
|
|
943
|
-
const
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
983
|
+
const data = JSON.parse(await fs.promises.readFile(authPath, 'utf-8'));
|
|
984
|
+
const records = (data && typeof data === 'object' ? [data, ...Object.values(data)] : [])
|
|
985
|
+
.filter((r) => !!r && typeof r === 'object');
|
|
986
|
+
const account = records
|
|
987
|
+
.filter(r => typeof r.refresh_token === 'string' || typeof r.email === 'string')
|
|
988
|
+
.sort((a, b) => String(b.create_time || '').localeCompare(String(a.create_time || '')))[0];
|
|
989
|
+
if (account) {
|
|
990
|
+
const email = typeof account.email === 'string' ? account.email : null;
|
|
991
|
+
const accountId = normalizeIdentityPart(account.user_id ?? account.principal_id);
|
|
992
|
+
const organizationId = normalizeIdentityPart(account.team_id);
|
|
993
|
+
const accountKey = buildIdentityKey(agentId, [['user', accountId], ['org', organizationId]]);
|
|
994
|
+
return { ...empty, email, accountId, organizationId, accountKey, signedIn: true, lastActive };
|
|
948
995
|
}
|
|
949
996
|
}
|
|
950
997
|
catch { }
|
|
951
998
|
return { ...empty, lastActive };
|
|
952
999
|
}
|
|
953
1000
|
case 'antigravity': {
|
|
954
|
-
// Antigravity (`agy`) stores a Google OAuth
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
1001
|
+
// Antigravity (`agy`) stores a consumer Google OAuth grant (access +
|
|
1002
|
+
// refresh token, no id_token) — presence of a refresh token is the only
|
|
1003
|
+
// signed-in signal we can derive without a network call. Storage is
|
|
1004
|
+
// platform-split: on Linux it's a file at
|
|
1005
|
+
// ~/.gemini/antigravity-cli/antigravity-oauth-token; on macOS the Go
|
|
1006
|
+
// keyring puts it in the keychain (service 'gemini', account
|
|
1007
|
+
// 'antigravity'), so no file exists — check both.
|
|
959
1008
|
const tokenPath = resolveAccountCredentialPath(base, '.gemini', 'antigravity-cli', 'antigravity-oauth-token');
|
|
960
|
-
if (
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
1009
|
+
if (tokenPath) {
|
|
1010
|
+
const data = JSON.parse(await fs.promises.readFile(tokenPath, 'utf-8'));
|
|
1011
|
+
if (typeof data?.token?.refresh_token === 'string' && data.token.refresh_token) {
|
|
1012
|
+
return { ...empty, signedIn: true, lastActive };
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (await antigravityKeychainSignedIn())
|
|
1016
|
+
return { ...empty, signedIn: true, lastActive };
|
|
1017
|
+
return { ...empty, lastActive };
|
|
967
1018
|
}
|
|
968
1019
|
case 'kimi': {
|
|
969
1020
|
// Kimi Code stores OAuth credentials at
|
package/dist/lib/daemon.js
CHANGED
|
@@ -290,6 +290,36 @@ export async function runDaemon() {
|
|
|
290
290
|
};
|
|
291
291
|
const healInterval = setInterval(() => { void runHealCheck(); }, 6 * 60 * 60_000);
|
|
292
292
|
const healKickoff = setTimeout(() => { void runHealCheck(); }, 30_000);
|
|
293
|
+
// Device probe: refresh registered devices' reachability and detect newly
|
|
294
|
+
// appeared tailnet nodes, dropping a sentinel per pending device so the
|
|
295
|
+
// menu-bar helper can surface "NEW DEVICES → Register / Ignore". Refresh mode
|
|
296
|
+
// never auto-registers a newcomer. Soft + overlap-guarded like session sync;
|
|
297
|
+
// a machine without tailscale is a clean no-op. ~every 3 min.
|
|
298
|
+
let probingDevices = false;
|
|
299
|
+
const runDeviceProbe = async () => {
|
|
300
|
+
if (probingDevices)
|
|
301
|
+
return;
|
|
302
|
+
probingDevices = true;
|
|
303
|
+
try {
|
|
304
|
+
const { runDeviceSync } = await import('./devices/sync.js');
|
|
305
|
+
const { reconcilePendingSentinels } = await import('./devices/pending.js');
|
|
306
|
+
const dev = await runDeviceSync({ soft: true, mode: 'refresh' });
|
|
307
|
+
if (dev.ok) {
|
|
308
|
+
reconcilePendingSentinels(dev.pending);
|
|
309
|
+
if (dev.pending.length) {
|
|
310
|
+
log('INFO', `devices: ${dev.pending.length} new pending (${dev.pending.map((p) => p.name).join(', ')})`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
log('ERROR', `device probe failed: ${err.message}`);
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
probingDevices = false;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
const deviceProbeInterval = setInterval(() => { void runDeviceProbe(); }, 3 * 60_000);
|
|
322
|
+
const deviceProbeKickoff = setTimeout(() => { void runDeviceProbe(); }, 15_000);
|
|
293
323
|
const handleReload = () => {
|
|
294
324
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
295
325
|
scheduler.reloadAll();
|
|
@@ -307,6 +337,8 @@ export async function runDaemon() {
|
|
|
307
337
|
clearInterval(syncInterval);
|
|
308
338
|
clearInterval(healInterval);
|
|
309
339
|
clearTimeout(healKickoff);
|
|
340
|
+
clearInterval(deviceProbeInterval);
|
|
341
|
+
clearTimeout(deviceProbeKickoff);
|
|
310
342
|
removeDaemonPid();
|
|
311
343
|
process.exit(0);
|
|
312
344
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface PendingDevice {
|
|
2
|
+
name: string;
|
|
3
|
+
platform: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Make the sentinel dir exactly match `pending`: create a file per pending
|
|
7
|
+
* device (content = platform), and delete any leftover sentinel whose device is
|
|
8
|
+
* no longer pending (it got registered, ignored, or left the tailnet). Best-
|
|
9
|
+
* effort — a filesystem error here must never crash the daemon, so callers pass
|
|
10
|
+
* this through their existing try/catch.
|
|
11
|
+
*/
|
|
12
|
+
export declare function reconcilePendingSentinels(pending: PendingDevice[]): void;
|
|
13
|
+
/** Remove one device's pending sentinel (after the user registers or ignores it).
|
|
14
|
+
* No-op if it doesn't exist. */
|
|
15
|
+
export declare function clearPendingSentinel(name: string): void;
|
|
16
|
+
/** Read the current pending sentinels (name + platform). Used by tests and any
|
|
17
|
+
* TS-side consumer; the menu-bar helper reads the dir directly in Swift. */
|
|
18
|
+
export declare function readPendingSentinels(): PendingDevice[];
|