@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.
@@ -11,6 +11,7 @@ import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.j
11
11
  import { linkPath, relativeToCwd } from '../lib/session/render.js';
12
12
  import { renderMarkdown } from '../lib/markdown.js';
13
13
  import { itemPicker } from '../lib/picker.js';
14
+ import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
14
15
  /**
15
16
  * SessionMeta originates in discover.ts (gitBranch, cwd, label, etc. read from
16
17
  * untrusted session files). parseSession sanitizes event payloads at its
@@ -176,8 +177,8 @@ const TODOS_MAX_ITEMS = 5;
176
177
  function formatCompactPreview(events, session) {
177
178
  let firstUser = '';
178
179
  let lastAssistant = '';
179
- const filesModified = new Set();
180
180
  const filesRead = new Set();
181
+ const toolCounts = {};
181
182
  let toolCalls = 0;
182
183
  let planFile = '';
183
184
  let latestTodos = null;
@@ -195,10 +196,7 @@ function formatCompactPreview(events, session) {
195
196
  else if (event.type === 'tool_use' && !event._local) {
196
197
  const tool = event.tool || '';
197
198
  const p = event.path || event.args?.file_path || event.args?.path || '';
198
- if (['Write', 'Edit', 'write_file', 'edit_file', 'create_file', 'replace', 'patch'].includes(tool) && p) {
199
- filesModified.add(p);
200
- }
201
- else if (['Read', 'read_file', 'view_file', 'cat_file', 'get_file'].includes(tool) && p) {
199
+ if (['Read', 'read_file', 'view_file', 'cat_file', 'get_file'].includes(tool) && p) {
202
200
  filesRead.add(p);
203
201
  }
204
202
  if (!planFile && p && /\/plans\/[^/]+\.md$/.test(p)) {
@@ -207,9 +205,14 @@ function formatCompactPreview(events, session) {
207
205
  if (tool === 'TodoWrite' && Array.isArray(event.args?.todos)) {
208
206
  latestTodos = event.args.todos;
209
207
  }
208
+ if (tool)
209
+ toolCounts[tool] = (toolCounts[tool] ?? 0) + 1;
210
210
  toolCalls++;
211
211
  }
212
212
  }
213
+ // Digest signals folded into the preview: change lifecycle, tool mix, tests.
214
+ const changes = classifyFileChanges(events);
215
+ const chg = changeCounts(changes);
213
216
  const lines = [];
214
217
  const termWidth = process.stdout.columns || 80;
215
218
  if (firstUser) {
@@ -219,14 +222,36 @@ function formatCompactPreview(events, session) {
219
222
  }
220
223
  }
221
224
  const activity = [];
222
- if (filesModified.size)
223
- activity.push(`${filesModified.size} modified`);
225
+ const changed = chg.created + chg.modified + chg.deleted;
226
+ if (changed) {
227
+ const parts = [
228
+ chg.created ? chalk.green(`+${chg.created}`) : '',
229
+ chg.modified ? chalk.yellow(`~${chg.modified}`) : '',
230
+ chg.deleted ? chalk.red(`−${chg.deleted}`) : '',
231
+ ].filter(Boolean).join(' ');
232
+ activity.push(`${parts} ${chalk.gray('changed')}`);
233
+ }
224
234
  if (filesRead.size)
225
- activity.push(`${filesRead.size} read`);
235
+ activity.push(chalk.gray(`${filesRead.size} read`));
226
236
  if (toolCalls)
227
- activity.push(`${toolCalls} tool${toolCalls === 1 ? '' : 's'}`);
237
+ activity.push(chalk.gray(`${toolCalls} tool${toolCalls === 1 ? '' : 's'}`));
228
238
  if (activity.length) {
229
- lines.push(chalk.cyan('Activity: ') + chalk.gray(activity.join(' · ')));
239
+ lines.push(chalk.cyan('Changes: ') + activity.join(chalk.gray(' · ')));
240
+ }
241
+ // Tool mix (top 4) — what kind of work this was.
242
+ const hist = toolHistogram(toolCounts, 4);
243
+ if (hist.length) {
244
+ lines.push(chalk.cyan('Tools: ') + chalk.gray(hist.map(h => `${h.tool} ${h.count}`).join(' · ')));
245
+ }
246
+ // Last test/build verdict.
247
+ const test = detectTestResult(events);
248
+ if (test?.ok) {
249
+ const bits = [
250
+ test.passed !== undefined ? chalk.green(`${test.passed} pass`) : '',
251
+ test.failed ? chalk.red(`${test.failed} fail`) : '',
252
+ ].filter(Boolean).join(chalk.gray(' · '));
253
+ const mark = test.failed ? chalk.red('✗') : chalk.green('✓');
254
+ lines.push(chalk.cyan('Tests: ') + `${mark} ${test.runner}${bits ? ' ' + bits : ''}`);
230
255
  }
231
256
  if (planFile) {
232
257
  const basename = planFile.split('/').pop() || planFile;
@@ -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 badges = signalBadges(s);
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 +
@@ -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'));
@@ -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 { getDevice, loadDevices, removeDevice, upsertDevice, } from '../lib/devices/registry.js';
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
- function deviceSummary(d) {
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
- return ` ${chalk.bold(d.name.padEnd(16))} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}`;
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 # ingest tailscale nodes (auto-detect platform)
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` and create/update device profiles (auto-detects platform, address, reachability).')
65
- .action(async () => {
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 nodes = parseTailscaleStatus(tailscaleStatusJson());
69
- spinner.text = `Updating ${nodes.length} device${nodes.length === 1 ? '' : 's'}...`;
70
- for (const node of nodes) {
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
- .action(async () => {
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>')
@@ -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 authPath = path.join(base, '.grok', 'auth.json');
944
- if (fs.existsSync(authPath)) {
945
- const data = JSON.parse(await fs.promises.readFile(authPath, 'utf-8'));
946
- const email = data.email || data.user?.email || data.account?.email || null;
947
- return { ...empty, email, signedIn: !!email, lastActive };
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 token at
955
- // ~/.gemini/antigravity-cli/antigravity-oauth-token. It's a consumer
956
- // OAuth grant (access + refresh token, no id_token), so there's no email
957
- // claim to read locally — presence of a refresh token is the only
958
- // signed-in signal we can derive without a network call.
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 (!tokenPath)
961
- return { ...empty, lastActive };
962
- const data = JSON.parse(await fs.promises.readFile(tokenPath, 'utf-8'));
963
- const hasToken = typeof data?.token?.refresh_token === 'string' && !!data.token.refresh_token;
964
- if (!hasToken)
965
- return { ...empty, lastActive };
966
- return { ...empty, signedIn: true, lastActive };
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. Modified files
644
- if (filesModifiedAbs.size > 0) {
645
- lines.push(chalk.bold('Modified') + chalk.gray(` (${filesModifiedAbs.size})`));
646
- const groups = groupByParentDir(filesModifiedAbs, cwd);
647
- renderFileGroup(lines, groups, modifiedAbsMap);
648
- lines.push('');
649
- }
650
- // 6b. External edits (files edited outside the project root — typically /tmp)
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();
@@ -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/). */
@@ -57,6 +57,11 @@ export interface UmbrellaResult {
57
57
  pulled: number;
58
58
  merged: number;
59
59
  };
60
+ devices?: {
61
+ synced: number;
62
+ pending: number;
63
+ skipped: boolean;
64
+ };
60
65
  reconciled: boolean;
61
66
  }
62
67
  export interface RunUmbrellaArgs {
@@ -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.30",
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",