@phnx-labs/agents-cli 1.20.30 → 1.20.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.20.31
6
+
7
+ **`agents sessions <id>`: a catch-up digest for switching between many agents (#502)**
8
+
9
+ - Opening a single session now leads with its auto-inferred title (user `/rename` > Claude `ai-title` > first-prompt topic) and PR / worktree / ticket badges, then a **Changes** section that groups touched files by directory and tags each as created / modified / deleted (with a `+N ~N -N` summary) instead of the old flat "Modified" list, a **Tools** histogram (per-tool call counts), and a **Tests** verdict parsed from the last `vitest` / `jest` / `pytest` / `go test` / `cargo test` / `tsc` run. The same signals are folded into the interactive picker preview.
10
+ - `agents sessions --active` now collapses the many subagent/fork PIDs of one session into a single row with a `×N` count instead of printing dozens of identical lines. Source: `src/lib/session/digest.ts`, `src/lib/session/render.ts`, `src/lib/session/active.ts`, `src/commands/sessions.ts`, `src/commands/sessions-picker.ts`.
11
+
12
+ ## 1.20.30
13
+
14
+ **`agents sessions` live state engine: waiting / PR / worktree / ticket detection + reliable preview (#494)**
15
+
16
+ - `agents sessions --active` infers real activity from each transcript's tail — **working** / **waiting** / **idle** — rather than the old mtime-only running/idle guess, using structural signals (Claude `ExitPlanMode` / `AskUserQuestion`) plus a question + mtime heuristic for Codex. It detects and badges a PR opened during the session (`gh pr create` + the resulting pull URL), a git worktree (`.agents/worktrees/<slug>/`), and a Linear/Jira ticket (from the prompt or branch), and shows the latest turn as the preview instead of the first prompt.
17
+ - `--waiting` filters `--active` to only sessions blocked on your input and exits non-zero (a scriptable gate); `--tree` groups the listing by directory, dropping the id/version columns while keeping the short-id handle.
18
+ - The preview line is now width-correct: measurement is ANSI- and wide-char-aware and reads `$COLUMNS` first, so it no longer wraps or drifts under tmux or over `--host` SSH (the remote is handed the caller's width). Session index schema v7 persists the PR / worktree / ticket signals so historical listings carry them too. Source: `src/lib/session/state.ts`, `src/lib/session/tail.ts`, `src/lib/session/width.ts`, `src/lib/session/{discover,db,active}.ts`, `src/commands/sessions.ts`.
19
+
5
20
  **`agents sessions --host <machine>`: query a remote machine's sessions live over SSH**
6
21
 
7
22
  - `agents sessions "<query>" --host <alias|user@host>` runs the same session query on a remote machine's own index over SSH and streams the result back — repeat `--host` (or pass several) to fan out across machines. SSH access is the only auth; there's no daemon or shared store. Targets are validated against a strict allowlist (`SSH_TARGET_RE`) to block flag-smuggling, and the forwarded invocation is double-quoted (`shellQuote`) so a query like `$(whoami)` survives as a literal string on both shell layers. Source: `src/lib/session/remote.ts`, `src/commands/sessions.ts`, `docs/05-sessions.md`.
@@ -11,6 +11,7 @@ import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.j
11
11
  import { linkPath, relativeToCwd } from '../lib/session/render.js';
12
12
  import { renderMarkdown } from '../lib/markdown.js';
13
13
  import { itemPicker } from '../lib/picker.js';
14
+ import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
14
15
  /**
15
16
  * SessionMeta originates in discover.ts (gitBranch, cwd, label, etc. read from
16
17
  * untrusted session files). parseSession sanitizes event payloads at its
@@ -176,8 +177,8 @@ const TODOS_MAX_ITEMS = 5;
176
177
  function formatCompactPreview(events, session) {
177
178
  let firstUser = '';
178
179
  let lastAssistant = '';
179
- const filesModified = new Set();
180
180
  const filesRead = new Set();
181
+ const toolCounts = {};
181
182
  let toolCalls = 0;
182
183
  let planFile = '';
183
184
  let latestTodos = null;
@@ -195,10 +196,7 @@ function formatCompactPreview(events, session) {
195
196
  else if (event.type === 'tool_use' && !event._local) {
196
197
  const tool = event.tool || '';
197
198
  const p = event.path || event.args?.file_path || event.args?.path || '';
198
- if (['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,12 @@ import * as path from 'path';
16
16
  import chalk from 'chalk';
17
17
  import ora from 'ora';
18
18
  import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
19
- import { 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 { clearPendingSentinel } from '../lib/devices/pending.js';
24
+ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
21
25
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
22
26
  import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
23
27
  /** Parse `user@host` or `host` into pieces. */
@@ -27,8 +31,9 @@ function parseTarget(target) {
27
31
  return { host: target };
28
32
  return { user: target.slice(0, at), host: target.slice(at + 1) };
29
33
  }
30
- /** One-line summary of a device for `list`. */
31
- function deviceSummary(d) {
34
+ /** One-line summary of a device for `list`. `isSelf` marks the machine this
35
+ * command is running on so it stands out from the rest of the tailnet. */
36
+ function deviceSummary(d, isSelf = false) {
32
37
  const addr = hostNameFor(d) ?? chalk.gray('no address');
33
38
  const online = d.tailscale
34
39
  ? d.tailscale.online
@@ -36,7 +41,10 @@ function deviceSummary(d) {
36
41
  : chalk.gray('offline')
37
42
  : chalk.gray('unknown');
38
43
  const reach = d.tailscale?.online && !d.tailscale.direct ? chalk.yellow(' (relayed)') : '';
39
- return ` ${chalk.bold(d.name.padEnd(16))} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}`;
44
+ const marker = isSelf ? chalk.cyan('▸ ') : ' ';
45
+ const name = isSelf ? chalk.bold.cyan(d.name.padEnd(16)) : chalk.bold(d.name.padEnd(16));
46
+ const here = isSelf ? chalk.cyan(' ← this machine') : '';
47
+ return `${marker}${name} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}${here}`;
40
48
  }
41
49
  /** Resolve a device or exit with a clear error. */
42
50
  async function mustGetDevice(name) {
@@ -47,6 +55,72 @@ async function mustGetDevice(name) {
47
55
  }
48
56
  return d;
49
57
  }
58
+ /**
59
+ * Interactive `agents devices sync`: discover tailscale nodes, present a
60
+ * checkbox pre-checked with what's already registered, and reconcile the
61
+ * choice. Checked = registered (and un-ignored). Unchecked = removed from the
62
+ * registry AND added to the ignore-list, so auto-discovery never re-suggests
63
+ * it — this is the "click to register/unregister" surface, with dismissals that
64
+ * stick.
65
+ */
66
+ async function runInteractiveDeviceSync() {
67
+ const spinner = ora('Reading tailscale status...').start();
68
+ let nodes;
69
+ try {
70
+ nodes = parseTailscaleStatus(tailscaleStatusJson());
71
+ }
72
+ catch (err) {
73
+ spinner.fail(err.message);
74
+ process.exit(1);
75
+ }
76
+ const [reg, ignored] = await Promise.all([loadDevices(), loadIgnored()]);
77
+ const registered = new Set(Object.keys(reg));
78
+ spinner.stop();
79
+ if (nodes.length === 0) {
80
+ console.log(chalk.gray('No tailscale nodes found.'));
81
+ return;
82
+ }
83
+ const { checkbox } = await import('@inquirer/prompts');
84
+ let selected;
85
+ try {
86
+ selected = await checkbox({
87
+ // Everything not already dismissed starts checked, so pressing Enter keeps
88
+ // the fleet as-is (matching what auto-sync would register). Unchecking a
89
+ // device removes it AND dismisses it so auto-sync never re-adds it.
90
+ message: 'Your fleet — uncheck a device to remove and stop suggesting it:',
91
+ pageSize: Math.min(nodes.length, 20),
92
+ choices: nodes.map((n) => {
93
+ const flags = [n.platform, n.online ? undefined : 'offline', ignored.has(n.name) ? 'ignored' : undefined]
94
+ .filter(Boolean)
95
+ .join(', ');
96
+ return { value: n.name, name: `${n.name} ${chalk.gray(`(${flags})`)}`, checked: !ignored.has(n.name) };
97
+ }),
98
+ });
99
+ }
100
+ catch (err) {
101
+ if (isPromptCancelled(err)) {
102
+ console.log(chalk.gray('Cancelled — no changes.'));
103
+ return;
104
+ }
105
+ throw err;
106
+ }
107
+ const byName = new Map(nodes.map((n) => [n.name, n]));
108
+ const plan = planDeviceReconciliation(byName.keys(), selected, registered, ignored);
109
+ for (const name of plan.toRegister)
110
+ await upsertDevice(name, nodeToDeviceInput(byName.get(name)));
111
+ for (const name of plan.toUnignore)
112
+ await removeIgnored(name);
113
+ for (const name of plan.toRemove)
114
+ await removeDevice(name);
115
+ for (const name of plan.toIgnore)
116
+ await addIgnored(name);
117
+ const parts = [
118
+ chalk.green(`${plan.toRegister.length} registered`),
119
+ plan.toRemove.length ? chalk.yellow(`${plan.toRemove.length} removed`) : null,
120
+ plan.toIgnore.length ? chalk.gray(`${plan.toIgnore.length} ignored`) : null,
121
+ ].filter(Boolean);
122
+ console.log(parts.join(chalk.gray(' · ')));
123
+ }
50
124
  /** Register the `agents devices` command tree. */
51
125
  function registerDevicesCommands(program) {
52
126
  const devicesCmd = program
@@ -54,43 +128,100 @@ function registerDevicesCommands(program) {
54
128
  .description('Registry of SSH device profiles (platform, user, address, auth), self-populated from Tailscale.')
55
129
  .addHelpText('after', `
56
130
  Typical workflow:
57
- agents devices sync # ingest tailscale nodes (auto-detect platform)
131
+ agents devices sync # curate: pick which tailscale nodes to keep (TTY)
132
+ agents devices sync --yes # non-interactive: register all non-ignored nodes
58
133
  agents devices list # see what's registered
134
+ agents devices ignore ipad165 # dismiss a node so it's never re-suggested
59
135
  agents devices set win-mini --auth password --bundle muqsit
60
136
  agents devices render --write # write ~/.ssh/config.d/agents include
61
137
  `);
62
138
  devicesCmd
63
139
  .command('sync')
64
- .description('Ingest `tailscale status --json` and create/update device profiles (auto-detects platform, address, reachability).')
65
- .action(async () => {
140
+ .description('Ingest `tailscale status --json` into device profiles. In a terminal, opens a checkbox to register/unregister nodes; with --yes, registers every non-ignored node.')
141
+ .option('--yes', 'skip the picker; register all discovered non-ignored nodes')
142
+ .action(async (opts) => {
143
+ if (isInteractiveTerminal() && !opts.yes) {
144
+ await runInteractiveDeviceSync();
145
+ return;
146
+ }
66
147
  const spinner = ora('Reading tailscale status...').start();
148
+ try {
149
+ const res = await runDeviceSync();
150
+ const extra = res.pending.length ? chalk.gray(` (${res.pending.length} new)`) : '';
151
+ spinner.succeed(`Synced ${res.synced} device${res.synced === 1 ? '' : 's'} from Tailscale${extra}`);
152
+ }
153
+ catch (err) {
154
+ spinner.fail(err.message);
155
+ process.exit(1);
156
+ }
157
+ });
158
+ devicesCmd
159
+ .command('register <name>')
160
+ .description('Register a discovered (pending) node by name — used by the menu-bar "NEW DEVICES → Register" action.')
161
+ .action(async (name) => {
67
162
  try {
68
163
  const nodes = parseTailscaleStatus(tailscaleStatusJson());
69
- spinner.text = `Updating ${nodes.length} device${nodes.length === 1 ? '' : 's'}...`;
70
- for (const node of nodes) {
71
- await upsertDevice(node.name, nodeToDeviceInput(node));
164
+ const node = nodes.find((n) => n.name === name);
165
+ if (!node) {
166
+ console.error(chalk.red(`'${name}' is not a current tailscale node. See 'agents devices sync'.`));
167
+ process.exit(1);
72
168
  }
73
- spinner.succeed(`Synced ${nodes.length} device${nodes.length === 1 ? '' : 's'} from Tailscale`);
169
+ await removeIgnored(name); // a re-registered node is no longer dismissed
170
+ const d = await upsertDevice(name, nodeToDeviceInput(node));
171
+ clearPendingSentinel(name); // drop the notification immediately
172
+ console.log(chalk.green(`Registered '${name}'`) + chalk.gray(` (${d.platform})`));
74
173
  }
75
174
  catch (err) {
76
- spinner.fail(err.message);
175
+ console.error(chalk.red(err.message));
77
176
  process.exit(1);
78
177
  }
79
178
  });
179
+ devicesCmd
180
+ .command('ignore <name>')
181
+ .description('Dismiss a node from auto-discovery so it is never re-suggested (and remove it from the registry if present).')
182
+ .action(async (name) => {
183
+ try {
184
+ await removeDevice(name);
185
+ await addIgnored(name);
186
+ clearPendingSentinel(name); // drop the notification immediately
187
+ console.log(chalk.green(`Ignored '${name}'`) + chalk.gray(" — it won't be suggested again. Undo with `agents devices unignore`."));
188
+ }
189
+ catch (err) {
190
+ console.error(chalk.red(err.message));
191
+ process.exit(1);
192
+ }
193
+ });
194
+ devicesCmd
195
+ .command('unignore <name>')
196
+ .description('Undo `ignore`: allow a node to be discovered and registered again.')
197
+ .action(async (name) => {
198
+ const ok = await removeIgnored(name);
199
+ if (!ok) {
200
+ console.error(chalk.gray(`'${name}' was not ignored.`));
201
+ return;
202
+ }
203
+ console.log(chalk.green(`No longer ignoring '${name}'`) + chalk.gray(' — run `agents devices sync` to register it.'));
204
+ });
80
205
  devicesCmd
81
206
  .command('list')
82
207
  .alias('ls')
83
208
  .description('List registered devices with platform, address, and reachability.')
84
- .action(async () => {
209
+ .option('--json', 'output the registry as a JSON array (for scripts and hooks)')
210
+ .action(async (opts) => {
85
211
  const reg = await loadDevices();
86
212
  const names = Object.keys(reg).sort();
213
+ if (opts.json) {
214
+ process.stdout.write(JSON.stringify(names.map((n) => reg[n]), null, 2) + '\n');
215
+ return;
216
+ }
87
217
  if (names.length === 0) {
88
218
  console.log(chalk.gray("No devices. Run 'agents devices sync' or 'agents devices add <name> <user@host>'."));
89
219
  return;
90
220
  }
221
+ const self = machineId();
91
222
  console.log(chalk.bold(`Devices (${names.length})`));
92
223
  for (const name of names)
93
- console.log(deviceSummary(reg[name]));
224
+ console.log(deviceSummary(reg[name], name === self));
94
225
  });
95
226
  devicesCmd
96
227
  .command('show <name>')
@@ -5,7 +5,7 @@ import * as path from 'path';
5
5
  import { AGENTS, ALL_AGENT_IDS, getAllCliStates, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
6
6
  import { deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
7
7
  import { readManifest } from '../lib/manifest.js';
8
- import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, resolveVersionAlias, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, } from '../lib/versions.js';
8
+ import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, resolveVersionAlias, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, } from '../lib/versions.js';
9
9
  import { ensureVersionedAliasCurrent, removeShim, } from '../lib/shims.js';
10
10
  import { getAgentResources } from '../lib/resources.js';
11
11
  import { listCliStatus } from '../lib/cli-resources.js';
@@ -1262,6 +1262,16 @@ export async function viewAction(agentArg, options) {
1262
1262
  cli: options?.cli,
1263
1263
  };
1264
1264
  const filterIsSet = SECTION_KEYS.some((k) => filter[k]);
1265
+ // RUSH-1320: fold any stale literal `latest` version-home into its concrete
1266
+ // version before rendering, so it stops appearing as a bogus "version" next
1267
+ // to the real ones. Best-effort — must never break `agents view`. Scoped to
1268
+ // the queried agent when one is given (cheap no-op for agents with no
1269
+ // `latest` dir, i.e. almost all of them).
1270
+ {
1271
+ const target = agentArg ? resolveAgentName(agentArg.split('@')[0]) : null;
1272
+ const toReconcile = agentArg ? (target ? [target] : []) : ALL_AGENT_IDS;
1273
+ await Promise.all(toReconcile.map((a) => reconcileStaleLatestForAgent(a).catch(() => { })));
1274
+ }
1265
1275
  if (!agentArg) {
1266
1276
  if (prune) {
1267
1277
  await pruneDuplicates(undefined, yes, dryRun);
@@ -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
@@ -290,6 +290,36 @@ export async function runDaemon() {
290
290
  };
291
291
  const healInterval = setInterval(() => { void runHealCheck(); }, 6 * 60 * 60_000);
292
292
  const healKickoff = setTimeout(() => { void runHealCheck(); }, 30_000);
293
+ // Device probe: refresh registered devices' reachability and detect newly
294
+ // appeared tailnet nodes, dropping a sentinel per pending device so the
295
+ // menu-bar helper can surface "NEW DEVICES → Register / Ignore". Refresh mode
296
+ // never auto-registers a newcomer. Soft + overlap-guarded like session sync;
297
+ // a machine without tailscale is a clean no-op. ~every 3 min.
298
+ let probingDevices = false;
299
+ const runDeviceProbe = async () => {
300
+ if (probingDevices)
301
+ return;
302
+ probingDevices = true;
303
+ try {
304
+ const { runDeviceSync } = await import('./devices/sync.js');
305
+ const { reconcilePendingSentinels } = await import('./devices/pending.js');
306
+ const dev = await runDeviceSync({ soft: true, mode: 'refresh' });
307
+ if (dev.ok) {
308
+ reconcilePendingSentinels(dev.pending);
309
+ if (dev.pending.length) {
310
+ log('INFO', `devices: ${dev.pending.length} new pending (${dev.pending.map((p) => p.name).join(', ')})`);
311
+ }
312
+ }
313
+ }
314
+ catch (err) {
315
+ log('ERROR', `device probe failed: ${err.message}`);
316
+ }
317
+ finally {
318
+ probingDevices = false;
319
+ }
320
+ };
321
+ const deviceProbeInterval = setInterval(() => { void runDeviceProbe(); }, 3 * 60_000);
322
+ const deviceProbeKickoff = setTimeout(() => { void runDeviceProbe(); }, 15_000);
293
323
  const handleReload = () => {
294
324
  log('INFO', 'Reloading jobs (SIGHUP)');
295
325
  scheduler.reloadAll();
@@ -307,6 +337,8 @@ export async function runDaemon() {
307
337
  clearInterval(syncInterval);
308
338
  clearInterval(healInterval);
309
339
  clearTimeout(healKickoff);
340
+ clearInterval(deviceProbeInterval);
341
+ clearTimeout(deviceProbeKickoff);
310
342
  removeDaemonPid();
311
343
  process.exit(0);
312
344
  };
@@ -0,0 +1,18 @@
1
+ export interface PendingDevice {
2
+ name: string;
3
+ platform: string;
4
+ }
5
+ /**
6
+ * Make the sentinel dir exactly match `pending`: create a file per pending
7
+ * device (content = platform), and delete any leftover sentinel whose device is
8
+ * no longer pending (it got registered, ignored, or left the tailnet). Best-
9
+ * effort — a filesystem error here must never crash the daemon, so callers pass
10
+ * this through their existing try/catch.
11
+ */
12
+ export declare function reconcilePendingSentinels(pending: PendingDevice[]): void;
13
+ /** Remove one device's pending sentinel (after the user registers or ignores it).
14
+ * No-op if it doesn't exist. */
15
+ export declare function clearPendingSentinel(name: string): void;
16
+ /** Read the current pending sentinels (name + platform). Used by tests and any
17
+ * TS-side consumer; the menu-bar helper reads the dir directly in Swift. */
18
+ export declare function readPendingSentinels(): PendingDevice[];