@phnx-labs/agents-cli 1.20.77 → 1.20.78
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 +102 -0
- package/README.md +2 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +143 -9
- package/dist/commands/exec.js +29 -3
- package/dist/commands/repo.js +8 -0
- package/dist/commands/routines.js +4 -2
- package/dist/commands/secrets.js +8 -2
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +118 -12
- package/dist/commands/sessions-export.d.ts +3 -0
- package/dist/commands/sessions-export.js +13 -10
- package/dist/commands/sessions-picker.d.ts +15 -1
- package/dist/commands/sessions-picker.js +58 -4
- package/dist/commands/sessions.d.ts +95 -1
- package/dist/commands/sessions.js +224 -26
- package/dist/commands/ssh.js +9 -1
- package/dist/index.js +3 -5
- package/dist/lib/agents.d.ts +0 -21
- package/dist/lib/agents.js +0 -37
- package/dist/lib/auto-pull.d.ts +16 -8
- package/dist/lib/auto-pull.js +23 -28
- package/dist/lib/daemon.d.ts +9 -41
- package/dist/lib/daemon.js +16 -117
- package/dist/lib/devices/fleet-divergence.d.ts +101 -0
- package/dist/lib/devices/fleet-divergence.js +188 -0
- package/dist/lib/devices/fleet-inventory.d.ts +19 -0
- package/dist/lib/devices/fleet-inventory.js +57 -0
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/git.d.ts +13 -0
- package/dist/lib/git.js +102 -4
- package/dist/lib/hosts/remote-cmd.js +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/runner.js +29 -26
- package/dist/lib/sandbox.js +0 -16
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/agent.d.ts +2 -0
- package/dist/lib/secrets/agent.js +26 -14
- package/dist/lib/secrets/bundles.d.ts +1 -1
- package/dist/lib/secrets/bundles.js +16 -8
- package/dist/lib/secrets/scope.d.ts +26 -0
- package/dist/lib/secrets/scope.js +29 -0
- package/dist/lib/secrets/session-store.d.ts +10 -0
- package/dist/lib/secrets/session-store.js +64 -11
- package/dist/lib/session/active.d.ts +7 -0
- package/dist/lib/session/active.js +38 -3
- package/dist/lib/session/db.d.ts +37 -0
- package/dist/lib/session/db.js +112 -4
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +15 -2
- package/dist/lib/session/discover.js +19 -11
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/state.d.ts +1 -0
- package/dist/lib/session/state.js +1 -1
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/package.json +1 -1
- package/dist/lib/secrets/account-token.d.ts +0 -20
- package/dist/lib/secrets/account-token.js +0 -64
|
@@ -26,7 +26,7 @@ import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.
|
|
|
26
26
|
import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
|
|
27
27
|
import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
|
|
28
28
|
import { inferSessionState } from '../lib/session/state.js';
|
|
29
|
-
import { discoverSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
|
|
29
|
+
import { discoverSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
|
|
30
30
|
import { findSessionsById } from '../lib/session/db.js';
|
|
31
31
|
import { filterTeamSessions } from '../lib/session/team-filter.js';
|
|
32
32
|
import { parseSession } from '../lib/session/parse.js';
|
|
@@ -813,15 +813,18 @@ export function remoteHostsToDial(hosts, self) {
|
|
|
813
813
|
return hosts.filter(h => hostToken(h) !== self);
|
|
814
814
|
}
|
|
815
815
|
/**
|
|
816
|
-
*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
816
|
+
* The fleet-wide live-session set behind every `--active` surface. Local sessions
|
|
817
|
+
* come from `getActiveSessions()` and (unless `--local`) the registered online
|
|
818
|
+
* devices from `ag devices` are folded in over SSH. An explicit `--host`/`--device`
|
|
819
|
+
* list SCOPES the sweep to exactly those machines — the local machine is included
|
|
820
|
+
* only when it is itself named — so `--host` is a filter, not an addition.
|
|
821
|
+
*
|
|
822
|
+
* This is the single gather: the static renderer AND the interactive browser both
|
|
823
|
+
* call it, so the browser can never disagree with `--active --json` about which
|
|
824
|
+
* sessions are live (it used to call the local-only `getActiveSessions()` directly
|
|
825
|
+
* and silently hid every remote session).
|
|
823
826
|
*/
|
|
824
|
-
async function
|
|
827
|
+
export async function gatherActiveSessions(opts = {}) {
|
|
825
828
|
const self = machineId();
|
|
826
829
|
// An explicit --host/--device list scopes the view: seed local sessions only
|
|
827
830
|
// when no hosts are named, or when this machine is one of the named targets.
|
|
@@ -841,6 +844,17 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
|
841
844
|
merged = dedupeByMachineSession([...local, ...remote.sessions]);
|
|
842
845
|
}
|
|
843
846
|
}
|
|
847
|
+
return { sessions: merged, remoteDeviceCount };
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Render the unified active-session view, grouped by machine. Scoping and the
|
|
851
|
+
* fleet sweep live in {@link gatherActiveSessions}; this owns the presentation
|
|
852
|
+
* (the `--waiting` gate, JSON, and the grouped table). A tip is shown when there
|
|
853
|
+
* are no other machines to include.
|
|
854
|
+
*/
|
|
855
|
+
async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
|
|
856
|
+
const self = machineId();
|
|
857
|
+
const { sessions: merged, remoteDeviceCount } = await gatherActiveSessions(opts);
|
|
844
858
|
// --waiting: only sessions blocked on the user. Exits non-zero when any are
|
|
845
859
|
// present so a supervising agent or hook can poll it as a gate.
|
|
846
860
|
const sessions = waitingOnly ? merged.filter(s => s.status === 'input_required') : merged;
|
|
@@ -1023,6 +1037,7 @@ async function sessionsAction(query, options) {
|
|
|
1023
1037
|
agent: options.agent,
|
|
1024
1038
|
host: options.host,
|
|
1025
1039
|
since: options.since,
|
|
1040
|
+
all: options.all,
|
|
1026
1041
|
}), { local: options.local === true, hosts: options.host });
|
|
1027
1042
|
return;
|
|
1028
1043
|
}
|
|
@@ -1104,7 +1119,7 @@ async function sessionsAction(query, options) {
|
|
|
1104
1119
|
// When the user explicitly asks to render (via mode flag), resolve the
|
|
1105
1120
|
// query globally so sessions outside the default cwd/30d window are found.
|
|
1106
1121
|
if (wantsRender && searchQuery) {
|
|
1107
|
-
await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
|
|
1122
|
+
await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
|
|
1108
1123
|
return;
|
|
1109
1124
|
}
|
|
1110
1125
|
// Interactive picker loads a deep pool but shows only recent sessions
|
|
@@ -1171,19 +1186,34 @@ async function sessionsAction(query, options) {
|
|
|
1171
1186
|
// Smart ID routing: a bare query that resolves to one session renders
|
|
1172
1187
|
// directly. If nothing matches in the scoped window and the query looks
|
|
1173
1188
|
// like a session ID, widen to global scope (incl. Claude /resume history).
|
|
1174
|
-
|
|
1189
|
+
//
|
|
1190
|
+
// Exception: a PEER answering a parent's `--json` locate sweep
|
|
1191
|
+
// (AGENTS_SESSIONS_LOCAL=1) must return the SessionMeta[] ROW for the id, not
|
|
1192
|
+
// render its transcript — the sweep parses an array (parseRemoteList). So when
|
|
1193
|
+
// we are that peer and --json is set, skip the single-session short-circuit and
|
|
1194
|
+
// fall through to the array-emitting --json block below.
|
|
1195
|
+
const answeringJsonSweep = options.json === true && process.env[NO_FANOUT_ENV] === '1';
|
|
1196
|
+
if (searchQuery && !answeringJsonSweep) {
|
|
1175
1197
|
const idMatches = resolveSessionById(sessions, searchQuery);
|
|
1176
1198
|
if (idMatches.length === 1) {
|
|
1177
1199
|
await renderSession(idMatches[0], mode, filterOpts, options);
|
|
1178
1200
|
return;
|
|
1179
1201
|
}
|
|
1180
1202
|
if (idMatches.length === 0 && looksLikeSessionId(searchQuery)) {
|
|
1181
|
-
await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
|
|
1203
|
+
await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact, local: options.local, hosts: options.host });
|
|
1182
1204
|
return;
|
|
1183
1205
|
}
|
|
1184
1206
|
}
|
|
1185
1207
|
if (options.json) {
|
|
1186
|
-
|
|
1208
|
+
// An id-shaped query resolves by id ONLY — the same rule the render path
|
|
1209
|
+
// uses (resolveSessionQuery). Without this a `--json <uuid>` (as issued by
|
|
1210
|
+
// the fleet locate sweep, which runs `sessions <uuid> --json --local` on
|
|
1211
|
+
// each peer) would fall to FTS content search and return every transcript
|
|
1212
|
+
// that merely MENTIONS the id, defeating exact remote resolution. A genuine
|
|
1213
|
+
// search phrase keeps the ranked metadata+content path.
|
|
1214
|
+
const filtered = searchQuery
|
|
1215
|
+
? resolveSessionQuery(sessions, searchQuery).matches
|
|
1216
|
+
: sessions;
|
|
1187
1217
|
process.stdout.write(serializeSessionsJson(filtered));
|
|
1188
1218
|
return;
|
|
1189
1219
|
}
|
|
@@ -1271,15 +1301,6 @@ async function sessionsAction(query, options) {
|
|
|
1271
1301
|
process.exit(1);
|
|
1272
1302
|
}
|
|
1273
1303
|
}
|
|
1274
|
-
/** Whether a query should route to the single-session render rather than the
|
|
1275
|
-
* listing. The hex-ish test catches a bare id prefix; `isCompleteSessionId`
|
|
1276
|
-
* additionally catches the prefixed whole ids (`session_…`, `ses_…`) that the
|
|
1277
|
-
* hex test rejects — without it those never reach the id-only resolution and
|
|
1278
|
-
* still content-search. */
|
|
1279
|
-
function looksLikeSessionId(query) {
|
|
1280
|
-
const trimmed = query.trim();
|
|
1281
|
-
return /^[0-9a-f-]{6,}$/i.test(trimmed) || isCompleteSessionId(trimmed);
|
|
1282
|
-
}
|
|
1283
1304
|
function teamTag(session) {
|
|
1284
1305
|
const origin = session.teamOrigin;
|
|
1285
1306
|
if (!origin)
|
|
@@ -1725,7 +1746,23 @@ export function pickerColumnsFor(sessions) {
|
|
|
1725
1746
|
showTicket: sessions.some((s) => ticketLabel(s) !== ''),
|
|
1726
1747
|
};
|
|
1727
1748
|
}
|
|
1728
|
-
|
|
1749
|
+
/** Width of the host-program column (`tmux→ghostty` is the long realistic case). */
|
|
1750
|
+
const PICKER_HOST_W = 14;
|
|
1751
|
+
/**
|
|
1752
|
+
* Which program a live session is running in, for the picker's host column: the
|
|
1753
|
+
* immediate host app (`codium`, `ghostty`, `tmux`, `iterm`, …) and — when a tmux
|
|
1754
|
+
* session is currently being watched through a different app — the app it is
|
|
1755
|
+
* viewed in, as `tmux→ghostty`. A tmux session with no attached client stays a
|
|
1756
|
+
* bare `tmux`, which is exactly "running detached". Empty when the session has no
|
|
1757
|
+
* resolvable host (cloud rows, an unreadable process env).
|
|
1758
|
+
*/
|
|
1759
|
+
export function liveHostLabel(a) {
|
|
1760
|
+
if (!a?.host)
|
|
1761
|
+
return '';
|
|
1762
|
+
const viewer = a.viewingIn?.app;
|
|
1763
|
+
return viewer && viewer !== a.host ? `${a.host}→${viewer}` : a.host;
|
|
1764
|
+
}
|
|
1765
|
+
export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
|
|
1729
1766
|
const agentColor = colorAgent(s.agent);
|
|
1730
1767
|
const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
|
|
1731
1768
|
const project = s.project || '-';
|
|
@@ -1750,18 +1787,29 @@ export function formatPickerLabel(s, query, cols = {}, ssh) {
|
|
|
1750
1787
|
const ticketCell = cols.showTicket
|
|
1751
1788
|
? chalk.blue(padRight(truncate(ticketLabel(s) || '-', TICKET_W), TICKET_W + 1))
|
|
1752
1789
|
: '';
|
|
1790
|
+
// Which terminal/editor the session runs in — a tab in Ghostty vs a VS Code
|
|
1791
|
+
// panel vs a detached tmux pane is the thing you need to know to go find it.
|
|
1792
|
+
const hostCell = cols.showHost
|
|
1793
|
+
? chalk.gray(padRight(truncate(host || '-', PICKER_HOST_W - 1), PICKER_HOST_W))
|
|
1794
|
+
: '';
|
|
1753
1795
|
// The picker prepends a gutter (cursor, plus a checkbox in multi-select mode);
|
|
1754
1796
|
// reserve it, plus the conditional columns, so the topic shrinks to fit and
|
|
1755
1797
|
// rows never wrap.
|
|
1756
1798
|
const gutter = cols.gutter ?? 2;
|
|
1757
1799
|
const machineColW = cols.showMachine ? machineW : 0;
|
|
1758
1800
|
const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
|
|
1801
|
+
const hostW = cols.showHost ? PICKER_HOST_W : 0;
|
|
1759
1802
|
const wtW = wt ? stringWidth(wt) + 1 : 0;
|
|
1760
|
-
const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - ticketW - wtW - sshW - stringWidth(when) - 1);
|
|
1761
|
-
return (
|
|
1803
|
+
const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - stringWidth(when) - 1);
|
|
1804
|
+
return (
|
|
1805
|
+
// Truncated, not just padded: an indexed shortId is always 8 chars, but a
|
|
1806
|
+
// live row with no session id is named by its pid or cloud task, which can
|
|
1807
|
+
// run past the column and shunt every later column out of alignment.
|
|
1808
|
+
chalk.white(padRight(truncate(s.shortId, 9), 10)) +
|
|
1762
1809
|
agentColor(padRight(truncate(s.agent, 8), 9)) +
|
|
1763
1810
|
chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
|
|
1764
1811
|
machineCell +
|
|
1812
|
+
hostCell +
|
|
1765
1813
|
chalk.cyan(padRight(truncate(project, 14), 16)) +
|
|
1766
1814
|
sshSeg +
|
|
1767
1815
|
renderTopicCell(label, topic, query, topicW, topicW) +
|
|
@@ -1827,7 +1875,25 @@ function warnNoPeerTarget(machine, session) {
|
|
|
1827
1875
|
console.log(chalk.yellow(`Session ${session.shortId} lives on ${machine}, which isn't a reachable device right now.`));
|
|
1828
1876
|
console.log(chalk.gray(`Register/wake it (ag devices), or run there: agents ssh ${machine}`));
|
|
1829
1877
|
}
|
|
1878
|
+
/**
|
|
1879
|
+
* Row-id prefix for a live session whose agent has not reported a session id yet
|
|
1880
|
+
* (a booting harness, a queued teammate). The browser lists these so nothing live
|
|
1881
|
+
* is hidden, but they address no transcript — {@link isIdlessLiveRow} is what the
|
|
1882
|
+
* pick handler checks before trying to read or resume one.
|
|
1883
|
+
*/
|
|
1884
|
+
export const LIVE_ROW_PREFIX = 'live:';
|
|
1885
|
+
export function isIdlessLiveRow(s) {
|
|
1886
|
+
return s.id.startsWith(LIVE_ROW_PREFIX);
|
|
1887
|
+
}
|
|
1830
1888
|
export async function handlePickedSession(picked) {
|
|
1889
|
+
// A live row with no session id has no transcript to open and no id to resume
|
|
1890
|
+
// by; say where the process is instead of dead-ending on an unreadable path.
|
|
1891
|
+
if (isIdlessLiveRow(picked.session)) {
|
|
1892
|
+
const where = picked.session.machine ? ` on ${picked.session.machine}` : '';
|
|
1893
|
+
console.log(chalk.yellow(`This session hasn't reported a session id yet — nothing to open${where}.`));
|
|
1894
|
+
console.log(chalk.gray(`Watch for it with: agents sessions --active${picked.session.machine ? ` --host ${picked.session.machine}` : ''}`));
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1831
1897
|
// A session on another machine is read/resumed ON that machine over SSH — its
|
|
1832
1898
|
// transcript and agent binary live there. Both actions execute on the peer
|
|
1833
1899
|
// (not a local `--host` hop, which would discover locally and dead-end for a
|
|
@@ -2403,6 +2469,21 @@ async function renderOneSession(query, mode, scope) {
|
|
|
2403
2469
|
}
|
|
2404
2470
|
}
|
|
2405
2471
|
else if (byId) {
|
|
2472
|
+
// Not on this machine. A UUID names ONE session, so before giving up ask
|
|
2473
|
+
// the fleet — the session may live only on a peer (the whole point of
|
|
2474
|
+
// RUSH-2024). Skip the sweep when the caller pinned --local, or when we
|
|
2475
|
+
// are ourselves a peer answering a parent's sweep (AGENTS_SESSIONS_LOCAL),
|
|
2476
|
+
// so a locate never recurses. On a single remote hit we hand rendering to
|
|
2477
|
+
// that peer; a multi-host hit surfaces the conflict; a miss keeps the
|
|
2478
|
+
// local not-found message. No FTS fallback either way.
|
|
2479
|
+
if (shouldFanOutForId(query, scope.local)) {
|
|
2480
|
+
const outcome = await resolveSessionAcrossFleet(query, mode, scope.hosts);
|
|
2481
|
+
if (outcome === 'rendered')
|
|
2482
|
+
return;
|
|
2483
|
+
if (outcome === 'conflict')
|
|
2484
|
+
process.exit(1);
|
|
2485
|
+
// 'not-found' falls through to the local message below.
|
|
2486
|
+
}
|
|
2406
2487
|
notFoundByIdMessage(query).forEach(l => console.error(l));
|
|
2407
2488
|
process.exit(1);
|
|
2408
2489
|
}
|
|
@@ -2441,6 +2522,123 @@ async function renderOneSession(query, mode, scope) {
|
|
|
2441
2522
|
process.exit(1);
|
|
2442
2523
|
}
|
|
2443
2524
|
}
|
|
2525
|
+
/**
|
|
2526
|
+
* Whether a missed local id lookup should widen to a cross-machine sweep.
|
|
2527
|
+
*
|
|
2528
|
+
* Gate (all must hold):
|
|
2529
|
+
* - the query is id-shaped (`looksLikeSessionId`) — only an identifier resolves
|
|
2530
|
+
* across the fleet; a search phrase never does.
|
|
2531
|
+
* - not `--local` — the caller opted out of cross-machine lookup (deterministic
|
|
2532
|
+
* local behavior for scripts, RUSH-2024 acceptance: "--local still restricts").
|
|
2533
|
+
* - `AGENTS_SESSIONS_LOCAL` is unset — we are not ourselves a peer answering a
|
|
2534
|
+
* parent's sweep, so a locate can never recurse (RUSH-2024: avoid double-fan-out).
|
|
2535
|
+
*
|
|
2536
|
+
* Pure + exported so the gate is unit-tested without driving discovery / SSH.
|
|
2537
|
+
*/
|
|
2538
|
+
export function shouldFanOutForId(query, local) {
|
|
2539
|
+
if (local === true)
|
|
2540
|
+
return false;
|
|
2541
|
+
if (process.env[NO_FANOUT_ENV] === '1')
|
|
2542
|
+
return false;
|
|
2543
|
+
return looksLikeSessionId(query);
|
|
2544
|
+
}
|
|
2545
|
+
/** The render-mode flag a peer's `agents sessions <id>` must carry so the remote
|
|
2546
|
+
* summary matches the mode the user asked for. `summary` is the peer's default,
|
|
2547
|
+
* so it needs no flag; the others map 1:1 to a CLI flag. */
|
|
2548
|
+
function modeFlag(mode) {
|
|
2549
|
+
if (mode === 'markdown')
|
|
2550
|
+
return '--markdown';
|
|
2551
|
+
if (mode === 'json')
|
|
2552
|
+
return '--json';
|
|
2553
|
+
return undefined; // summary — the peer's default render
|
|
2554
|
+
}
|
|
2555
|
+
/** Group a fleet sweep's rows to the DISTINCT machines that hold the id. Each
|
|
2556
|
+
* peer answered `sessions <id> --json --local`, which (post-fix) id-resolves and
|
|
2557
|
+
* so returns the matching row(s); a peer with a synced MIRROR of the same id can
|
|
2558
|
+
* emit more than one row, so we keep the first per machine. Rows the peer somehow
|
|
2559
|
+
* returned that do NOT match the id (defensive against version skew) are dropped
|
|
2560
|
+
* so a stray content hit can never masquerade as an exact resolution. */
|
|
2561
|
+
export function fleetHitsById(rows, id) {
|
|
2562
|
+
const q = id.trim().toLowerCase();
|
|
2563
|
+
const byMachine = new Map();
|
|
2564
|
+
for (const s of rows) {
|
|
2565
|
+
const machine = s.machine;
|
|
2566
|
+
if (!machine)
|
|
2567
|
+
continue; // an untagged row can't be routed back to a peer
|
|
2568
|
+
if (s.id?.toLowerCase() !== q)
|
|
2569
|
+
continue; // exact id only — never a content hit
|
|
2570
|
+
if (!byMachine.has(machine))
|
|
2571
|
+
byMachine.set(machine, s);
|
|
2572
|
+
}
|
|
2573
|
+
return Array.from(byMachine.entries()).map(([machine, session]) => ({ machine, session }));
|
|
2574
|
+
}
|
|
2575
|
+
/**
|
|
2576
|
+
* Locate a full session id across the online fleet and render it from the machine
|
|
2577
|
+
* that holds it. The local disk already missed; this fans `sessions <id> --json
|
|
2578
|
+
* --all` out to every registered online peer (or the explicit `hosts` set),
|
|
2579
|
+
* groups the rows to distinct machines, then:
|
|
2580
|
+
*
|
|
2581
|
+
* - exactly one machine → delegate rendering to that peer via `runOnPeer`
|
|
2582
|
+
* (its transcript and agent binary live there — a local `--host` hop would
|
|
2583
|
+
* re-discover locally and dead-end), returning `'rendered'`.
|
|
2584
|
+
* - more than one machine → print the conflict with machine labels so the user
|
|
2585
|
+
* can disambiguate with `--device <host>`, returning `'conflict'`.
|
|
2586
|
+
* - none → `'not-found'`, letting the caller print the local
|
|
2587
|
+
* "no session on this machine" message.
|
|
2588
|
+
*
|
|
2589
|
+
* No fuzzy/content fallback: the sweep forwards a UUID, each peer id-resolves it,
|
|
2590
|
+
* and `fleetHitsById` drops anything that isn't an exact id match.
|
|
2591
|
+
*/
|
|
2592
|
+
export async function resolveSessionAcrossFleet(id, mode, hosts, deps = { gatherRemoteList, runOnPeer }) {
|
|
2593
|
+
const spinner = isInteractiveTerminal() ? ora('Searching the fleet...').start() : null;
|
|
2594
|
+
let hits;
|
|
2595
|
+
try {
|
|
2596
|
+
// Force whole-index scope (--all): the peer runs in its SSH-login home dir,
|
|
2597
|
+
// whose cwd would otherwise silently narrow the lookup and hide the row.
|
|
2598
|
+
// --json so each peer answers a parseable array; --local so it answers for
|
|
2599
|
+
// itself and never re-fans-out (belt-and-suspenders with the parent's
|
|
2600
|
+
// AGENTS_SESSIONS_LOCAL, which remote-list also sets on the peer).
|
|
2601
|
+
const forwarded = ['sessions', id, '--json', '--all', '--local'];
|
|
2602
|
+
const { sessions } = await deps.gatherRemoteList(forwarded, hosts);
|
|
2603
|
+
hits = fleetHitsById(sessions, id);
|
|
2604
|
+
}
|
|
2605
|
+
catch {
|
|
2606
|
+
// A fan-out failure is not an exact resolution — treat as not-found so the
|
|
2607
|
+
// caller prints the honest local message rather than a half-answer.
|
|
2608
|
+
hits = [];
|
|
2609
|
+
}
|
|
2610
|
+
finally {
|
|
2611
|
+
spinner?.stop();
|
|
2612
|
+
}
|
|
2613
|
+
if (hits.length === 0)
|
|
2614
|
+
return 'not-found';
|
|
2615
|
+
if (hits.length > 1) {
|
|
2616
|
+
console.error(chalk.red(`Session ${id} exists on multiple machines:`));
|
|
2617
|
+
for (const h of hits) {
|
|
2618
|
+
const s = h.session;
|
|
2619
|
+
const label = s.label ?? s.topic ?? '';
|
|
2620
|
+
console.error(chalk.cyan(` ${h.machine}`) + chalk.gray(` ${s.agent}${s.version ? ` ${s.version}` : ''} ${label}`));
|
|
2621
|
+
}
|
|
2622
|
+
console.error(chalk.gray(`Pick one with: agents sessions ${id} --device <host>`));
|
|
2623
|
+
return 'conflict';
|
|
2624
|
+
}
|
|
2625
|
+
const { machine } = hits[0];
|
|
2626
|
+
// Render the remote summary by re-running `sessions <id>` ON the peer. --local
|
|
2627
|
+
// keeps that render on the peer (it owns the transcript); the mode flag matches
|
|
2628
|
+
// the mode the user asked for. No TTY: a summary/markdown/json render is a
|
|
2629
|
+
// one-shot capture, not an interactive resume.
|
|
2630
|
+
const peerArgs = ['sessions', id, '--local'];
|
|
2631
|
+
const flag = modeFlag(mode);
|
|
2632
|
+
if (flag)
|
|
2633
|
+
peerArgs.push(flag);
|
|
2634
|
+
const result = await deps.runOnPeer(peerArgs, machine);
|
|
2635
|
+
if (result === 'no-target') {
|
|
2636
|
+
console.error(chalk.red(`Session ${id} is on ${machine}, but it is not a reachable registered device.`));
|
|
2637
|
+
console.error(chalk.gray('Register it with `agents devices` or run the command on that machine.'));
|
|
2638
|
+
return 'conflict'; // a definitive answer (found, un-renderable) — do NOT fall to the local not-found line
|
|
2639
|
+
}
|
|
2640
|
+
return 'rendered';
|
|
2641
|
+
}
|
|
2444
2642
|
/** Register the `agents sessions` command with all its options and help text. */
|
|
2445
2643
|
export function registerSessionsCommands(program) {
|
|
2446
2644
|
const sessionsCmd = program
|
|
@@ -2455,7 +2653,7 @@ export function registerSessionsCommands(program) {
|
|
|
2455
2653
|
.option('--antigravity', 'Shorthand for --agent antigravity')
|
|
2456
2654
|
.option('--grok', 'Shorthand for --agent grok')
|
|
2457
2655
|
.option('--opencode', 'Shorthand for --agent opencode')
|
|
2458
|
-
.option('--all', '
|
|
2656
|
+
.option('--all', 'Widen every non-status filter to "all": every directory (not just this project) and all time (no window cap). Status filters like --active still compose; -a/--device/--since still narrow their axis.')
|
|
2459
2657
|
.option('--unmanaged', "Also show sessions from your own ~/.<agent> installs (hidden once agents-cli manages that agent)")
|
|
2460
2658
|
.option('--teams', 'Include team-spawned sessions (hidden by default)')
|
|
2461
2659
|
.option('--routine', 'Show only sessions archived from routine runs')
|
package/dist/commands/ssh.js
CHANGED
|
@@ -37,6 +37,7 @@ import { fanOutDevices, fleetHealthSkip, planFleetTargets, remoteFleetTargets, r
|
|
|
37
37
|
import { fleetCapacity, fmtBytes, headroom, } from '../lib/devices/health.js';
|
|
38
38
|
import { buildFleetHealthReport, renderFleetMatrix, renderFleetSummary, renderFleetWarnings, } from '../lib/devices/health-report.js';
|
|
39
39
|
import { loadFleetStats, readStatsCache } from '../lib/devices/stats-cache.js';
|
|
40
|
+
import { collectLocalFleetInventory } from '../lib/devices/fleet-inventory.js';
|
|
40
41
|
import { checkSyncStatus, countOrphans } from '../lib/drift.js';
|
|
41
42
|
import { checkAllClis } from '../lib/teams/agents.js';
|
|
42
43
|
import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
|
|
@@ -321,6 +322,9 @@ function localHealthRow(self, stats) {
|
|
|
321
322
|
clis: checkAllClis(),
|
|
322
323
|
sync: checkSyncStatus(process.cwd()),
|
|
323
324
|
orphans: countOrphans(),
|
|
325
|
+
// Local baseline inventory for cross-device divergence (RUSH-2027) — the
|
|
326
|
+
// yardstick every remote box is compared against.
|
|
327
|
+
inventory: collectLocalFleetInventory(process.cwd()),
|
|
324
328
|
};
|
|
325
329
|
}
|
|
326
330
|
async function probeRemoteHealth(target) {
|
|
@@ -344,6 +348,10 @@ async function probeRemoteHealth(target) {
|
|
|
344
348
|
// so the Auth column is current without a prior fleet-wide `fleet ping`.
|
|
345
349
|
// Older remotes that don't emit it fall back to this host's cache below.
|
|
346
350
|
auth: parsed.auth,
|
|
351
|
+
// Harness inventory (resources / agent versions / repo state) for
|
|
352
|
+
// cross-device divergence detection (RUSH-2027). Undefined on an older CLI
|
|
353
|
+
// that doesn't emit the `fleet` field — the comparator skips it.
|
|
354
|
+
inventory: parsed.fleet,
|
|
347
355
|
};
|
|
348
356
|
}
|
|
349
357
|
async function runFleetStatus(opts) {
|
|
@@ -416,7 +424,7 @@ async function runFleetStatus(opts) {
|
|
|
416
424
|
row.lastSeen = profile.tailscale?.lastSeen ?? profile.reachability?.checkedAt;
|
|
417
425
|
}
|
|
418
426
|
}
|
|
419
|
-
const report = buildFleetHealthReport(rows);
|
|
427
|
+
const report = buildFleetHealthReport(rows, new Date(), { self });
|
|
420
428
|
if (opts.json) {
|
|
421
429
|
console.log(JSON.stringify(report, null, 2));
|
|
422
430
|
}
|
package/dist/index.js
CHANGED
|
@@ -960,12 +960,10 @@ if (brandDisabled.size > 0) {
|
|
|
960
960
|
if (!helpOrVersionRequested) {
|
|
961
961
|
// Run update check before parsing so the upgrade notice/prompt precedes output.
|
|
962
962
|
await checkForUpdates();
|
|
963
|
-
//
|
|
964
|
-
// fire-and-forget the next background sync. System repo gets a real fast-forward
|
|
963
|
+
// Fire-and-forget the background sync. System repo gets a real fast-forward
|
|
965
964
|
// pull (read-only locally, safe). User repo and extras get fetch-only + a
|
|
966
|
-
// status marker that
|
|
967
|
-
const { spawnDetachedSync
|
|
968
|
-
printPendingUpdateNotices();
|
|
965
|
+
// status marker that `agents doctor` surfaces as a repo-behind warning.
|
|
966
|
+
const { spawnDetachedSync } = await import('./lib/auto-pull.js');
|
|
969
967
|
spawnDetachedSync();
|
|
970
968
|
}
|
|
971
969
|
// First-run experience: no args + no config yet + TTY -> launch interactive setup.
|
package/dist/lib/agents.d.ts
CHANGED
|
@@ -265,27 +265,6 @@ export declare function __resetAntigravityKeychainCacheForTest(): void;
|
|
|
265
265
|
* Sync, no Keychain, no network — safe on the `agents run` hot path.
|
|
266
266
|
*/
|
|
267
267
|
export declare function isClaudeCredentialFileBlank(base: string, platform?: NodeJS.Platform): boolean;
|
|
268
|
-
/**
|
|
269
|
-
* Whether a Claude version home holds its OWN on-disk credential — a non-blank
|
|
270
|
-
* `.claude/.credentials.json` (the store Claude Code writes on login off macOS)
|
|
271
|
-
* or a `.claude/.oauth_token` file (the keychain-less shim fallback, shims.ts).
|
|
272
|
-
*
|
|
273
|
-
* RUSH-1979: the routines daemon injects one ambient `CLAUDE_CODE_OAUTH_TOKEN`
|
|
274
|
-
* (RUSH-1759) so a token-less default account still authenticates. But Claude —
|
|
275
|
-
* and the Linux shim's own `-z CLAUDE_CODE_OAUTH_TOKEN` guard — both prefer that
|
|
276
|
-
* env var over a pinned account's config dir, so when balanced rotation pins a
|
|
277
|
-
* specific account's `CLAUDE_CONFIG_DIR` the ambient token shadows it: the pool
|
|
278
|
-
* is inert and every fire 401s on the one stale token. A routine spawn drops the
|
|
279
|
-
* ambient token when the pinned account has its own credential (this returns
|
|
280
|
-
* true) so the account authenticates itself; when it does not (the RUSH-1759
|
|
281
|
-
* default), the injected token is kept.
|
|
282
|
-
*
|
|
283
|
-
* Off-darwin only, mirroring {@link isClaudeCredentialFileBlank}: on macOS the
|
|
284
|
-
* login Keychain is the canonical store and probing it raises an auth sheet, and
|
|
285
|
-
* there the daemon injects no token to shadow (broker-only, usually absent), so
|
|
286
|
-
* returning false changes nothing. Sync, no Keychain, no network.
|
|
287
|
-
*/
|
|
288
|
-
export declare function claudeHomeHasOwnCredential(base: string, platform?: NodeJS.Platform): boolean;
|
|
289
268
|
export declare function getAccountInfo(agentId: AgentId, home?: string): Promise<AccountInfo>;
|
|
290
269
|
/**
|
|
291
270
|
* Determine when the agent was last used by checking session file mtimes,
|
package/dist/lib/agents.js
CHANGED
|
@@ -1404,43 +1404,6 @@ export function isClaudeCredentialFileBlank(base, platform = process.platform) {
|
|
|
1404
1404
|
return false;
|
|
1405
1405
|
}
|
|
1406
1406
|
}
|
|
1407
|
-
/**
|
|
1408
|
-
* Whether a Claude version home holds its OWN on-disk credential — a non-blank
|
|
1409
|
-
* `.claude/.credentials.json` (the store Claude Code writes on login off macOS)
|
|
1410
|
-
* or a `.claude/.oauth_token` file (the keychain-less shim fallback, shims.ts).
|
|
1411
|
-
*
|
|
1412
|
-
* RUSH-1979: the routines daemon injects one ambient `CLAUDE_CODE_OAUTH_TOKEN`
|
|
1413
|
-
* (RUSH-1759) so a token-less default account still authenticates. But Claude —
|
|
1414
|
-
* and the Linux shim's own `-z CLAUDE_CODE_OAUTH_TOKEN` guard — both prefer that
|
|
1415
|
-
* env var over a pinned account's config dir, so when balanced rotation pins a
|
|
1416
|
-
* specific account's `CLAUDE_CONFIG_DIR` the ambient token shadows it: the pool
|
|
1417
|
-
* is inert and every fire 401s on the one stale token. A routine spawn drops the
|
|
1418
|
-
* ambient token when the pinned account has its own credential (this returns
|
|
1419
|
-
* true) so the account authenticates itself; when it does not (the RUSH-1759
|
|
1420
|
-
* default), the injected token is kept.
|
|
1421
|
-
*
|
|
1422
|
-
* Off-darwin only, mirroring {@link isClaudeCredentialFileBlank}: on macOS the
|
|
1423
|
-
* login Keychain is the canonical store and probing it raises an auth sheet, and
|
|
1424
|
-
* there the daemon injects no token to shadow (broker-only, usually absent), so
|
|
1425
|
-
* returning false changes nothing. Sync, no Keychain, no network.
|
|
1426
|
-
*/
|
|
1427
|
-
export function claudeHomeHasOwnCredential(base, platform = process.platform) {
|
|
1428
|
-
if (platform === 'darwin')
|
|
1429
|
-
return false;
|
|
1430
|
-
if (fs.existsSync(path.join(base, '.claude', '.oauth_token')))
|
|
1431
|
-
return true;
|
|
1432
|
-
try {
|
|
1433
|
-
const raw = fs.readFileSync(path.join(base, '.claude', '.credentials.json'), 'utf-8');
|
|
1434
|
-
const oauth = JSON.parse(raw).claudeAiOauth;
|
|
1435
|
-
if (!oauth)
|
|
1436
|
-
return false;
|
|
1437
|
-
const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
1438
|
-
return nonEmpty(oauth.accessToken) || nonEmpty(oauth.refreshToken);
|
|
1439
|
-
}
|
|
1440
|
-
catch {
|
|
1441
|
-
return false;
|
|
1442
|
-
}
|
|
1443
|
-
}
|
|
1444
1407
|
export async function getAccountInfo(agentId, home) {
|
|
1445
1408
|
const base = home || os.homedir();
|
|
1446
1409
|
const empty = {
|
package/dist/lib/auto-pull.d.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* Background sync for tracked git repos:
|
|
3
3
|
* - System repo (~/.agents/.system/) is read-only locally — fast-forward auto-pull is safe.
|
|
4
4
|
* - User repo (~/.agents/) and enabled extras may have local commits, so we only
|
|
5
|
-
* `git fetch` and write a status marker.
|
|
6
|
-
*
|
|
5
|
+
* `git fetch` and write a status marker. `agents doctor` surfaces these markers
|
|
6
|
+
* as a "Repo updates" section instead of printing to stderr on every command.
|
|
7
|
+
* Pulling is left to the user via `agents repo pull`.
|
|
7
8
|
*
|
|
8
9
|
* Public API:
|
|
9
|
-
* spawnDetachedSync()
|
|
10
|
-
*
|
|
10
|
+
* spawnDetachedSync() — fire-and-forget; never blocks the foreground command.
|
|
11
|
+
* readRepoBehindMarkers() — synchronous; reads markers without consuming them.
|
|
11
12
|
*/
|
|
12
13
|
/** Per-repo lock file path. mtime acts as a recency check. */
|
|
13
14
|
export declare function lockFilePath(alias: string): string;
|
|
@@ -24,8 +25,15 @@ export interface FetchStatusMarker {
|
|
|
24
25
|
/** Spawn the detached worker. No-op when AGENTS_NO_AUTOPULL=1 is set. */
|
|
25
26
|
export declare function spawnDetachedSync(): void;
|
|
26
27
|
/**
|
|
27
|
-
* Read
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* Read all current status markers and return those where the local repo is
|
|
29
|
+
* behind upstream. Markers are NOT deleted — they persist until the next
|
|
30
|
+
* background fetch overwrites them with fresh data.
|
|
31
|
+
*
|
|
32
|
+
* Synchronous, cheap (small JSON files). Used by `agents doctor` to surface
|
|
33
|
+
* repo-behind warnings in one place instead of printing to stderr on every
|
|
34
|
+
* command.
|
|
35
|
+
*
|
|
36
|
+
* @param fetchDir - Override the fetch state dir (for tests). Defaults to the
|
|
37
|
+
* production getFetchCacheDir() path.
|
|
30
38
|
*/
|
|
31
|
-
export declare function
|
|
39
|
+
export declare function readRepoBehindMarkers(fetchDir?: string): FetchStatusMarker[];
|
package/dist/lib/auto-pull.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* Background sync for tracked git repos:
|
|
3
3
|
* - System repo (~/.agents/.system/) is read-only locally — fast-forward auto-pull is safe.
|
|
4
4
|
* - User repo (~/.agents/) and enabled extras may have local commits, so we only
|
|
5
|
-
* `git fetch` and write a status marker.
|
|
6
|
-
*
|
|
5
|
+
* `git fetch` and write a status marker. `agents doctor` surfaces these markers
|
|
6
|
+
* as a "Repo updates" section instead of printing to stderr on every command.
|
|
7
|
+
* Pulling is left to the user via `agents repo pull`.
|
|
7
8
|
*
|
|
8
9
|
* Public API:
|
|
9
|
-
* spawnDetachedSync()
|
|
10
|
-
*
|
|
10
|
+
* spawnDetachedSync() — fire-and-forget; never blocks the foreground command.
|
|
11
|
+
* readRepoBehindMarkers() — synchronous; reads markers without consuming them.
|
|
11
12
|
*/
|
|
12
13
|
import * as fs from 'fs';
|
|
13
14
|
import * as path from 'path';
|
|
@@ -57,21 +58,29 @@ export function spawnDetachedSync() {
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
/**
|
|
60
|
-
* Read
|
|
61
|
-
*
|
|
62
|
-
*
|
|
61
|
+
* Read all current status markers and return those where the local repo is
|
|
62
|
+
* behind upstream. Markers are NOT deleted — they persist until the next
|
|
63
|
+
* background fetch overwrites them with fresh data.
|
|
64
|
+
*
|
|
65
|
+
* Synchronous, cheap (small JSON files). Used by `agents doctor` to surface
|
|
66
|
+
* repo-behind warnings in one place instead of printing to stderr on every
|
|
67
|
+
* command.
|
|
68
|
+
*
|
|
69
|
+
* @param fetchDir - Override the fetch state dir (for tests). Defaults to the
|
|
70
|
+
* production getFetchCacheDir() path.
|
|
63
71
|
*/
|
|
64
|
-
export function
|
|
65
|
-
const dir = fetchStateDir();
|
|
72
|
+
export function readRepoBehindMarkers(fetchDir) {
|
|
73
|
+
const dir = fetchDir ?? fetchStateDir();
|
|
66
74
|
if (!fs.existsSync(dir))
|
|
67
|
-
return;
|
|
75
|
+
return [];
|
|
68
76
|
let entries = [];
|
|
69
77
|
try {
|
|
70
78
|
entries = fs.readdirSync(dir);
|
|
71
79
|
}
|
|
72
80
|
catch {
|
|
73
|
-
return;
|
|
81
|
+
return [];
|
|
74
82
|
}
|
|
83
|
+
const result = [];
|
|
75
84
|
for (const name of entries) {
|
|
76
85
|
if (!name.endsWith('.status.json'))
|
|
77
86
|
continue;
|
|
@@ -81,25 +90,11 @@ export function printPendingUpdateNotices() {
|
|
|
81
90
|
marker = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
82
91
|
}
|
|
83
92
|
catch {
|
|
84
|
-
try {
|
|
85
|
-
fs.unlinkSync(file);
|
|
86
|
-
}
|
|
87
|
-
catch { /* ignore */ }
|
|
88
93
|
continue;
|
|
89
94
|
}
|
|
90
|
-
if (!marker || marker.behind <= 0)
|
|
91
|
-
try {
|
|
92
|
-
fs.unlinkSync(file);
|
|
93
|
-
}
|
|
94
|
-
catch { /* ignore */ }
|
|
95
|
+
if (!marker || marker.behind <= 0)
|
|
95
96
|
continue;
|
|
96
|
-
|
|
97
|
-
const repoLabel = marker.alias === 'user' ? '~/.agents/' : marker.alias;
|
|
98
|
-
process.stderr.write(`agents-cli: ${repoLabel} is ${marker.behind} commit${marker.behind === 1 ? '' : 's'} ` +
|
|
99
|
-
`behind ${marker.branch} — run 'agents repo pull ${marker.alias}' to update.\n`);
|
|
100
|
-
try {
|
|
101
|
-
fs.unlinkSync(file);
|
|
102
|
-
}
|
|
103
|
-
catch { /* ignore */ }
|
|
97
|
+
result.push(marker);
|
|
104
98
|
}
|
|
99
|
+
return result;
|
|
105
100
|
}
|