@phnx-labs/agents-cli 1.20.30 → 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/sessions-picker.js +35 -10
- package/dist/commands/sessions.js +9 -1
- package/dist/commands/setup.js +8 -0
- package/dist/commands/ssh.js +123 -15
- package/dist/lib/agents.js +69 -18
- 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/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 +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/sync-umbrella.d.ts +5 -0
- package/dist/lib/sync-umbrella.js +10 -0
- package/package.json +1 -1
|
@@ -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,11 @@ 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 { isInteractiveTerminal, isPromptCancelled } from './utils.js';
|
|
21
24
|
import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
|
|
22
25
|
import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
|
|
23
26
|
/** Parse `user@host` or `host` into pieces. */
|
|
@@ -27,8 +30,9 @@ function parseTarget(target) {
|
|
|
27
30
|
return { host: target };
|
|
28
31
|
return { user: target.slice(0, at), host: target.slice(at + 1) };
|
|
29
32
|
}
|
|
30
|
-
/** One-line summary of a device for `list`.
|
|
31
|
-
|
|
33
|
+
/** One-line summary of a device for `list`. `isSelf` marks the machine this
|
|
34
|
+
* command is running on so it stands out from the rest of the tailnet. */
|
|
35
|
+
function deviceSummary(d, isSelf = false) {
|
|
32
36
|
const addr = hostNameFor(d) ?? chalk.gray('no address');
|
|
33
37
|
const online = d.tailscale
|
|
34
38
|
? d.tailscale.online
|
|
@@ -36,7 +40,10 @@ function deviceSummary(d) {
|
|
|
36
40
|
: chalk.gray('offline')
|
|
37
41
|
: chalk.gray('unknown');
|
|
38
42
|
const reach = d.tailscale?.online && !d.tailscale.direct ? chalk.yellow(' (relayed)') : '';
|
|
39
|
-
|
|
43
|
+
const marker = isSelf ? chalk.cyan('▸ ') : ' ';
|
|
44
|
+
const name = isSelf ? chalk.bold.cyan(d.name.padEnd(16)) : chalk.bold(d.name.padEnd(16));
|
|
45
|
+
const here = isSelf ? chalk.cyan(' ← this machine') : '';
|
|
46
|
+
return `${marker}${name} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}${here}`;
|
|
40
47
|
}
|
|
41
48
|
/** Resolve a device or exit with a clear error. */
|
|
42
49
|
async function mustGetDevice(name) {
|
|
@@ -47,6 +54,72 @@ async function mustGetDevice(name) {
|
|
|
47
54
|
}
|
|
48
55
|
return d;
|
|
49
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Interactive `agents devices sync`: discover tailscale nodes, present a
|
|
59
|
+
* checkbox pre-checked with what's already registered, and reconcile the
|
|
60
|
+
* choice. Checked = registered (and un-ignored). Unchecked = removed from the
|
|
61
|
+
* registry AND added to the ignore-list, so auto-discovery never re-suggests
|
|
62
|
+
* it — this is the "click to register/unregister" surface, with dismissals that
|
|
63
|
+
* stick.
|
|
64
|
+
*/
|
|
65
|
+
async function runInteractiveDeviceSync() {
|
|
66
|
+
const spinner = ora('Reading tailscale status...').start();
|
|
67
|
+
let nodes;
|
|
68
|
+
try {
|
|
69
|
+
nodes = parseTailscaleStatus(tailscaleStatusJson());
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
spinner.fail(err.message);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
const [reg, ignored] = await Promise.all([loadDevices(), loadIgnored()]);
|
|
76
|
+
const registered = new Set(Object.keys(reg));
|
|
77
|
+
spinner.stop();
|
|
78
|
+
if (nodes.length === 0) {
|
|
79
|
+
console.log(chalk.gray('No tailscale nodes found.'));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const { checkbox } = await import('@inquirer/prompts');
|
|
83
|
+
let selected;
|
|
84
|
+
try {
|
|
85
|
+
selected = await checkbox({
|
|
86
|
+
// Everything not already dismissed starts checked, so pressing Enter keeps
|
|
87
|
+
// the fleet as-is (matching what auto-sync would register). Unchecking a
|
|
88
|
+
// device removes it AND dismisses it so auto-sync never re-adds it.
|
|
89
|
+
message: 'Your fleet — uncheck a device to remove and stop suggesting it:',
|
|
90
|
+
pageSize: Math.min(nodes.length, 20),
|
|
91
|
+
choices: nodes.map((n) => {
|
|
92
|
+
const flags = [n.platform, n.online ? undefined : 'offline', ignored.has(n.name) ? 'ignored' : undefined]
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.join(', ');
|
|
95
|
+
return { value: n.name, name: `${n.name} ${chalk.gray(`(${flags})`)}`, checked: !ignored.has(n.name) };
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (isPromptCancelled(err)) {
|
|
101
|
+
console.log(chalk.gray('Cancelled — no changes.'));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
const byName = new Map(nodes.map((n) => [n.name, n]));
|
|
107
|
+
const plan = planDeviceReconciliation(byName.keys(), selected, registered, ignored);
|
|
108
|
+
for (const name of plan.toRegister)
|
|
109
|
+
await upsertDevice(name, nodeToDeviceInput(byName.get(name)));
|
|
110
|
+
for (const name of plan.toUnignore)
|
|
111
|
+
await removeIgnored(name);
|
|
112
|
+
for (const name of plan.toRemove)
|
|
113
|
+
await removeDevice(name);
|
|
114
|
+
for (const name of plan.toIgnore)
|
|
115
|
+
await addIgnored(name);
|
|
116
|
+
const parts = [
|
|
117
|
+
chalk.green(`${plan.toRegister.length} registered`),
|
|
118
|
+
plan.toRemove.length ? chalk.yellow(`${plan.toRemove.length} removed`) : null,
|
|
119
|
+
plan.toIgnore.length ? chalk.gray(`${plan.toIgnore.length} ignored`) : null,
|
|
120
|
+
].filter(Boolean);
|
|
121
|
+
console.log(parts.join(chalk.gray(' · ')));
|
|
122
|
+
}
|
|
50
123
|
/** Register the `agents devices` command tree. */
|
|
51
124
|
function registerDevicesCommands(program) {
|
|
52
125
|
const devicesCmd = program
|
|
@@ -54,43 +127,78 @@ function registerDevicesCommands(program) {
|
|
|
54
127
|
.description('Registry of SSH device profiles (platform, user, address, auth), self-populated from Tailscale.')
|
|
55
128
|
.addHelpText('after', `
|
|
56
129
|
Typical workflow:
|
|
57
|
-
agents devices sync #
|
|
130
|
+
agents devices sync # curate: pick which tailscale nodes to keep (TTY)
|
|
131
|
+
agents devices sync --yes # non-interactive: register all non-ignored nodes
|
|
58
132
|
agents devices list # see what's registered
|
|
133
|
+
agents devices ignore ipad165 # dismiss a node so it's never re-suggested
|
|
59
134
|
agents devices set win-mini --auth password --bundle muqsit
|
|
60
135
|
agents devices render --write # write ~/.ssh/config.d/agents include
|
|
61
136
|
`);
|
|
62
137
|
devicesCmd
|
|
63
138
|
.command('sync')
|
|
64
|
-
.description('Ingest `tailscale status --json`
|
|
65
|
-
.
|
|
139
|
+
.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.')
|
|
140
|
+
.option('--yes', 'skip the picker; register all discovered non-ignored nodes')
|
|
141
|
+
.action(async (opts) => {
|
|
142
|
+
if (isInteractiveTerminal() && !opts.yes) {
|
|
143
|
+
await runInteractiveDeviceSync();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
66
146
|
const spinner = ora('Reading tailscale status...').start();
|
|
67
147
|
try {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
await upsertDevice(node.name, nodeToDeviceInput(node));
|
|
72
|
-
}
|
|
73
|
-
spinner.succeed(`Synced ${nodes.length} device${nodes.length === 1 ? '' : 's'} from Tailscale`);
|
|
148
|
+
const res = await runDeviceSync();
|
|
149
|
+
const extra = res.pending.length ? chalk.gray(` (${res.pending.length} new)`) : '';
|
|
150
|
+
spinner.succeed(`Synced ${res.synced} device${res.synced === 1 ? '' : 's'} from Tailscale${extra}`);
|
|
74
151
|
}
|
|
75
152
|
catch (err) {
|
|
76
153
|
spinner.fail(err.message);
|
|
77
154
|
process.exit(1);
|
|
78
155
|
}
|
|
79
156
|
});
|
|
157
|
+
devicesCmd
|
|
158
|
+
.command('ignore <name>')
|
|
159
|
+
.description('Dismiss a node from auto-discovery so it is never re-suggested (and remove it from the registry if present).')
|
|
160
|
+
.action(async (name) => {
|
|
161
|
+
try {
|
|
162
|
+
await removeDevice(name);
|
|
163
|
+
await addIgnored(name);
|
|
164
|
+
console.log(chalk.green(`Ignored '${name}'`) + chalk.gray(" — it won't be suggested again. Undo with `agents devices unignore`."));
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
console.error(chalk.red(err.message));
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
devicesCmd
|
|
172
|
+
.command('unignore <name>')
|
|
173
|
+
.description('Undo `ignore`: allow a node to be discovered and registered again.')
|
|
174
|
+
.action(async (name) => {
|
|
175
|
+
const ok = await removeIgnored(name);
|
|
176
|
+
if (!ok) {
|
|
177
|
+
console.error(chalk.gray(`'${name}' was not ignored.`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
console.log(chalk.green(`No longer ignoring '${name}'`) + chalk.gray(' — run `agents devices sync` to register it.'));
|
|
181
|
+
});
|
|
80
182
|
devicesCmd
|
|
81
183
|
.command('list')
|
|
82
184
|
.alias('ls')
|
|
83
185
|
.description('List registered devices with platform, address, and reachability.')
|
|
84
|
-
.
|
|
186
|
+
.option('--json', 'output the registry as a JSON array (for scripts and hooks)')
|
|
187
|
+
.action(async (opts) => {
|
|
85
188
|
const reg = await loadDevices();
|
|
86
189
|
const names = Object.keys(reg).sort();
|
|
190
|
+
if (opts.json) {
|
|
191
|
+
process.stdout.write(JSON.stringify(names.map((n) => reg[n]), null, 2) + '\n');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
87
194
|
if (names.length === 0) {
|
|
88
195
|
console.log(chalk.gray("No devices. Run 'agents devices sync' or 'agents devices add <name> <user@host>'."));
|
|
89
196
|
return;
|
|
90
197
|
}
|
|
198
|
+
const self = machineId();
|
|
91
199
|
console.log(chalk.bold(`Devices (${names.length})`));
|
|
92
200
|
for (const name of names)
|
|
93
|
-
console.log(deviceSummary(reg[name]));
|
|
201
|
+
console.log(deviceSummary(reg[name], name === self));
|
|
94
202
|
});
|
|
95
203
|
devicesCmd
|
|
96
204
|
.command('show <name>')
|
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
|
|
@@ -76,3 +76,14 @@ export interface DeviceInput {
|
|
|
76
76
|
export declare function upsertDevice(name: string, input: DeviceInput): Promise<DeviceProfile>;
|
|
77
77
|
/** Remove a device. Returns false if it was not registered. */
|
|
78
78
|
export declare function removeDevice(name: string): Promise<boolean>;
|
|
79
|
+
/** Load the set of ignored node names. Missing file => empty set. A malformed
|
|
80
|
+
* file is a hard error for the same reason the registry is: silently returning
|
|
81
|
+
* [] would let the next write wipe the user's dismissals. */
|
|
82
|
+
export declare function loadIgnored(): Promise<Set<string>>;
|
|
83
|
+
/** True if `name` is on the ignore-list. */
|
|
84
|
+
export declare function isIgnored(name: string): Promise<boolean>;
|
|
85
|
+
/** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
|
|
86
|
+
export declare function addIgnored(name: string): Promise<Set<string>>;
|
|
87
|
+
/** Remove a node name from the ignore-list (un-ignore). Returns false if it was
|
|
88
|
+
* not ignored. */
|
|
89
|
+
export declare function removeIgnored(name: string): Promise<boolean>;
|
|
@@ -18,7 +18,7 @@ import * as fsSync from 'fs';
|
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import { randomBytes } from 'crypto';
|
|
20
20
|
import lockfile from 'proper-lockfile';
|
|
21
|
-
import { getDevicesRegistryPath } from '../state.js';
|
|
21
|
+
import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
|
|
22
22
|
function registryPath() {
|
|
23
23
|
return getDevicesRegistryPath();
|
|
24
24
|
}
|
|
@@ -166,3 +166,55 @@ export async function removeDevice(name) {
|
|
|
166
166
|
return true;
|
|
167
167
|
});
|
|
168
168
|
}
|
|
169
|
+
function ignoredPath() {
|
|
170
|
+
return getDevicesIgnoredPath();
|
|
171
|
+
}
|
|
172
|
+
/** Load the set of ignored node names. Missing file => empty set. A malformed
|
|
173
|
+
* file is a hard error for the same reason the registry is: silently returning
|
|
174
|
+
* [] would let the next write wipe the user's dismissals. */
|
|
175
|
+
export async function loadIgnored() {
|
|
176
|
+
const p = ignoredPath();
|
|
177
|
+
let raw;
|
|
178
|
+
try {
|
|
179
|
+
raw = await fs.readFile(p, 'utf-8');
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
if (err && err.code === 'ENOENT')
|
|
183
|
+
return new Set();
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(raw);
|
|
188
|
+
return new Set(Array.isArray(parsed.ignored) ? parsed.ignored : []);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
throw new Error(`Device ignore-list corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** True if `name` is on the ignore-list. */
|
|
195
|
+
export async function isIgnored(name) {
|
|
196
|
+
return (await loadIgnored()).has(name);
|
|
197
|
+
}
|
|
198
|
+
/** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
|
|
199
|
+
export async function addIgnored(name) {
|
|
200
|
+
assertValidDeviceName(name);
|
|
201
|
+
const p = ignoredPath();
|
|
202
|
+
return withRegistryLock(p, async () => {
|
|
203
|
+
const set = await loadIgnored();
|
|
204
|
+
set.add(name);
|
|
205
|
+
await atomicWriteJson(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
|
|
206
|
+
return set;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** Remove a node name from the ignore-list (un-ignore). Returns false if it was
|
|
210
|
+
* not ignored. */
|
|
211
|
+
export async function removeIgnored(name) {
|
|
212
|
+
const p = ignoredPath();
|
|
213
|
+
return withRegistryLock(p, async () => {
|
|
214
|
+
const set = await loadIgnored();
|
|
215
|
+
if (!set.delete(name))
|
|
216
|
+
return false;
|
|
217
|
+
await atomicWriteJson(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
|
|
218
|
+
return true;
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type TailscaleNode } from './tailscale.js';
|
|
2
|
+
export interface DeviceSyncResult {
|
|
3
|
+
/** False when discovery could not run (e.g. tailscale absent) in soft mode. */
|
|
4
|
+
ok: boolean;
|
|
5
|
+
/** Number of tailscale nodes upserted into the registry. */
|
|
6
|
+
synced: number;
|
|
7
|
+
/** Node names discovered but neither registered-before nor ignored. */
|
|
8
|
+
pending: string[];
|
|
9
|
+
/** Populated when ok is false: why discovery was skipped. */
|
|
10
|
+
reason?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Node names present on the tailnet but neither already in the registry nor on
|
|
14
|
+
* the ignore-list — i.e. genuinely new devices worth surfacing. Pure so the
|
|
15
|
+
* flag matrix is unit-testable without a live tailnet.
|
|
16
|
+
*/
|
|
17
|
+
export declare function computePendingDevices(nodes: TailscaleNode[], registered: Iterable<string>, ignored: Iterable<string>): string[];
|
|
18
|
+
/**
|
|
19
|
+
* Ingest `tailscale status --json` into the registry. In soft mode a missing
|
|
20
|
+
* tailscale binary / unreachable daemon resolves to `{ ok: false }` instead of
|
|
21
|
+
* throwing, so callers wiring this into setup/sync never abort the whole run.
|
|
22
|
+
* The `pending` list is computed against the registry state BEFORE this sync so
|
|
23
|
+
* "new" means "not previously registered and not ignored".
|
|
24
|
+
*/
|
|
25
|
+
export declare function runDeviceSync(opts?: {
|
|
26
|
+
soft?: boolean;
|
|
27
|
+
}): Promise<DeviceSyncResult>;
|
|
28
|
+
/**
|
|
29
|
+
* The register/remove/ignore decision for the interactive curation picker.
|
|
30
|
+
* Pure so the highest-risk reconcile logic is unit-testable without a tailnet
|
|
31
|
+
* or a live prompt. `keep` is the set the user left checked; everything else is
|
|
32
|
+
* dismissed. Checked => register (and un-ignore if it was ignored). Unchecked
|
|
33
|
+
* => remove from the registry if it was there, and ignore it so auto-sync never
|
|
34
|
+
* re-adds it.
|
|
35
|
+
*/
|
|
36
|
+
export interface DeviceReconciliation {
|
|
37
|
+
toRegister: string[];
|
|
38
|
+
toUnignore: string[];
|
|
39
|
+
toRemove: string[];
|
|
40
|
+
toIgnore: string[];
|
|
41
|
+
}
|
|
42
|
+
export declare function planDeviceReconciliation(allNames: Iterable<string>, keep: Iterable<string>, registered: Iterable<string>, ignored: Iterable<string>): DeviceReconciliation;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable device discovery.
|
|
3
|
+
*
|
|
4
|
+
* `agents devices sync` was the only thing that ever populated the registry,
|
|
5
|
+
* and it was purely user-invoked — so the registry sat empty until someone
|
|
6
|
+
* remembered to run it. This module extracts the ingest so it can be triggered
|
|
7
|
+
* automatically (from `agents sync` and `agents setup`) without duplicating the
|
|
8
|
+
* tailscale-parse-and-upsert loop, and exposes the pure pending-device diff the
|
|
9
|
+
* curation picker and the menu-bar probe both need.
|
|
10
|
+
*
|
|
11
|
+
* Two failure modes, one function:
|
|
12
|
+
* - hard (default): the CLI `agents devices sync` action wants a clear error
|
|
13
|
+
* and a non-zero exit when tailscale is missing.
|
|
14
|
+
* - soft (`soft: true`): auto-callers must never abort setup/sync because a
|
|
15
|
+
* machine has no tailscale — they get a result with `ok: false` instead.
|
|
16
|
+
*/
|
|
17
|
+
import { loadDevices, loadIgnored, upsertDevice, } from './registry.js';
|
|
18
|
+
import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from './tailscale.js';
|
|
19
|
+
/**
|
|
20
|
+
* Node names present on the tailnet but neither already in the registry nor on
|
|
21
|
+
* the ignore-list — i.e. genuinely new devices worth surfacing. Pure so the
|
|
22
|
+
* flag matrix is unit-testable without a live tailnet.
|
|
23
|
+
*/
|
|
24
|
+
export function computePendingDevices(nodes, registered, ignored) {
|
|
25
|
+
const known = new Set(registered);
|
|
26
|
+
const skip = new Set(ignored);
|
|
27
|
+
return nodes
|
|
28
|
+
.map((n) => n.name)
|
|
29
|
+
.filter((name) => !known.has(name) && !skip.has(name));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Ingest `tailscale status --json` into the registry. In soft mode a missing
|
|
33
|
+
* tailscale binary / unreachable daemon resolves to `{ ok: false }` instead of
|
|
34
|
+
* throwing, so callers wiring this into setup/sync never abort the whole run.
|
|
35
|
+
* The `pending` list is computed against the registry state BEFORE this sync so
|
|
36
|
+
* "new" means "not previously registered and not ignored".
|
|
37
|
+
*/
|
|
38
|
+
export async function runDeviceSync(opts = {}) {
|
|
39
|
+
// Soft mode must be non-fatal for ANY failure, not just a missing tailscale:
|
|
40
|
+
// a corrupted registry/ignore file (both throw by design), a disk error, or
|
|
41
|
+
// registry lock contention (plausible when many agents SessionStart-autosync
|
|
42
|
+
// the same host at once) would otherwise abort the whole `agents sync`. The
|
|
43
|
+
// whole body is inside the guard so the "never a sync failure" promise holds.
|
|
44
|
+
try {
|
|
45
|
+
const nodes = parseTailscaleStatus(tailscaleStatusJson());
|
|
46
|
+
const [registeredBefore, ignored] = await Promise.all([loadDevices(), loadIgnored()]);
|
|
47
|
+
const pending = computePendingDevices(nodes, Object.keys(registeredBefore), ignored);
|
|
48
|
+
// Register/refresh every node the user has NOT dismissed. Skipping ignored
|
|
49
|
+
// nodes is what makes the "register all" default safe: a phone or someone
|
|
50
|
+
// else's laptop the user once dismissed never silently comes back.
|
|
51
|
+
let synced = 0;
|
|
52
|
+
for (const node of nodes) {
|
|
53
|
+
if (ignored.has(node.name))
|
|
54
|
+
continue;
|
|
55
|
+
await upsertDevice(node.name, nodeToDeviceInput(node));
|
|
56
|
+
synced++;
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, synced, pending };
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (opts.soft) {
|
|
62
|
+
return { ok: false, synced: 0, pending: [], reason: err?.message ?? String(err) };
|
|
63
|
+
}
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function planDeviceReconciliation(allNames, keep, registered, ignored) {
|
|
68
|
+
const keepSet = new Set(keep);
|
|
69
|
+
const regSet = new Set(registered);
|
|
70
|
+
const ignSet = new Set(ignored);
|
|
71
|
+
const out = { toRegister: [], toUnignore: [], toRemove: [], toIgnore: [] };
|
|
72
|
+
for (const name of allNames) {
|
|
73
|
+
if (keepSet.has(name)) {
|
|
74
|
+
out.toRegister.push(name);
|
|
75
|
+
if (ignSet.has(name))
|
|
76
|
+
out.toUnignore.push(name);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
if (regSet.has(name))
|
|
80
|
+
out.toRemove.push(name);
|
|
81
|
+
out.toIgnore.push(name);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
@@ -28,6 +28,8 @@ export interface ActiveSession {
|
|
|
28
28
|
sessionFile?: string;
|
|
29
29
|
startedAtMs?: number;
|
|
30
30
|
status: ActiveStatus;
|
|
31
|
+
/** How many live PIDs resolve to this same session (subagents/forks). 1 unless collapsed. */
|
|
32
|
+
pidCount?: number;
|
|
31
33
|
teamName?: string;
|
|
32
34
|
agentId?: string;
|
|
33
35
|
cloudProvider?: string;
|
|
@@ -536,5 +536,33 @@ export async function getActiveSessions(opts = {}) {
|
|
|
536
536
|
if (s.pid)
|
|
537
537
|
knownPids.add(s.pid);
|
|
538
538
|
const unattributed = opts.skipHeadless ? [] : await listUnattributedActive(knownPids);
|
|
539
|
-
return [...teams, ...terminals, ...cloud, ...unattributed];
|
|
539
|
+
return dedupeBySession([...teams, ...terminals, ...cloud, ...unattributed]);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Collapse rows that resolve to the *same* session — a session with many
|
|
543
|
+
* subagent/fork PIDs (all matched to one transcript file) would otherwise print
|
|
544
|
+
* dozens of identical rows. Keyed by session id (falling back to the file), the
|
|
545
|
+
* first row wins and carries a `pidCount`. Rows with no session identity (cloud,
|
|
546
|
+
* unresolved headless) pass through untouched.
|
|
547
|
+
*/
|
|
548
|
+
function dedupeBySession(sessions) {
|
|
549
|
+
const out = [];
|
|
550
|
+
const byKey = new Map();
|
|
551
|
+
for (const s of sessions) {
|
|
552
|
+
const key = s.sessionId || s.sessionFile;
|
|
553
|
+
if (!key) {
|
|
554
|
+
out.push(s);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
const existing = byKey.get(key);
|
|
558
|
+
if (existing) {
|
|
559
|
+
existing.pidCount = (existing.pidCount ?? 1) + 1;
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
s.pidCount = 1;
|
|
563
|
+
byKey.set(key, s);
|
|
564
|
+
out.push(s);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return out;
|
|
540
568
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catch-up digest extractors.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that turn a session's events into the signals a developer needs
|
|
5
|
+
* to reload a task fast when switching between many agents: which files changed
|
|
6
|
+
* and how (created / modified / deleted), which tools dominated the work, and the
|
|
7
|
+
* last test/build result. Consumed by the single-session view and the picker
|
|
8
|
+
* preview. No I/O — fully unit-testable.
|
|
9
|
+
*/
|
|
10
|
+
import type { SessionEvent } from './types.js';
|
|
11
|
+
export type FileOp = 'created' | 'modified' | 'deleted';
|
|
12
|
+
export interface FileChange {
|
|
13
|
+
path: string;
|
|
14
|
+
op: FileOp;
|
|
15
|
+
}
|
|
16
|
+
/** Extract file paths deleted by a shell command (rm / git rm / unlink). Conservative. */
|
|
17
|
+
export declare function extractDeletedPaths(command: string): string[];
|
|
18
|
+
/**
|
|
19
|
+
* Classify every touched file as created / modified / deleted from the event
|
|
20
|
+
* stream. Heuristics: a Write to a path never previously Read and not seen
|
|
21
|
+
* before is a *creation*; an Edit (or a Write to a known/read path) is a
|
|
22
|
+
* *modification*; a path in an `rm`/`git rm` command is a *deletion* (and wins
|
|
23
|
+
* over create/modify — a created-then-deleted file nets to gone). Plan files
|
|
24
|
+
* (`.claude/plans/*.md`) are excluded; they're surfaced by detectPlan.
|
|
25
|
+
*/
|
|
26
|
+
export declare function classifyFileChanges(events: SessionEvent[]): FileChange[];
|
|
27
|
+
/** Net change summary: counts per op. */
|
|
28
|
+
export declare function changeCounts(changes: FileChange[]): {
|
|
29
|
+
created: number;
|
|
30
|
+
modified: number;
|
|
31
|
+
deleted: number;
|
|
32
|
+
};
|
|
33
|
+
/** Tool histogram sorted highest-first, capped to `top` entries. */
|
|
34
|
+
export declare function toolHistogram(toolCounts: Record<string, number>, top?: number): Array<{
|
|
35
|
+
tool: string;
|
|
36
|
+
count: number;
|
|
37
|
+
}>;
|
|
38
|
+
export interface TestResult {
|
|
39
|
+
runner: string;
|
|
40
|
+
passed?: number;
|
|
41
|
+
failed?: number;
|
|
42
|
+
/** True when we could parse a pass/fail verdict. */
|
|
43
|
+
ok: boolean;
|
|
44
|
+
ts: number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The most recent test/build run and its verdict. Correlates a runner command
|
|
48
|
+
* (tool_use) with the next tool_result's output. Returns undefined if none ran.
|
|
49
|
+
*/
|
|
50
|
+
export declare function detectTestResult(events: SessionEvent[]): TestResult | undefined;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catch-up digest extractors.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that turn a session's events into the signals a developer needs
|
|
5
|
+
* to reload a task fast when switching between many agents: which files changed
|
|
6
|
+
* and how (created / modified / deleted), which tools dominated the work, and the
|
|
7
|
+
* last test/build result. Consumed by the single-session view and the picker
|
|
8
|
+
* preview. No I/O — fully unit-testable.
|
|
9
|
+
*/
|
|
10
|
+
// Tool vocab mirrors parse.ts / render.ts so classification matches what those
|
|
11
|
+
// modules already recognize across Claude/Codex/others.
|
|
12
|
+
const READ_TOOLS = new Set(['Read', 'read_file', 'view_file', 'cat_file', 'get_file']);
|
|
13
|
+
const WRITE_TOOLS = new Set(['Write', 'write_file', 'create_file']);
|
|
14
|
+
const EDIT_TOOLS = new Set(['Edit', 'edit_file', 'replace', 'patch', 'MultiEdit', 'apply_patch']);
|
|
15
|
+
/** Extract file paths deleted by a shell command (rm / git rm / unlink). Conservative. */
|
|
16
|
+
export function extractDeletedPaths(command) {
|
|
17
|
+
const out = [];
|
|
18
|
+
// Split on && ; | to inspect each simple command separately.
|
|
19
|
+
for (const seg of command.split(/&&|\|\||;|\|/)) {
|
|
20
|
+
const m = seg.trim().match(/^(?:sudo\s+)?(?:git\s+rm|rm|unlink)\s+(.+)$/);
|
|
21
|
+
if (!m)
|
|
22
|
+
continue;
|
|
23
|
+
for (const tok of m[1].split(/\s+/)) {
|
|
24
|
+
if (tok.startsWith('-'))
|
|
25
|
+
continue; // flags (-r, -f, --force)
|
|
26
|
+
if (/[*?{}]/.test(tok))
|
|
27
|
+
continue; // globs — too imprecise to attribute
|
|
28
|
+
out.push(tok.replace(/^['"]|['"]$/g, '')); // unquote
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Classify every touched file as created / modified / deleted from the event
|
|
35
|
+
* stream. Heuristics: a Write to a path never previously Read and not seen
|
|
36
|
+
* before is a *creation*; an Edit (or a Write to a known/read path) is a
|
|
37
|
+
* *modification*; a path in an `rm`/`git rm` command is a *deletion* (and wins
|
|
38
|
+
* over create/modify — a created-then-deleted file nets to gone). Plan files
|
|
39
|
+
* (`.claude/plans/*.md`) are excluded; they're surfaced by detectPlan.
|
|
40
|
+
*/
|
|
41
|
+
export function classifyFileChanges(events) {
|
|
42
|
+
const readBefore = new Set();
|
|
43
|
+
const created = new Set();
|
|
44
|
+
const modified = new Set();
|
|
45
|
+
const deleted = new Set();
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
for (const e of events) {
|
|
48
|
+
if (e.type !== 'tool_use' || e._local)
|
|
49
|
+
continue;
|
|
50
|
+
if (e.command)
|
|
51
|
+
for (const d of extractDeletedPaths(e.command))
|
|
52
|
+
deleted.add(d);
|
|
53
|
+
const tool = e.tool || '';
|
|
54
|
+
const args = e.args || {};
|
|
55
|
+
const p = e.path || args.file_path || args.path || '';
|
|
56
|
+
if (!p)
|
|
57
|
+
continue;
|
|
58
|
+
if (p.includes('.claude/plans/') && p.endsWith('.md'))
|
|
59
|
+
continue;
|
|
60
|
+
if (READ_TOOLS.has(tool)) {
|
|
61
|
+
readBefore.add(p);
|
|
62
|
+
}
|
|
63
|
+
else if (WRITE_TOOLS.has(tool)) {
|
|
64
|
+
if (!seen.has(p) && !readBefore.has(p))
|
|
65
|
+
created.add(p);
|
|
66
|
+
else
|
|
67
|
+
modified.add(p);
|
|
68
|
+
seen.add(p);
|
|
69
|
+
deleted.delete(p); // a write after a delete recreates the file
|
|
70
|
+
}
|
|
71
|
+
else if (EDIT_TOOLS.has(tool)) {
|
|
72
|
+
modified.add(p);
|
|
73
|
+
seen.add(p);
|
|
74
|
+
deleted.delete(p);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const out = [];
|
|
78
|
+
for (const p of created)
|
|
79
|
+
if (!deleted.has(p))
|
|
80
|
+
out.push({ path: p, op: 'created' });
|
|
81
|
+
for (const p of modified)
|
|
82
|
+
if (!created.has(p) && !deleted.has(p))
|
|
83
|
+
out.push({ path: p, op: 'modified' });
|
|
84
|
+
for (const p of deleted)
|
|
85
|
+
out.push({ path: p, op: 'deleted' });
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** Net change summary: counts per op. */
|
|
89
|
+
export function changeCounts(changes) {
|
|
90
|
+
const c = { created: 0, modified: 0, deleted: 0 };
|
|
91
|
+
for (const ch of changes)
|
|
92
|
+
c[ch.op]++;
|
|
93
|
+
return c;
|
|
94
|
+
}
|
|
95
|
+
/** Tool histogram sorted highest-first, capped to `top` entries. */
|
|
96
|
+
export function toolHistogram(toolCounts, top = 8) {
|
|
97
|
+
return Object.entries(toolCounts)
|
|
98
|
+
.map(([tool, count]) => ({ tool, count }))
|
|
99
|
+
.sort((a, b) => b.count - a.count || a.tool.localeCompare(b.tool))
|
|
100
|
+
.slice(0, top);
|
|
101
|
+
}
|
|
102
|
+
/** Recognized test/build runners → the label we show. */
|
|
103
|
+
const TEST_RUNNERS = [
|
|
104
|
+
{ re: /\b((?:bun|npm|yarn|pnpm)\s+(?:run\s+)?test|vitest|jest)\b/, label: 'tests' },
|
|
105
|
+
{ re: /\bpytest\b/, label: 'pytest' },
|
|
106
|
+
{ re: /\bgo\s+test\b/, label: 'go test' },
|
|
107
|
+
{ re: /\bcargo\s+test\b/, label: 'cargo test' },
|
|
108
|
+
{ re: /\b(tsc|tsc\s+--noEmit)\b/, label: 'tsc' },
|
|
109
|
+
];
|
|
110
|
+
/** Parse pass/fail counts from common runner output. */
|
|
111
|
+
function parseTestOutput(runner, output) {
|
|
112
|
+
// vitest/jest/bun: "N passed", "N failed"; pytest: "N passed, N failed".
|
|
113
|
+
// Take the LAST occurrence — runners print a per-file line first, then the
|
|
114
|
+
// authoritative aggregate ("Tests 4 failed | 294 passed") at the end.
|
|
115
|
+
const lastNum = (re) => {
|
|
116
|
+
let m;
|
|
117
|
+
let val;
|
|
118
|
+
const g = new RegExp(re.source, 'gi');
|
|
119
|
+
while ((m = g.exec(output)) !== null)
|
|
120
|
+
val = +m[1];
|
|
121
|
+
return val;
|
|
122
|
+
};
|
|
123
|
+
const passed = lastNum(/(\d+)\s+pass(?:ed)?/);
|
|
124
|
+
const failed = lastNum(/(\d+)\s+fail(?:ed|ures?)?/);
|
|
125
|
+
if (passed !== undefined || failed !== undefined) {
|
|
126
|
+
return { passed, failed, ok: true };
|
|
127
|
+
}
|
|
128
|
+
// tsc: no news is good news; "error TSxxxx" means failure.
|
|
129
|
+
if (runner === 'tsc') {
|
|
130
|
+
const errs = output.match(/error\s+TS\d+/gi);
|
|
131
|
+
return { failed: errs ? errs.length : 0, ok: true };
|
|
132
|
+
}
|
|
133
|
+
// go test: no pass/fail counts — uses `--- PASS/FAIL:` lines and an ok/FAIL
|
|
134
|
+
// summary. Count the per-test markers; fall back to the summary verdict.
|
|
135
|
+
if (runner === 'go test') {
|
|
136
|
+
const passCount = (output.match(/---\s+PASS/gi) || []).length;
|
|
137
|
+
const failCount = (output.match(/---\s+FAIL/gi) || []).length;
|
|
138
|
+
const sawFail = failCount > 0 || /(^|\s)FAIL($|\s)/.test(output);
|
|
139
|
+
const sawOk = /(^|\s)(ok|PASS)($|\s)/.test(output);
|
|
140
|
+
if (sawFail || sawOk) {
|
|
141
|
+
return { passed: passCount || undefined, failed: sawFail ? failCount || 1 : 0, ok: true };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { ok: false };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The most recent test/build run and its verdict. Correlates a runner command
|
|
148
|
+
* (tool_use) with the next tool_result's output. Returns undefined if none ran.
|
|
149
|
+
*/
|
|
150
|
+
export function detectTestResult(events) {
|
|
151
|
+
let pending = null;
|
|
152
|
+
let last;
|
|
153
|
+
for (const e of events) {
|
|
154
|
+
const ts = new Date(e.timestamp).getTime() || 0;
|
|
155
|
+
if (e.type === 'tool_use' && e.command) {
|
|
156
|
+
const hit = TEST_RUNNERS.find(r => r.re.test(e.command));
|
|
157
|
+
pending = hit ? { runner: hit.label, ts } : pending;
|
|
158
|
+
}
|
|
159
|
+
else if (e.type === 'tool_result' && pending) {
|
|
160
|
+
const parsed = parseTestOutput(pending.runner, e.output || '');
|
|
161
|
+
last = { runner: pending.runner, ts: pending.ts, ...parsed };
|
|
162
|
+
pending = null;
|
|
163
|
+
}
|
|
164
|
+
else if (e.type === 'error' && pending) {
|
|
165
|
+
last = { runner: pending.runner, ts: pending.ts, ok: true, failed: 1 };
|
|
166
|
+
pending = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return last;
|
|
170
|
+
}
|
|
@@ -49,6 +49,8 @@ export interface SessionStats {
|
|
|
49
49
|
userTurns: number;
|
|
50
50
|
assistantTurns: number;
|
|
51
51
|
toolCount: number;
|
|
52
|
+
/** Per-tool call counts (histogram), highest first when rendered. */
|
|
53
|
+
toolCounts: Record<string, number>;
|
|
52
54
|
errorCount: number;
|
|
53
55
|
outputTokens: number;
|
|
54
56
|
cacheReadTokens: number;
|
|
@@ -11,6 +11,7 @@ import { summarizeToolUse } from './parse.js';
|
|
|
11
11
|
import { cleanSessionPrompt, extractSessionTopic } from './prompt.js';
|
|
12
12
|
import { renderMarkdown } from '../markdown.js';
|
|
13
13
|
import { redactSecrets } from '../redact.js';
|
|
14
|
+
import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from './digest.js';
|
|
14
15
|
// ── Path helpers ──────────────────────────────────────────────────────────────
|
|
15
16
|
/**
|
|
16
17
|
* Return absPath relative to cwd; fall back to ~/… then absolute.
|
|
@@ -153,6 +154,7 @@ export function collapseRetries(commands) {
|
|
|
153
154
|
/** Compute aggregate statistics (turns, tools, tokens, duration) from session events. */
|
|
154
155
|
export function computeSummaryStats(events) {
|
|
155
156
|
const modelSet = new Set();
|
|
157
|
+
const toolCounts = {};
|
|
156
158
|
let userTurns = 0;
|
|
157
159
|
let assistantTurns = 0;
|
|
158
160
|
let toolCount = 0;
|
|
@@ -177,6 +179,8 @@ export function computeSummaryStats(events) {
|
|
|
177
179
|
}
|
|
178
180
|
else if (e.type === 'tool_use' && !e._local) {
|
|
179
181
|
toolCount++;
|
|
182
|
+
if (e.tool)
|
|
183
|
+
toolCounts[e.tool] = (toolCounts[e.tool] ?? 0) + 1;
|
|
180
184
|
}
|
|
181
185
|
else if (e.type === 'error') {
|
|
182
186
|
errorCount++;
|
|
@@ -193,6 +197,7 @@ export function computeSummaryStats(events) {
|
|
|
193
197
|
userTurns,
|
|
194
198
|
assistantTurns,
|
|
195
199
|
toolCount,
|
|
200
|
+
toolCounts,
|
|
196
201
|
errorCount,
|
|
197
202
|
outputTokens,
|
|
198
203
|
cacheReadTokens,
|
|
@@ -426,6 +431,76 @@ function renderActivityLine(item) {
|
|
|
426
431
|
return chalk.green('Msg ') + ' ' + chalk.gray('"' + trim(item.label) + '"');
|
|
427
432
|
}
|
|
428
433
|
}
|
|
434
|
+
// ── Catch-up digest sections ──────────────────────────────────────────────────
|
|
435
|
+
const OP_GLYPH = {
|
|
436
|
+
created: (s) => chalk.green(s),
|
|
437
|
+
modified: (s) => chalk.yellow(s),
|
|
438
|
+
deleted: (s) => chalk.red(s),
|
|
439
|
+
};
|
|
440
|
+
const OP_MARK = { created: '+', modified: '~', deleted: '−' };
|
|
441
|
+
/**
|
|
442
|
+
* Render the Changes section: files grouped by directory, each tagged with its
|
|
443
|
+
* create/modify/delete lifecycle, plus a `+N ~N −N` summary. Replaces the old
|
|
444
|
+
* flat "Modified" list. Returns true if anything was rendered.
|
|
445
|
+
*/
|
|
446
|
+
function renderChangesSection(lines, events, cwd) {
|
|
447
|
+
// In-project changes only; edits outside cwd (e.g. /tmp) keep their own
|
|
448
|
+
// "External edits" section so they don't clutter the project's changeset.
|
|
449
|
+
const inCwd = (p) => !cwd || !p.startsWith('/') || p.startsWith(cwd + '/');
|
|
450
|
+
const changes = classifyFileChanges(events).filter(ch => inCwd(ch.path));
|
|
451
|
+
if (changes.length === 0)
|
|
452
|
+
return false;
|
|
453
|
+
const c = changeCounts(changes);
|
|
454
|
+
const opByRel = new Map();
|
|
455
|
+
for (const ch of changes)
|
|
456
|
+
opByRel.set(relativeToCwd(ch.path, cwd), ch.op);
|
|
457
|
+
const summary = [
|
|
458
|
+
c.created ? chalk.green(`+${c.created}`) : '',
|
|
459
|
+
c.modified ? chalk.yellow(`~${c.modified}`) : '',
|
|
460
|
+
c.deleted ? chalk.red(`−${c.deleted}`) : '',
|
|
461
|
+
].filter(Boolean).join(' ');
|
|
462
|
+
lines.push(chalk.bold('Changes') + chalk.gray(` (${changes.length}) `) + summary);
|
|
463
|
+
const groups = groupByParentDir(changes.map(ch => ch.path), cwd);
|
|
464
|
+
const single = groups.size === 1;
|
|
465
|
+
for (const [dir, files] of groups) {
|
|
466
|
+
// Single dir: show the full relative path per file (dir/base). Multiple
|
|
467
|
+
// dirs: a dir header, then bare filenames under it.
|
|
468
|
+
if (!single)
|
|
469
|
+
lines.push(' ' + chalk.dim(dir + '/'));
|
|
470
|
+
for (const f of files.sort()) {
|
|
471
|
+
const rel = dir === '.' ? f : `${dir}/${f}`;
|
|
472
|
+
const op = opByRel.get(rel) ?? 'modified';
|
|
473
|
+
const shown = single ? rel : f;
|
|
474
|
+
const name = op === 'deleted' ? chalk.strikethrough(chalk.gray(shown)) : shown;
|
|
475
|
+
lines.push((single ? ' ' : ' ') + OP_GLYPH[op](OP_MARK[op]) + ' ' + name);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
lines.push('');
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
/** Render the tool histogram: `Edit 61 · Bash 48 · Read 35 …`. */
|
|
482
|
+
function renderToolsSection(lines, stats) {
|
|
483
|
+
const hist = toolHistogram(stats.toolCounts, 8);
|
|
484
|
+
if (hist.length === 0)
|
|
485
|
+
return;
|
|
486
|
+
const parts = hist.map(h => `${chalk.white(h.tool)} ${chalk.gray(String(h.count))}`);
|
|
487
|
+
lines.push(chalk.bold('Tools') + ' ' + parts.join(chalk.gray(' · ')));
|
|
488
|
+
lines.push('');
|
|
489
|
+
}
|
|
490
|
+
/** Render the last test/build verdict, e.g. `Tests tests: 294 pass · 4 fail`. */
|
|
491
|
+
function renderTestsLine(lines, events) {
|
|
492
|
+
const r = detectTestResult(events);
|
|
493
|
+
if (!r || !r.ok)
|
|
494
|
+
return;
|
|
495
|
+
const bits = [];
|
|
496
|
+
if (r.passed !== undefined)
|
|
497
|
+
bits.push(chalk.green(`${r.passed} pass`));
|
|
498
|
+
if (r.failed !== undefined)
|
|
499
|
+
bits.push(r.failed > 0 ? chalk.red(`${r.failed} fail`) : chalk.gray('0 fail'));
|
|
500
|
+
const verdict = r.failed && r.failed > 0 ? chalk.red('✗') : chalk.green('✓');
|
|
501
|
+
lines.push(chalk.bold('Tests') + ` ${verdict} ${chalk.cyan(r.runner)} ${bits.join(chalk.gray(' · '))}`);
|
|
502
|
+
lines.push('');
|
|
503
|
+
}
|
|
429
504
|
// ── Main summary renderer ─────────────────────────────────────────────────────
|
|
430
505
|
/**
|
|
431
506
|
* Render session as an activity summary.
|
|
@@ -560,7 +635,6 @@ export function renderSummary(events, cwd) {
|
|
|
560
635
|
}
|
|
561
636
|
return m;
|
|
562
637
|
};
|
|
563
|
-
const modifiedAbsMap = buildAbsMap(filesModifiedAbs);
|
|
564
638
|
const readAbsMap = buildAbsMap(filesReadAbs);
|
|
565
639
|
// ── Render sections ───────────────────────────────────────────────────────
|
|
566
640
|
const lines = [''];
|
|
@@ -640,15 +714,14 @@ export function renderSummary(events, cwd) {
|
|
|
640
714
|
chalk.gray(`: ${errors.length} failure${errors.length !== 1 ? 's' : ''} — first: ${firstDesc}`));
|
|
641
715
|
lines.push('');
|
|
642
716
|
}
|
|
643
|
-
// 6.
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
//
|
|
651
|
-
// Filter out plan files (already shown in Plan section)
|
|
717
|
+
// 6. Changes — files grouped by directory with create/modify/delete lifecycle
|
|
718
|
+
// (replaces the old flat "Modified" + "External edits" lists).
|
|
719
|
+
renderChangesSection(lines, events, cwd);
|
|
720
|
+
// 6b. Catch-up signals: last test/build verdict, then the tool histogram.
|
|
721
|
+
renderTestsLine(lines, events);
|
|
722
|
+
renderToolsSection(lines, computeSummaryStats(events));
|
|
723
|
+
// 6c. External edits (files edited outside the project root — typically /tmp).
|
|
724
|
+
// Filter out plan files (already shown in Plan section).
|
|
652
725
|
const externalNonPlan = [...filesModifiedExternal].filter(p => !(p.includes('.claude/plans/') && p.endsWith('.md')));
|
|
653
726
|
if (externalNonPlan.length > 0) {
|
|
654
727
|
const externalList = externalNonPlan.sort();
|
package/dist/lib/state.d.ts
CHANGED
|
@@ -156,6 +156,8 @@ export declare function getTeamsAgentsDir(): string;
|
|
|
156
156
|
export declare function getTeamsRegistryPath(): string;
|
|
157
157
|
/** Path to the device registry — SSH device profiles with platform/auth metadata. Durable runtime, per-machine (host list + addresses are NOT pulled by `agents repo push`). */
|
|
158
158
|
export declare function getDevicesRegistryPath(): string;
|
|
159
|
+
/** Path to the device ignore-list — tailscale node names the user dismissed, so auto-discovery never re-suggests them. Per-machine, same dir as the registry. */
|
|
160
|
+
export declare function getDevicesIgnoredPath(): string;
|
|
159
161
|
/** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
|
|
160
162
|
export declare function getCloudDir(): string;
|
|
161
163
|
/** Path to terminal session metadata (~/.agents/.cache/terminals/). */
|
package/dist/lib/state.js
CHANGED
|
@@ -352,6 +352,8 @@ export function getTeamsAgentsDir() { return TEAMS_AGENTS_DIR; }
|
|
|
352
352
|
export function getTeamsRegistryPath() { return path.join(HISTORY_DIR, 'teams', 'registry.json'); }
|
|
353
353
|
/** Path to the device registry — SSH device profiles with platform/auth metadata. Durable runtime, per-machine (host list + addresses are NOT pulled by `agents repo push`). */
|
|
354
354
|
export function getDevicesRegistryPath() { return path.join(HISTORY_DIR, 'devices', 'registry.json'); }
|
|
355
|
+
/** Path to the device ignore-list — tailscale node names the user dismissed, so auto-discovery never re-suggests them. Per-machine, same dir as the registry. */
|
|
356
|
+
export function getDevicesIgnoredPath() { return path.join(HISTORY_DIR, 'devices', 'ignored.json'); }
|
|
355
357
|
/** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
|
|
356
358
|
export function getCloudDir() { return CLOUD_DIR; }
|
|
357
359
|
/** Path to terminal session metadata (~/.agents/.cache/terminals/). */
|
|
@@ -120,6 +120,16 @@ export async function runUmbrellaSync(args) {
|
|
|
120
120
|
const { refresh } = await import('./refresh.js');
|
|
121
121
|
await refresh({ skipPrompts: yes });
|
|
122
122
|
result.reconciled = true;
|
|
123
|
+
// Keep the local device registry current with the tailnet. Soft: a machine
|
|
124
|
+
// without tailscale is a clean no-op, never a sync failure. This is the
|
|
125
|
+
// wiring that fixes the "registry stays empty until you remember to run
|
|
126
|
+
// `agents devices sync`" gap — the SessionStart autosync now populates it.
|
|
127
|
+
const { runDeviceSync } = await import('./devices/sync.js');
|
|
128
|
+
const dev = await runDeviceSync({ soft: true });
|
|
129
|
+
result.devices = { synced: dev.synced, pending: dev.pending.length, skipped: !dev.ok };
|
|
130
|
+
if (dev.ok) {
|
|
131
|
+
log(`devices: ${dev.synced} synced${dev.pending.length ? `, ${dev.pending.length} new` : ''}`);
|
|
132
|
+
}
|
|
123
133
|
}
|
|
124
134
|
return result;
|
|
125
135
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.31",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|