@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 +15 -0
- package/dist/commands/ssh.js +23 -0
- package/dist/commands/view.js +11 -1
- package/dist/lib/daemon.js +32 -0
- package/dist/lib/devices/pending.d.ts +18 -0
- package/dist/lib/devices/pending.js +103 -0
- package/dist/lib/devices/sync.d.ts +21 -2
- package/dist/lib/devices/sync.js +26 -10
- package/dist/lib/state.d.ts +2 -0
- package/dist/lib/state.js +2 -0
- package/dist/lib/sync-umbrella.js +10 -6
- package/dist/lib/versions.d.ts +11 -0
- package/dist/lib/versions.js +20 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 1.20.31
|
|
6
|
+
|
|
7
|
+
**`agents sessions <id>`: a catch-up digest for switching between many agents (#502)**
|
|
8
|
+
|
|
9
|
+
- Opening a single session now leads with its auto-inferred title (user `/rename` > Claude `ai-title` > first-prompt topic) and PR / worktree / ticket badges, then a **Changes** section that groups touched files by directory and tags each as created / modified / deleted (with a `+N ~N -N` summary) instead of the old flat "Modified" list, a **Tools** histogram (per-tool call counts), and a **Tests** verdict parsed from the last `vitest` / `jest` / `pytest` / `go test` / `cargo test` / `tsc` run. The same signals are folded into the interactive picker preview.
|
|
10
|
+
- `agents sessions --active` now collapses the many subagent/fork PIDs of one session into a single row with a `×N` count instead of printing dozens of identical lines. Source: `src/lib/session/digest.ts`, `src/lib/session/render.ts`, `src/lib/session/active.ts`, `src/commands/sessions.ts`, `src/commands/sessions-picker.ts`.
|
|
11
|
+
|
|
12
|
+
## 1.20.30
|
|
13
|
+
|
|
14
|
+
**`agents sessions` live state engine: waiting / PR / worktree / ticket detection + reliable preview (#494)**
|
|
15
|
+
|
|
16
|
+
- `agents sessions --active` infers real activity from each transcript's tail — **working** / **waiting** / **idle** — rather than the old mtime-only running/idle guess, using structural signals (Claude `ExitPlanMode` / `AskUserQuestion`) plus a question + mtime heuristic for Codex. It detects and badges a PR opened during the session (`gh pr create` + the resulting pull URL), a git worktree (`.agents/worktrees/<slug>/`), and a Linear/Jira ticket (from the prompt or branch), and shows the latest turn as the preview instead of the first prompt.
|
|
17
|
+
- `--waiting` filters `--active` to only sessions blocked on your input and exits non-zero (a scriptable gate); `--tree` groups the listing by directory, dropping the id/version columns while keeping the short-id handle.
|
|
18
|
+
- The preview line is now width-correct: measurement is ANSI- and wide-char-aware and reads `$COLUMNS` first, so it no longer wraps or drifts under tmux or over `--host` SSH (the remote is handed the caller's width). Session index schema v7 persists the PR / worktree / ticket signals so historical listings carry them too. Source: `src/lib/session/state.ts`, `src/lib/session/tail.ts`, `src/lib/session/width.ts`, `src/lib/session/{discover,db,active}.ts`, `src/commands/sessions.ts`.
|
|
19
|
+
|
|
5
20
|
**`agents sessions --host <machine>`: query a remote machine's sessions live over SSH**
|
|
6
21
|
|
|
7
22
|
- `agents sessions "<query>" --host <alias|user@host>` runs the same session query on a remote machine's own index over SSH and streams the result back — repeat `--host` (or pass several) to fan out across machines. SSH access is the only auth; there's no daemon or shared store. Targets are validated against a strict allowlist (`SSH_TARGET_RE`) to block flag-smuggling, and the forwarded invocation is double-quoted (`shellQuote`) so a query like `$(whoami)` survives as a literal string on both shell layers. Source: `src/lib/session/remote.ts`, `src/commands/sessions.ts`, `docs/05-sessions.md`.
|
package/dist/commands/ssh.js
CHANGED
|
@@ -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) {
|
package/dist/commands/view.js
CHANGED
|
@@ -5,7 +5,7 @@ import * as path from 'path';
|
|
|
5
5
|
import { AGENTS, ALL_AGENT_IDS, getAllCliStates, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
|
|
6
6
|
import { deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
|
|
7
7
|
import { readManifest } from '../lib/manifest.js';
|
|
8
|
-
import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, resolveVersionAlias, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, } from '../lib/versions.js';
|
|
8
|
+
import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, resolveVersionAlias, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, } from '../lib/versions.js';
|
|
9
9
|
import { ensureVersionedAliasCurrent, removeShim, } from '../lib/shims.js';
|
|
10
10
|
import { getAgentResources } from '../lib/resources.js';
|
|
11
11
|
import { listCliStatus } from '../lib/cli-resources.js';
|
|
@@ -1262,6 +1262,16 @@ export async function viewAction(agentArg, options) {
|
|
|
1262
1262
|
cli: options?.cli,
|
|
1263
1263
|
};
|
|
1264
1264
|
const filterIsSet = SECTION_KEYS.some((k) => filter[k]);
|
|
1265
|
+
// RUSH-1320: fold any stale literal `latest` version-home into its concrete
|
|
1266
|
+
// version before rendering, so it stops appearing as a bogus "version" next
|
|
1267
|
+
// to the real ones. Best-effort — must never break `agents view`. Scoped to
|
|
1268
|
+
// the queried agent when one is given (cheap no-op for agents with no
|
|
1269
|
+
// `latest` dir, i.e. almost all of them).
|
|
1270
|
+
{
|
|
1271
|
+
const target = agentArg ? resolveAgentName(agentArg.split('@')[0]) : null;
|
|
1272
|
+
const toReconcile = agentArg ? (target ? [target] : []) : ALL_AGENT_IDS;
|
|
1273
|
+
await Promise.all(toReconcile.map((a) => reconcileStaleLatestForAgent(a).catch(() => { })));
|
|
1274
|
+
}
|
|
1265
1275
|
if (!agentArg) {
|
|
1266
1276
|
if (prune) {
|
|
1267
1277
|
await pruneDuplicates(undefined, yes, dryRun);
|
package/dist/lib/daemon.js
CHANGED
|
@@ -290,6 +290,36 @@ export async function runDaemon() {
|
|
|
290
290
|
};
|
|
291
291
|
const healInterval = setInterval(() => { void runHealCheck(); }, 6 * 60 * 60_000);
|
|
292
292
|
const healKickoff = setTimeout(() => { void runHealCheck(); }, 30_000);
|
|
293
|
+
// Device probe: refresh registered devices' reachability and detect newly
|
|
294
|
+
// appeared tailnet nodes, dropping a sentinel per pending device so the
|
|
295
|
+
// menu-bar helper can surface "NEW DEVICES → Register / Ignore". Refresh mode
|
|
296
|
+
// never auto-registers a newcomer. Soft + overlap-guarded like session sync;
|
|
297
|
+
// a machine without tailscale is a clean no-op. ~every 3 min.
|
|
298
|
+
let probingDevices = false;
|
|
299
|
+
const runDeviceProbe = async () => {
|
|
300
|
+
if (probingDevices)
|
|
301
|
+
return;
|
|
302
|
+
probingDevices = true;
|
|
303
|
+
try {
|
|
304
|
+
const { runDeviceSync } = await import('./devices/sync.js');
|
|
305
|
+
const { reconcilePendingSentinels } = await import('./devices/pending.js');
|
|
306
|
+
const dev = await runDeviceSync({ soft: true, mode: 'refresh' });
|
|
307
|
+
if (dev.ok) {
|
|
308
|
+
reconcilePendingSentinels(dev.pending);
|
|
309
|
+
if (dev.pending.length) {
|
|
310
|
+
log('INFO', `devices: ${dev.pending.length} new pending (${dev.pending.map((p) => p.name).join(', ')})`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
catch (err) {
|
|
315
|
+
log('ERROR', `device probe failed: ${err.message}`);
|
|
316
|
+
}
|
|
317
|
+
finally {
|
|
318
|
+
probingDevices = false;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
const deviceProbeInterval = setInterval(() => { void runDeviceProbe(); }, 3 * 60_000);
|
|
322
|
+
const deviceProbeKickoff = setTimeout(() => { void runDeviceProbe(); }, 15_000);
|
|
293
323
|
const handleReload = () => {
|
|
294
324
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
295
325
|
scheduler.reloadAll();
|
|
@@ -307,6 +337,8 @@ export async function runDaemon() {
|
|
|
307
337
|
clearInterval(syncInterval);
|
|
308
338
|
clearInterval(healInterval);
|
|
309
339
|
clearTimeout(healKickoff);
|
|
340
|
+
clearInterval(deviceProbeInterval);
|
|
341
|
+
clearTimeout(deviceProbeKickoff);
|
|
310
342
|
removeDaemonPid();
|
|
311
343
|
process.exit(0);
|
|
312
344
|
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface PendingDevice {
|
|
2
|
+
name: string;
|
|
3
|
+
platform: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Make the sentinel dir exactly match `pending`: create a file per pending
|
|
7
|
+
* device (content = platform), and delete any leftover sentinel whose device is
|
|
8
|
+
* no longer pending (it got registered, ignored, or left the tailnet). Best-
|
|
9
|
+
* effort — a filesystem error here must never crash the daemon, so callers pass
|
|
10
|
+
* this through their existing try/catch.
|
|
11
|
+
*/
|
|
12
|
+
export declare function reconcilePendingSentinels(pending: PendingDevice[]): void;
|
|
13
|
+
/** Remove one device's pending sentinel (after the user registers or ignores it).
|
|
14
|
+
* No-op if it doesn't exist. */
|
|
15
|
+
export declare function clearPendingSentinel(name: string): void;
|
|
16
|
+
/** Read the current pending sentinels (name + platform). Used by tests and any
|
|
17
|
+
* TS-side consumer; the menu-bar helper reads the dir directly in Swift. */
|
|
18
|
+
export declare function readPendingSentinels(): PendingDevice[];
|
|
@@ -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
|
-
/**
|
|
8
|
-
pending:
|
|
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.
|
package/dist/lib/devices/sync.js
CHANGED
|
@@ -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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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) {
|
package/dist/lib/state.d.ts
CHANGED
|
@@ -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
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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
|
|
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}
|
|
135
|
+
log(`devices: ${dev.synced} refreshed${dev.pending.length ? `, ${dev.pending.length} new pending` : ''}`);
|
|
132
136
|
}
|
|
133
137
|
}
|
|
134
138
|
return result;
|
package/dist/lib/versions.d.ts
CHANGED
|
@@ -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/.
|
package/dist/lib/versions.js
CHANGED
|
@@ -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.
|
|
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",
|