@phnx-labs/agents-cli 1.20.31 → 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`.
@@ -20,6 +20,7 @@ import { machineId } from '../lib/session/sync/config.js';
20
20
  import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, } from '../lib/devices/registry.js';
21
21
  import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from '../lib/devices/tailscale.js';
22
22
  import { planDeviceReconciliation, runDeviceSync } from '../lib/devices/sync.js';
23
+ import { clearPendingSentinel } from '../lib/devices/pending.js';
23
24
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
24
25
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
25
26
  import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
@@ -154,6 +155,27 @@ Typical workflow:
154
155
  process.exit(1);
155
156
  }
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) => {
162
+ try {
163
+ const nodes = parseTailscaleStatus(tailscaleStatusJson());
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);
168
+ }
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})`));
173
+ }
174
+ catch (err) {
175
+ console.error(chalk.red(err.message));
176
+ process.exit(1);
177
+ }
178
+ });
157
179
  devicesCmd
158
180
  .command('ignore <name>')
159
181
  .description('Dismiss a node from auto-discovery so it is never re-suggested (and remove it from the registry if present).')
@@ -161,6 +183,7 @@ Typical workflow:
161
183
  try {
162
184
  await removeDevice(name);
163
185
  await addIgnored(name);
186
+ clearPendingSentinel(name); // drop the notification immediately
164
187
  console.log(chalk.green(`Ignored '${name}'`) + chalk.gray(" — it won't be suggested again. Undo with `agents devices unignore`."));
165
188
  }
166
189
  catch (err) {
@@ -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);
@@ -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[];
@@ -0,0 +1,103 @@
1
+ /**
2
+ * "Pending device" sentinels.
3
+ *
4
+ * When the daemon's tailscale probe finds a node that is neither registered nor
5
+ * ignored, it drops a sentinel file under ~/.agents/.cache/state/devices-pending/
6
+ * — the same filesystem-signal pattern the attention hook uses for the menu bar.
7
+ * The Swift helper polls that dir every 10s and renders a "NEW DEVICES" section
8
+ * with Register / Ignore. The file NAME is the device name; the file CONTENT is
9
+ * the platform (one line), so the tray can show "zion (macos)" without opening
10
+ * the registry.
11
+ *
12
+ * The daemon owns writes (reconcile to match the current pending set); the CLI
13
+ * `agents devices register|ignore` clears a single sentinel the moment the user
14
+ * acts, so the badge updates immediately instead of waiting for the next probe.
15
+ */
16
+ import * as fs from 'fs';
17
+ import * as path from 'path';
18
+ import { getDevicesPendingDir } from '../state.js';
19
+ /** Device-name sentinels must be safe filenames (no path traversal). The device
20
+ * name charset is already the ssh-alias set, but guard defensively. */
21
+ function isSafeName(name) {
22
+ return /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
23
+ }
24
+ /**
25
+ * Make the sentinel dir exactly match `pending`: create a file per pending
26
+ * device (content = platform), and delete any leftover sentinel whose device is
27
+ * no longer pending (it got registered, ignored, or left the tailnet). Best-
28
+ * effort — a filesystem error here must never crash the daemon, so callers pass
29
+ * this through their existing try/catch.
30
+ */
31
+ export function reconcilePendingSentinels(pending) {
32
+ const dir = getDevicesPendingDir();
33
+ const want = new Map(pending.filter((p) => isSafeName(p.name)).map((p) => [p.name, p.platform]));
34
+ // Whole body is best-effort: a filesystem error here must never propagate into
35
+ // the daemon loop or `agents sync`. The top-level mkdir/readdir are guarded
36
+ // too, so no caller needs its own try/catch.
37
+ let existing;
38
+ try {
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ existing = fs.readdirSync(dir).filter((n) => !n.startsWith('.'));
41
+ }
42
+ catch {
43
+ return;
44
+ }
45
+ // Remove sentinels that are no longer pending.
46
+ for (const name of existing) {
47
+ if (!want.has(name)) {
48
+ try {
49
+ fs.unlinkSync(path.join(dir, name));
50
+ }
51
+ catch { /* already gone */ }
52
+ }
53
+ }
54
+ // Write/refresh the sentinels that should exist.
55
+ for (const [name, platform] of want) {
56
+ const p = path.join(dir, name);
57
+ const body = `${platform}\n`;
58
+ // Only write when missing or changed, to avoid needless mtime churn.
59
+ let current = null;
60
+ try {
61
+ current = fs.readFileSync(p, 'utf-8');
62
+ }
63
+ catch {
64
+ current = null;
65
+ }
66
+ if (current !== body) {
67
+ try {
68
+ fs.writeFileSync(p, body);
69
+ }
70
+ catch { /* best-effort */ }
71
+ }
72
+ }
73
+ }
74
+ /** Remove one device's pending sentinel (after the user registers or ignores it).
75
+ * No-op if it doesn't exist. */
76
+ export function clearPendingSentinel(name) {
77
+ if (!isSafeName(name))
78
+ return;
79
+ try {
80
+ fs.unlinkSync(path.join(getDevicesPendingDir(), name));
81
+ }
82
+ catch { /* already gone */ }
83
+ }
84
+ /** Read the current pending sentinels (name + platform). Used by tests and any
85
+ * TS-side consumer; the menu-bar helper reads the dir directly in Swift. */
86
+ export function readPendingSentinels() {
87
+ const dir = getDevicesPendingDir();
88
+ let names;
89
+ try {
90
+ names = fs.readdirSync(dir).filter((n) => !n.startsWith('.'));
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ return names.map((name) => {
96
+ let platform = 'unknown';
97
+ try {
98
+ platform = fs.readFileSync(path.join(dir, name), 'utf-8').trim() || 'unknown';
99
+ }
100
+ catch { /* keep default */ }
101
+ return { name, platform };
102
+ });
103
+ }
@@ -1,11 +1,22 @@
1
1
  import { type TailscaleNode } from './tailscale.js';
2
+ import type { PendingDevice } from './pending.js';
3
+ /**
4
+ * bootstrap — register every non-ignored node (opt-out). First-run `agents
5
+ * setup` and manual `agents devices sync`, so the fleet is usable out of box.
6
+ * refresh — only refresh reachability of already-registered nodes; a brand-new
7
+ * node is NOT auto-added, it is surfaced as `pending` for the user to approve
8
+ * (opt-in). Ongoing autosync and the daemon probe use this, so newcomers flow
9
+ * through the menu-bar "NEW DEVICES → Register / Ignore" gate instead of
10
+ * silently landing in the registry.
11
+ */
12
+ export type DeviceSyncMode = 'bootstrap' | 'refresh';
2
13
  export interface DeviceSyncResult {
3
14
  /** False when discovery could not run (e.g. tailscale absent) in soft mode. */
4
15
  ok: boolean;
5
16
  /** Number of tailscale nodes upserted into the registry. */
6
17
  synced: number;
7
- /** Node names discovered but neither registered-before nor ignored. */
8
- pending: string[];
18
+ /** Nodes discovered but neither registered-before nor ignored (name+platform). */
19
+ pending: PendingDevice[];
9
20
  /** Populated when ok is false: why discovery was skipped. */
10
21
  reason?: string;
11
22
  }
@@ -15,6 +26,13 @@ export interface DeviceSyncResult {
15
26
  * flag matrix is unit-testable without a live tailnet.
16
27
  */
17
28
  export declare function computePendingDevices(nodes: TailscaleNode[], registered: Iterable<string>, ignored: Iterable<string>): string[];
29
+ /**
30
+ * Which discovered nodes to upsert this run — the mode-defining decision, pure
31
+ * so it is unit-testable without a tailnet. Ignored nodes are always skipped.
32
+ * In `refresh` mode a node that isn't already registered is skipped too (it
33
+ * stays pending for approval); `bootstrap` includes every non-ignored node.
34
+ */
35
+ export declare function selectNodesToUpsert(nodes: TailscaleNode[], registered: Set<string>, ignored: Set<string>, mode: DeviceSyncMode): TailscaleNode[];
18
36
  /**
19
37
  * Ingest `tailscale status --json` into the registry. In soft mode a missing
20
38
  * tailscale binary / unreachable daemon resolves to `{ ok: false }` instead of
@@ -24,6 +42,7 @@ export declare function computePendingDevices(nodes: TailscaleNode[], registered
24
42
  */
25
43
  export declare function runDeviceSync(opts?: {
26
44
  soft?: boolean;
45
+ mode?: DeviceSyncMode;
27
46
  }): Promise<DeviceSyncResult>;
28
47
  /**
29
48
  * The register/remove/ignore decision for the interactive curation picker.
@@ -28,6 +28,21 @@ export function computePendingDevices(nodes, registered, ignored) {
28
28
  .map((n) => n.name)
29
29
  .filter((name) => !known.has(name) && !skip.has(name));
30
30
  }
31
+ /**
32
+ * Which discovered nodes to upsert this run — the mode-defining decision, pure
33
+ * so it is unit-testable without a tailnet. Ignored nodes are always skipped.
34
+ * In `refresh` mode a node that isn't already registered is skipped too (it
35
+ * stays pending for approval); `bootstrap` includes every non-ignored node.
36
+ */
37
+ export function selectNodesToUpsert(nodes, registered, ignored, mode) {
38
+ return nodes.filter((n) => {
39
+ if (ignored.has(n.name))
40
+ return false;
41
+ if (mode === 'refresh' && !registered.has(n.name))
42
+ return false;
43
+ return true;
44
+ });
45
+ }
31
46
  /**
32
47
  * Ingest `tailscale status --json` into the registry. In soft mode a missing
33
48
  * tailscale binary / unreachable daemon resolves to `{ ok: false }` instead of
@@ -36,6 +51,7 @@ export function computePendingDevices(nodes, registered, ignored) {
36
51
  * "new" means "not previously registered and not ignored".
37
52
  */
38
53
  export async function runDeviceSync(opts = {}) {
54
+ const mode = opts.mode ?? 'bootstrap';
39
55
  // Soft mode must be non-fatal for ANY failure, not just a missing tailscale:
40
56
  // a corrupted registry/ignore file (both throw by design), a disk error, or
41
57
  // registry lock contention (plausible when many agents SessionStart-autosync
@@ -44,18 +60,18 @@ export async function runDeviceSync(opts = {}) {
44
60
  try {
45
61
  const nodes = parseTailscaleStatus(tailscaleStatusJson());
46
62
  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;
63
+ const registered = new Set(Object.keys(registeredBefore));
64
+ const pendingNames = computePendingDevices(nodes, registered, ignored);
65
+ const byName = new Map(nodes.map((n) => [n.name, n]));
66
+ const pending = pendingNames.map((name) => ({
67
+ name,
68
+ platform: byName.get(name)?.platform ?? 'unknown',
69
+ }));
70
+ const toUpsert = selectNodesToUpsert(nodes, registered, ignored, mode);
71
+ for (const node of toUpsert) {
55
72
  await upsertDevice(node.name, nodeToDeviceInput(node));
56
- synced++;
57
73
  }
58
- return { ok: true, synced, pending };
74
+ return { ok: true, synced: toUpsert.length, pending };
59
75
  }
60
76
  catch (err) {
61
77
  if (opts.soft) {
@@ -158,6 +158,8 @@ export declare function getTeamsRegistryPath(): string;
158
158
  export declare function getDevicesRegistryPath(): string;
159
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
160
  export declare function getDevicesIgnoredPath(): string;
161
+ /** Dir of "pending device" sentinels (~/.agents/.cache/state/devices-pending/) — one empty-ish file per newly-discovered, not-yet-approved tailnet node. Written by the daemon probe, read by the menu-bar helper (mirrors the attention sentinel dir). */
162
+ export declare function getDevicesPendingDir(): string;
161
163
  /** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
162
164
  export declare function getCloudDir(): string;
163
165
  /** Path to terminal session metadata (~/.agents/.cache/terminals/). */
package/dist/lib/state.js CHANGED
@@ -354,6 +354,8 @@ export function getTeamsRegistryPath() { return path.join(HISTORY_DIR, 'teams',
354
354
  export function getDevicesRegistryPath() { return path.join(HISTORY_DIR, 'devices', 'registry.json'); }
355
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
356
  export function getDevicesIgnoredPath() { return path.join(HISTORY_DIR, 'devices', 'ignored.json'); }
357
+ /** Dir of "pending device" sentinels (~/.agents/.cache/state/devices-pending/) — one empty-ish file per newly-discovered, not-yet-approved tailnet node. Written by the daemon probe, read by the menu-bar helper (mirrors the attention sentinel dir). */
358
+ export function getDevicesPendingDir() { return path.join(RUNTIME_STATE_DIR, 'devices-pending'); }
357
359
  /** Path to cloud dispatch cache (~/.agents/.cache/cloud/). */
358
360
  export function getCloudDir() { return CLOUD_DIR; }
359
361
  /** Path to terminal session metadata (~/.agents/.cache/terminals/). */
@@ -120,15 +120,19 @@ 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.
123
+ // Keep already-registered devices' reachability current, and surface newly
124
+ // appeared tailnet nodes as "pending" for the menu-bar Register/Ignore gate
125
+ // rather than silently adding them (refresh mode). Soft: a machine without
126
+ // tailscale is a clean no-op, never a sync failure. First-run population is
127
+ // `agents setup` / manual `agents devices sync` (bootstrap).
127
128
  const { runDeviceSync } = await import('./devices/sync.js');
128
- const dev = await runDeviceSync({ soft: true });
129
+ const { reconcilePendingSentinels } = await import('./devices/pending.js');
130
+ const dev = await runDeviceSync({ soft: true, mode: 'refresh' });
131
+ if (dev.ok)
132
+ reconcilePendingSentinels(dev.pending);
129
133
  result.devices = { synced: dev.synced, pending: dev.pending.length, skipped: !dev.ok };
130
134
  if (dev.ok) {
131
- log(`devices: ${dev.synced} synced${dev.pending.length ? `, ${dev.pending.length} new` : ''}`);
135
+ log(`devices: ${dev.synced} refreshed${dev.pending.length ? `, ${dev.pending.length} new pending` : ''}`);
132
136
  }
133
137
  }
134
138
  return result;
@@ -197,6 +197,17 @@ export declare function installVersion(agent: AgentId, version: string, onProgre
197
197
  * when nothing was resolved or no stale dir is present, so it is safe to call
198
198
  * on every script-based install. Returns the action taken (for tests/logging).
199
199
  */
200
+ /**
201
+ * Proactively fold a stale `latest` version-home into its concrete version,
202
+ * WITHOUT needing a fresh install (RUSH-1320). `reconcileStaleLatestDir` only
203
+ * fires at install time, so a `latest` dir left by an old probe-failed install
204
+ * lingers in `agents view` indefinitely. This resolves the live CLI version and
205
+ * reconciles — cheap no-op when there's no `latest` dir (the common case, so
206
+ * `agents view` pays a `--version` shell-out only the once, until it's folded).
207
+ * Skipped when the active config symlink still points at `latest`, since
208
+ * renaming that dir would dangle the live symlink.
209
+ */
210
+ export declare function reconcileStaleLatestForAgent(agent: AgentId): Promise<void>;
200
211
  export declare function reconcileStaleLatestDir(agent: AgentId, installedVersion: string): Promise<'none' | 'renamed' | 'trashed'>;
201
212
  /**
202
213
  * Soft-delete a version directory by moving it to ~/.agents/.system/trash/versions/.
@@ -1209,6 +1209,26 @@ function removeInstallArtifacts(versionDir) {
1209
1209
  * when nothing was resolved or no stale dir is present, so it is safe to call
1210
1210
  * on every script-based install. Returns the action taken (for tests/logging).
1211
1211
  */
1212
+ /**
1213
+ * Proactively fold a stale `latest` version-home into its concrete version,
1214
+ * WITHOUT needing a fresh install (RUSH-1320). `reconcileStaleLatestDir` only
1215
+ * fires at install time, so a `latest` dir left by an old probe-failed install
1216
+ * lingers in `agents view` indefinitely. This resolves the live CLI version and
1217
+ * reconciles — cheap no-op when there's no `latest` dir (the common case, so
1218
+ * `agents view` pays a `--version` shell-out only the once, until it's folded).
1219
+ * Skipped when the active config symlink still points at `latest`, since
1220
+ * renaming that dir would dangle the live symlink.
1221
+ */
1222
+ export async function reconcileStaleLatestForAgent(agent) {
1223
+ if (!fs.existsSync(getVersionDir(agent, 'latest')))
1224
+ return;
1225
+ if (getConfigSymlinkVersion(agent) === 'latest')
1226
+ return;
1227
+ const concrete = await getCliVersionFromPath(agent);
1228
+ if (concrete && concrete !== 'latest') {
1229
+ await reconcileStaleLatestDir(agent, concrete);
1230
+ }
1231
+ }
1212
1232
  export async function reconcileStaleLatestDir(agent, installedVersion) {
1213
1233
  if (installedVersion === 'latest')
1214
1234
  return 'none';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.31",
3
+ "version": "1.20.32",
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",