@phnx-labs/agents-cli 1.22.22 → 1.22.23
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 +209 -0
- package/README.md +8 -2
- package/dist/commands/doctor.js +15 -7
- package/dist/commands/exec.js +20 -6
- package/dist/commands/focus.d.ts +76 -4
- package/dist/commands/focus.js +219 -40
- package/dist/commands/fork.d.ts +20 -2
- package/dist/commands/fork.js +91 -64
- package/dist/commands/go.d.ts +25 -0
- package/dist/commands/go.js +63 -2
- package/dist/commands/harness-wizard.d.ts +206 -0
- package/dist/commands/harness-wizard.js +403 -0
- package/dist/commands/harness.d.ts +12 -0
- package/dist/commands/harness.js +97 -101
- package/dist/commands/resume.js +10 -3
- package/dist/commands/secrets.js +25 -30
- package/dist/commands/sessions-resume.d.ts +21 -3
- package/dist/commands/sessions-resume.js +50 -11
- package/dist/commands/sessions.d.ts +81 -5
- package/dist/commands/sessions.js +325 -66
- package/dist/commands/watchdog.js +13 -2
- package/dist/lib/agents.d.ts +1 -1
- package/dist/lib/agents.js +132 -0
- package/dist/lib/codex-policy.d.ts +17 -0
- package/dist/lib/codex-policy.js +48 -0
- package/dist/lib/crabbox/lease.d.ts +25 -0
- package/dist/lib/crabbox/lease.js +62 -0
- package/dist/lib/daemon.js +70 -0
- package/dist/lib/exec.d.ts +4 -0
- package/dist/lib/exec.js +88 -54
- package/dist/lib/feed-broadcast.d.ts +1 -20
- package/dist/lib/feed-broadcast.js +31 -1
- package/dist/lib/hooks.js +12 -2
- package/dist/lib/mcp.js +44 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
- package/dist/lib/models.d.ts +0 -5
- package/dist/lib/models.js +48 -0
- package/dist/lib/plugin-marketplace.js +9 -0
- package/dist/lib/pricing/prices.json +119 -92
- package/dist/lib/pricing/table.js +13 -0
- package/dist/lib/remote-agents-json.d.ts +29 -1
- package/dist/lib/remote-agents-json.js +47 -10
- package/dist/lib/resources/mcp.js +2 -0
- package/dist/lib/resources/permissions.js +3 -0
- package/dist/lib/resources/types.d.ts +2 -1
- package/dist/lib/runner.js +28 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +118 -49
- package/dist/lib/secrets/reaper.d.ts +87 -0
- package/dist/lib/secrets/reaper.js +184 -0
- package/dist/lib/secrets/remote.d.ts +29 -0
- package/dist/lib/secrets/remote.js +37 -1
- package/dist/lib/session/active.d.ts +36 -1
- package/dist/lib/session/active.js +60 -19
- package/dist/lib/session/actor-sidecar.d.ts +14 -0
- package/dist/lib/session/actor-sidecar.js +67 -4
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +32 -1
- package/dist/lib/session/discover.js +168 -0
- package/dist/lib/session/parse.d.ts +10 -0
- package/dist/lib/session/parse.js +98 -0
- package/dist/lib/session/remote-list.d.ts +10 -1
- package/dist/lib/session/remote-list.js +2 -8
- package/dist/lib/session/remote.d.ts +57 -3
- package/dist/lib/session/remote.js +90 -26
- package/dist/lib/session/resume-command.d.ts +6 -0
- package/dist/lib/session/resume-command.js +8 -0
- package/dist/lib/session/session-cache.d.ts +173 -0
- package/dist/lib/session/session-cache.js +399 -0
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/session/width.d.ts +1 -1
- package/dist/lib/session/width.js +12 -2
- package/dist/lib/shims.d.ts +2 -2
- package/dist/lib/shims.js +40 -5
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/usage.d.ts +13 -0
- package/dist/lib/usage.js +215 -0
- package/dist/lib/versions.js +13 -2
- package/package.json +1 -1
- package/dist/bin/agents +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
|
@@ -160,6 +160,9 @@ export function parseSession(filePath, agent, opts = {}) {
|
|
|
160
160
|
case 'cursor':
|
|
161
161
|
events = parseCursor(filePath);
|
|
162
162
|
break;
|
|
163
|
+
case 'muse':
|
|
164
|
+
events = parseMuse(filePath);
|
|
165
|
+
break;
|
|
163
166
|
}
|
|
164
167
|
// Chokepoint: every string field that originated in an untrusted session
|
|
165
168
|
// file gets stripped of terminal escapes here, so renderers downstream can
|
|
@@ -206,6 +209,13 @@ export function detectAgent(filePath) {
|
|
|
206
209
|
return 'droid';
|
|
207
210
|
if (filePath.includes('/.cursor/') || filePath.includes('\\.cursor\\'))
|
|
208
211
|
return 'cursor';
|
|
212
|
+
// Muse sessions: ~/.local/share/muse/sessions/YYYY/MM/DD/<uuid>/session.jsonl
|
|
213
|
+
if (filePath.includes('/muse/sessions/') ||
|
|
214
|
+
filePath.includes('\\muse\\sessions\\') ||
|
|
215
|
+
filePath.includes('/.local/share/muse/') ||
|
|
216
|
+
filePath.includes('\\.local\\share\\muse\\')) {
|
|
217
|
+
return 'muse';
|
|
218
|
+
}
|
|
209
219
|
// Cloud convention: cloud-sessions/<id>/session.<format>.jsonl
|
|
210
220
|
const cloudMatch = filePath.match(/session\.(claude|codex|rush)\.jsonl(?:$|[?#])/);
|
|
211
221
|
if (cloudMatch)
|
|
@@ -1504,6 +1514,94 @@ export function parseRush(filePath) {
|
|
|
1504
1514
|
// system_prompt, message_count, messages: [{role, content}, ...] }
|
|
1505
1515
|
// Content may be a string or an array of text parts.
|
|
1506
1516
|
// ---------------------------------------------------------------------------
|
|
1517
|
+
/**
|
|
1518
|
+
* Muse Code session.jsonl → normalized events.
|
|
1519
|
+
*
|
|
1520
|
+
* Muse records an append-only event log. We map:
|
|
1521
|
+
* - runtime.command_intake.received / turn_submit → user message
|
|
1522
|
+
* - assistant_message_committed → assistant message
|
|
1523
|
+
* - model_completed.usage → usage
|
|
1524
|
+
* - tool-related events when present
|
|
1525
|
+
*/
|
|
1526
|
+
export function parseMuse(filePath) {
|
|
1527
|
+
const content = safeReadSessionFile(filePath);
|
|
1528
|
+
const lines = content.split('\n').filter((l) => l.trim());
|
|
1529
|
+
const events = [];
|
|
1530
|
+
const museTs = (raw) => {
|
|
1531
|
+
if (typeof raw?.recorded_at === 'number') {
|
|
1532
|
+
const v = raw.recorded_at;
|
|
1533
|
+
const ms = v > 1e14 ? Math.floor(v / 1000) : v;
|
|
1534
|
+
return new Date(ms).toISOString();
|
|
1535
|
+
}
|
|
1536
|
+
return new Date().toISOString();
|
|
1537
|
+
};
|
|
1538
|
+
for (const line of lines) {
|
|
1539
|
+
let raw;
|
|
1540
|
+
try {
|
|
1541
|
+
raw = JSON.parse(line);
|
|
1542
|
+
}
|
|
1543
|
+
catch {
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
const timestamp = museTs(raw);
|
|
1547
|
+
const payloadType = raw.payload_type;
|
|
1548
|
+
const payload = raw.payload;
|
|
1549
|
+
const event = payload?.event ?? payload;
|
|
1550
|
+
if (payloadType === 'runtime.command_intake.received') {
|
|
1551
|
+
const cmd = payload?.record?.command;
|
|
1552
|
+
if (cmd?.kind === 'turn_submit' && typeof cmd.prompt === 'string' && cmd.prompt.trim()) {
|
|
1553
|
+
events.push({
|
|
1554
|
+
type: 'message',
|
|
1555
|
+
agent: 'muse',
|
|
1556
|
+
timestamp,
|
|
1557
|
+
role: 'user',
|
|
1558
|
+
content: cmd.prompt.trim(),
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
if (event?.kind === 'assistant_message_committed' && typeof event.text === 'string') {
|
|
1564
|
+
events.push({
|
|
1565
|
+
type: 'message',
|
|
1566
|
+
agent: 'muse',
|
|
1567
|
+
timestamp,
|
|
1568
|
+
role: 'assistant',
|
|
1569
|
+
content: event.text,
|
|
1570
|
+
});
|
|
1571
|
+
continue;
|
|
1572
|
+
}
|
|
1573
|
+
if (event?.kind === 'model_completed' && event.usage && typeof event.usage === 'object') {
|
|
1574
|
+
const u = event.usage;
|
|
1575
|
+
events.push({
|
|
1576
|
+
type: 'usage',
|
|
1577
|
+
agent: 'muse',
|
|
1578
|
+
timestamp,
|
|
1579
|
+
content: JSON.stringify({
|
|
1580
|
+
input_tokens: u.input_tokens ?? 0,
|
|
1581
|
+
output_tokens: u.output_tokens ?? 0,
|
|
1582
|
+
cached_tokens: u.cached_tokens ?? 0,
|
|
1583
|
+
reasoning_tokens: u.reasoning_tokens ?? 0,
|
|
1584
|
+
}),
|
|
1585
|
+
});
|
|
1586
|
+
continue;
|
|
1587
|
+
}
|
|
1588
|
+
// Tool call / result shapes when Muse records them under task events
|
|
1589
|
+
if (event?.kind === 'tool_call' || event?.kind === 'tool.called') {
|
|
1590
|
+
const tool = event.tool || event.name || event.tool_name;
|
|
1591
|
+
if (typeof tool === 'string') {
|
|
1592
|
+
events.push({
|
|
1593
|
+
type: 'tool_use',
|
|
1594
|
+
agent: 'muse',
|
|
1595
|
+
timestamp,
|
|
1596
|
+
tool,
|
|
1597
|
+
args: typeof event.args === 'object' ? event.args : undefined,
|
|
1598
|
+
callId: typeof event.call_id === 'string' ? event.call_id : undefined,
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return events;
|
|
1604
|
+
}
|
|
1507
1605
|
/** Parse a Hermes session JSON file into normalized events. */
|
|
1508
1606
|
export function parseHermes(filePath) {
|
|
1509
1607
|
let session;
|
|
@@ -73,7 +73,16 @@ export declare function isAutomaticSessionPeer(d: DeviceProfile, self: string):
|
|
|
73
73
|
* address. `forwardedArgs` are the caller's own sessions args (query + filters,
|
|
74
74
|
* already `--json`) so every peer returns the same slice this machine asked for.
|
|
75
75
|
*/
|
|
76
|
-
export
|
|
76
|
+
export interface GatherRemoteListOptions {
|
|
77
|
+
/**
|
|
78
|
+
* Opt-in early-exit for a globally-unique id lookup (a full UUID): the first
|
|
79
|
+
* peer to return the matching row resolves the fan-out and cancels the rest.
|
|
80
|
+
* Omitted for browse/label/prefix sweeps, which must wait for every peer to
|
|
81
|
+
* know whether the match is unique or conflicting.
|
|
82
|
+
*/
|
|
83
|
+
isDefinitive?: (session: SessionMeta, machine: string) => boolean;
|
|
84
|
+
}
|
|
85
|
+
export declare function gatherRemoteList(forwardedArgs: string[], hosts?: string[], opts?: GatherRemoteListOptions): Promise<RemoteListResult>;
|
|
77
86
|
export interface RemoteToolSearchResult {
|
|
78
87
|
envelopes: Array<{
|
|
79
88
|
machine: string;
|
|
@@ -191,19 +191,13 @@ export function isAutomaticSessionPeer(d, self) {
|
|
|
191
191
|
return false;
|
|
192
192
|
return d.platform === 'windows' || d.platform === 'linux' || d.platform === 'macos';
|
|
193
193
|
}
|
|
194
|
-
|
|
195
|
-
* Gather listing sessions from other machines. With an explicit `hosts` list
|
|
196
|
-
* (from `--host`), fan out to exactly those. Otherwise sweep the registered,
|
|
197
|
-
* online devices from `ag devices`, excluding this machine and any without an
|
|
198
|
-
* address. `forwardedArgs` are the caller's own sessions args (query + filters,
|
|
199
|
-
* already `--json`) so every peer returns the same slice this machine asked for.
|
|
200
|
-
*/
|
|
201
|
-
export async function gatherRemoteList(forwardedArgs, hosts) {
|
|
194
|
+
export async function gatherRemoteList(forwardedArgs, hosts, opts) {
|
|
202
195
|
const safeResolver = forwardedArgs.includes('--resolve-safe-v1');
|
|
203
196
|
const result = await gatherRemoteAgentsJson({
|
|
204
197
|
args: forwardedArgs,
|
|
205
198
|
noFanoutEnv: NO_FANOUT_ENV,
|
|
206
199
|
hosts,
|
|
200
|
+
earlyExit: opts?.isDefinitive ? { isDefinitive: opts.isDefinitive } : undefined,
|
|
207
201
|
parse: (stdout, machine) => parseRemoteListPayload(stdout, machine, safeResolver),
|
|
208
202
|
});
|
|
209
203
|
return {
|
|
@@ -54,20 +54,74 @@ export declare function classifySshFailure(res: {
|
|
|
54
54
|
error?: Error | null;
|
|
55
55
|
status: number | null;
|
|
56
56
|
}): SshOutcome;
|
|
57
|
+
/**
|
|
58
|
+
* How long a successful remote fetch may be served without re-SSHing.
|
|
59
|
+
* Short on purpose: session listings must stay near-live (RUSH-2062). Match the
|
|
60
|
+
* active-session snapshot window so surfaces share one freshness model.
|
|
61
|
+
*/
|
|
62
|
+
export declare const REMOTE_CACHE_MAX_AGE_MS = 15000;
|
|
57
63
|
/**
|
|
58
64
|
* Deterministic cache path for a (host, forwarded-args) pair. The forwarded args
|
|
59
65
|
* are hashed so distinct queries cache independently; the host stays readable in
|
|
60
66
|
* the filename (sanitised so `user@host` and aliases are filesystem-safe).
|
|
61
67
|
*/
|
|
62
68
|
export declare function remoteCachePath(host: string, forwardedArgs: string[]): string;
|
|
69
|
+
/**
|
|
70
|
+
* Pure freshness check for a remote-sessions cache entry. A reachable host
|
|
71
|
+
* skips SSH only while this returns true; unreachable fallback ignores age.
|
|
72
|
+
*/
|
|
73
|
+
export declare function isRemoteCacheFresh(mtimeMs: number, nowMs: number, maxAgeMs?: number): boolean;
|
|
74
|
+
export interface RemoteCacheHit {
|
|
75
|
+
output: string;
|
|
76
|
+
mtimeMs: number;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Read a cached remote fetch. When `maxAgeMs` is set, returns null if the
|
|
80
|
+
* entry is older than the window (cache-first path for reachable hosts).
|
|
81
|
+
* Omit `maxAgeMs` to accept any age (unreachable fallback).
|
|
82
|
+
*/
|
|
83
|
+
export declare function readRemoteCache(host: string, forwardedArgs: string[], opts?: {
|
|
84
|
+
maxAgeMs?: number;
|
|
85
|
+
nowMs?: number;
|
|
86
|
+
}): RemoteCacheHit | null;
|
|
63
87
|
/** Banner shown above replayed cache rows when the peer is offline. */
|
|
64
88
|
export declare function formatStaleBanner(host: string, mtimeMs: number): string;
|
|
65
89
|
/** Message shown when a host is unreachable and there is no cache to fall back to. */
|
|
66
90
|
export declare function formatUnreachable(host: string): string;
|
|
91
|
+
/** Persist a successful fetch for later cache-first / offline replay.
|
|
92
|
+
* Best-effort: a cache write must never break the live query. Exported for tests. */
|
|
93
|
+
export declare function writeRemoteCache(host: string, forwardedArgs: string[], output: string): void;
|
|
94
|
+
/**
|
|
95
|
+
* Serve a *fresh* cache entry for a reachable-host skip (no banner — the data
|
|
96
|
+
* is still within the freshness window). Returns false when missing/stale so
|
|
97
|
+
* the caller SSHes. RUSH-2062: without this, a reachable host never skipped SSH
|
|
98
|
+
* even when the cache was just written.
|
|
99
|
+
*/
|
|
100
|
+
export declare function serveWarmRemoteCache(host: string, forwardedArgs: string[], opts?: {
|
|
101
|
+
maxAgeMs?: number;
|
|
102
|
+
nowMs?: number;
|
|
103
|
+
}): boolean;
|
|
104
|
+
/** Replay a cached fetch for an unreachable host (any age). Banner goes to
|
|
105
|
+
* stderr (so a piped stdout stays exactly the cached rows); returns false when
|
|
106
|
+
* nothing is cached for this exact (host, query). */
|
|
107
|
+
export declare function replayRemoteCache(host: string, forwardedArgs: string[]): boolean;
|
|
108
|
+
export interface RunRemoteSessionsOptions {
|
|
109
|
+
/** Skip warm cache and SSH every host (force-refresh). */
|
|
110
|
+
forceRefresh?: boolean;
|
|
111
|
+
/** Override freshness window for the warm path. */
|
|
112
|
+
maxAgeMs?: number;
|
|
113
|
+
/** Clock (tests). */
|
|
114
|
+
nowMs?: number;
|
|
115
|
+
}
|
|
67
116
|
/**
|
|
68
117
|
* Run the current `agents sessions` invocation on one or more remote machines over
|
|
69
|
-
* SSH, writing each remote's output to the terminal.
|
|
70
|
-
*
|
|
118
|
+
* SSH, writing each remote's output to the terminal.
|
|
119
|
+
*
|
|
120
|
+
* Cache policy (RUSH-2062):
|
|
121
|
+
* - **Default:** serve a fresh cache hit without SSH; SSH only on miss/stale.
|
|
122
|
+
* - **`forceRefresh`:** always SSH, then rewrite the cache.
|
|
123
|
+
* - **Unreachable:** fall back to any cached output (with a stale banner).
|
|
124
|
+
*
|
|
71
125
|
* Sets `process.exitCode = 1` if any host could not be answered (live or cached).
|
|
72
126
|
* Reads the invocation from `process.argv` (override via `argv` for testing).
|
|
73
127
|
*
|
|
@@ -75,4 +129,4 @@ export declare function formatUnreachable(host: string): string;
|
|
|
75
129
|
* Session output is small and the remote returns quickly, so buffering is
|
|
76
130
|
* imperceptible; `maxBuffer` is generous for the rare large `--markdown <id>` dump.
|
|
77
131
|
*/
|
|
78
|
-
export declare function runRemoteSessions(hosts: string[], argv?: string[]): void;
|
|
132
|
+
export declare function runRemoteSessions(hosts: string[], argv?: string[], opts?: RunRemoteSessionsOptions): void;
|
|
@@ -9,13 +9,14 @@
|
|
|
9
9
|
* upfront copy, always current, but the peer must be reachable. SSH access is the
|
|
10
10
|
* only auth — if you can `ssh <host>`, you own the box (no identity layer by design).
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Cache-first (RUSH-2062) + offline degradation: every *successful* fetch is
|
|
13
13
|
* cached to `~/.agents/.cache/remote-sessions/`, keyed by host + the exact query.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* A later call with a *fresh* cache serves it without SSH (same daemon-warmed
|
|
15
|
+
* shared-cache shape as `stats-cache.ts`) so a reachable host is not re-probed
|
|
16
|
+
* on every menubar/CLI/watchdog tick. When the host is unreachable, any cache
|
|
17
|
+
* (even stale) is replayed with a clearly labelled "showing cached results"
|
|
18
|
+
* banner. The cache is a byproduct of fetches you already made — freely
|
|
19
|
+
* deletable — so the fetch-don't-replicate model holds.
|
|
19
20
|
*
|
|
20
21
|
* Mirrors the transport already used by `agents secrets export --host`
|
|
21
22
|
* (`src/commands/secrets.ts`): `ssh -o BatchMode=yes <host> bash -lc '<cmd>'`,
|
|
@@ -145,6 +146,12 @@ export function classifySshFailure(res) {
|
|
|
145
146
|
}
|
|
146
147
|
/** Root of the offline-replay cache (`~/.agents/.cache/remote-sessions/`). */
|
|
147
148
|
const REMOTE_CACHE_DIR = join(getCacheDir(), 'remote-sessions');
|
|
149
|
+
/**
|
|
150
|
+
* How long a successful remote fetch may be served without re-SSHing.
|
|
151
|
+
* Short on purpose: session listings must stay near-live (RUSH-2062). Match the
|
|
152
|
+
* active-session snapshot window so surfaces share one freshness model.
|
|
153
|
+
*/
|
|
154
|
+
export const REMOTE_CACHE_MAX_AGE_MS = 15_000;
|
|
148
155
|
/**
|
|
149
156
|
* Deterministic cache path for a (host, forwarded-args) pair. The forwarded args
|
|
150
157
|
* are hashed so distinct queries cache independently; the host stays readable in
|
|
@@ -155,6 +162,37 @@ export function remoteCachePath(host, forwardedArgs) {
|
|
|
155
162
|
const safeHost = host.replace(/[^a-zA-Z0-9._@-]/g, '_');
|
|
156
163
|
return join(REMOTE_CACHE_DIR, `${safeHost}__${hash}.txt`);
|
|
157
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Pure freshness check for a remote-sessions cache entry. A reachable host
|
|
167
|
+
* skips SSH only while this returns true; unreachable fallback ignores age.
|
|
168
|
+
*/
|
|
169
|
+
export function isRemoteCacheFresh(mtimeMs, nowMs, maxAgeMs = REMOTE_CACHE_MAX_AGE_MS) {
|
|
170
|
+
if (!Number.isFinite(mtimeMs) || !Number.isFinite(maxAgeMs) || maxAgeMs < 0)
|
|
171
|
+
return false;
|
|
172
|
+
return nowMs - mtimeMs <= maxAgeMs;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Read a cached remote fetch. When `maxAgeMs` is set, returns null if the
|
|
176
|
+
* entry is older than the window (cache-first path for reachable hosts).
|
|
177
|
+
* Omit `maxAgeMs` to accept any age (unreachable fallback).
|
|
178
|
+
*/
|
|
179
|
+
export function readRemoteCache(host, forwardedArgs, opts = {}) {
|
|
180
|
+
try {
|
|
181
|
+
const p = remoteCachePath(host, forwardedArgs);
|
|
182
|
+
if (!existsSync(p))
|
|
183
|
+
return null;
|
|
184
|
+
const mtimeMs = statSync(p).mtimeMs;
|
|
185
|
+
if (opts.maxAgeMs !== undefined) {
|
|
186
|
+
const now = opts.nowMs ?? Date.now();
|
|
187
|
+
if (!isRemoteCacheFresh(mtimeMs, now, opts.maxAgeMs))
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
return { output: readFileSync(p, 'utf8'), mtimeMs };
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
158
196
|
/** Banner shown above replayed cache rows when the peer is offline. */
|
|
159
197
|
export function formatStaleBanner(host, mtimeMs) {
|
|
160
198
|
const ago = formatRelativeTime(new Date(mtimeMs).toISOString());
|
|
@@ -164,9 +202,9 @@ export function formatStaleBanner(host, mtimeMs) {
|
|
|
164
202
|
export function formatUnreachable(host) {
|
|
165
203
|
return chalk.red(`${host}: unreachable over SSH (asleep, offline, or host key changed?) — ConnectTimeout 10s`);
|
|
166
204
|
}
|
|
167
|
-
/** Persist a successful fetch for later offline replay.
|
|
168
|
-
* write must never break the live query. */
|
|
169
|
-
function writeRemoteCache(host, forwardedArgs, output) {
|
|
205
|
+
/** Persist a successful fetch for later cache-first / offline replay.
|
|
206
|
+
* Best-effort: a cache write must never break the live query. Exported for tests. */
|
|
207
|
+
export function writeRemoteCache(host, forwardedArgs, output) {
|
|
170
208
|
try {
|
|
171
209
|
mkdirSync(REMOTE_CACHE_DIR, { recursive: true });
|
|
172
210
|
writeFileSync(remoteCachePath(host, forwardedArgs), output);
|
|
@@ -175,26 +213,42 @@ function writeRemoteCache(host, forwardedArgs, output) {
|
|
|
175
213
|
// ignore — caching is an optimisation, not a guarantee
|
|
176
214
|
}
|
|
177
215
|
}
|
|
178
|
-
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
catch {
|
|
216
|
+
/**
|
|
217
|
+
* Serve a *fresh* cache entry for a reachable-host skip (no banner — the data
|
|
218
|
+
* is still within the freshness window). Returns false when missing/stale so
|
|
219
|
+
* the caller SSHes. RUSH-2062: without this, a reachable host never skipped SSH
|
|
220
|
+
* even when the cache was just written.
|
|
221
|
+
*/
|
|
222
|
+
export function serveWarmRemoteCache(host, forwardedArgs, opts = {}) {
|
|
223
|
+
const hit = readRemoteCache(host, forwardedArgs, {
|
|
224
|
+
maxAgeMs: opts.maxAgeMs ?? REMOTE_CACHE_MAX_AGE_MS,
|
|
225
|
+
nowMs: opts.nowMs,
|
|
226
|
+
});
|
|
227
|
+
if (!hit)
|
|
191
228
|
return false;
|
|
192
|
-
|
|
229
|
+
process.stdout.write(hit.output);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
/** Replay a cached fetch for an unreachable host (any age). Banner goes to
|
|
233
|
+
* stderr (so a piped stdout stays exactly the cached rows); returns false when
|
|
234
|
+
* nothing is cached for this exact (host, query). */
|
|
235
|
+
export function replayRemoteCache(host, forwardedArgs) {
|
|
236
|
+
const hit = readRemoteCache(host, forwardedArgs); // no maxAge — any age ok
|
|
237
|
+
if (!hit)
|
|
238
|
+
return false;
|
|
239
|
+
process.stderr.write(formatStaleBanner(host, hit.mtimeMs) + '\n');
|
|
240
|
+
process.stdout.write(hit.output);
|
|
241
|
+
return true;
|
|
193
242
|
}
|
|
194
243
|
/**
|
|
195
244
|
* Run the current `agents sessions` invocation on one or more remote machines over
|
|
196
|
-
* SSH, writing each remote's output to the terminal.
|
|
197
|
-
*
|
|
245
|
+
* SSH, writing each remote's output to the terminal.
|
|
246
|
+
*
|
|
247
|
+
* Cache policy (RUSH-2062):
|
|
248
|
+
* - **Default:** serve a fresh cache hit without SSH; SSH only on miss/stale.
|
|
249
|
+
* - **`forceRefresh`:** always SSH, then rewrite the cache.
|
|
250
|
+
* - **Unreachable:** fall back to any cached output (with a stale banner).
|
|
251
|
+
*
|
|
198
252
|
* Sets `process.exitCode = 1` if any host could not be answered (live or cached).
|
|
199
253
|
* Reads the invocation from `process.argv` (override via `argv` for testing).
|
|
200
254
|
*
|
|
@@ -202,16 +256,26 @@ function replayRemoteCache(host, forwardedArgs) {
|
|
|
202
256
|
* Session output is small and the remote returns quickly, so buffering is
|
|
203
257
|
* imperceptible; `maxBuffer` is generous for the rare large `--markdown <id>` dump.
|
|
204
258
|
*/
|
|
205
|
-
export function runRemoteSessions(hosts, argv = process.argv) {
|
|
259
|
+
export function runRemoteSessions(hosts, argv = process.argv, opts = {}) {
|
|
206
260
|
for (const host of hosts)
|
|
207
261
|
assertValidSshTarget(host); // fail fast on any bad target
|
|
208
262
|
const forwarded = ensureWholeIndex(buildForwardedArgs(argv, new Set(hosts)));
|
|
209
263
|
const cols = terminalWidth();
|
|
210
264
|
const multi = hosts.length > 1;
|
|
211
265
|
let failures = 0;
|
|
266
|
+
const forceRefresh = opts.forceRefresh === true
|
|
267
|
+
|| process.env.AGENTS_SESSIONS_FORCE_REFRESH === '1';
|
|
212
268
|
for (const host of hosts) {
|
|
213
269
|
if (multi)
|
|
214
270
|
process.stdout.write(chalk.cyan(`\n── ${host} ──\n`));
|
|
271
|
+
// Cache-first: a warm hit skips SSH entirely so reachable hosts share one
|
|
272
|
+
// snapshot across menubar/CLI/watchdog instead of re-fanning every call.
|
|
273
|
+
if (!forceRefresh && serveWarmRemoteCache(host, forwarded, {
|
|
274
|
+
maxAgeMs: opts.maxAgeMs,
|
|
275
|
+
nowMs: opts.nowMs,
|
|
276
|
+
})) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
215
279
|
// Per-host: a Windows peer needs a PowerShell command, POSIX peers `bash -lc`.
|
|
216
280
|
const remoteCmd = buildRemoteCommand(forwarded, cols, resolveRemoteOsSync(host));
|
|
217
281
|
const res = spawnSync('ssh', [...SSH_OPTS, ...controlOpts(), host, remoteCmd], {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one public command used by UI and lifecycle consumers to reopen a session.
|
|
3
|
+
* `agents resume` owns identity resolution, source-device routing, version/home
|
|
4
|
+
* selection, and harness-specific continuation; callers must not recreate it.
|
|
5
|
+
*/
|
|
6
|
+
export declare function buildCanonicalResumeCommand(sessionId: string): string[];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one public command used by UI and lifecycle consumers to reopen a session.
|
|
3
|
+
* `agents resume` owns identity resolution, source-device routing, version/home
|
|
4
|
+
* selection, and harness-specific continuation; callers must not recreate it.
|
|
5
|
+
*/
|
|
6
|
+
export function buildCanonicalResumeCommand(sessionId) {
|
|
7
|
+
return ['agents', 'resume', sessionId];
|
|
8
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { ActiveSession } from './active.js';
|
|
2
|
+
/**
|
|
3
|
+
* How long a snapshot may be served before a reader re-gathers.
|
|
4
|
+
* Short on purpose: live status (running/idle/waiting) must not go stale.
|
|
5
|
+
* The daemon warm tick uses the same cadence (see {@link SESSION_CACHE_WARM_INTERVAL_MS}).
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_ACTIVE_CACHE_MAX_AGE_MS = 15000;
|
|
8
|
+
/** Daemon warm interval — keep in sync with the setInterval in `lib/daemon.ts`. */
|
|
9
|
+
export declare const SESSION_CACHE_WARM_INTERVAL_MS = 15000;
|
|
10
|
+
/** Kick off the first warm ~25s after daemon start (staggered off other ticks). */
|
|
11
|
+
export declare const SESSION_CACHE_WARM_KICKOFF_MS = 25000;
|
|
12
|
+
/** Snapshot scope: this host only, or a fleet-wide merge written by a reader. */
|
|
13
|
+
export type ActiveCacheScope = 'local' | 'fleet';
|
|
14
|
+
export interface ActiveSessionsSnapshot {
|
|
15
|
+
version: 1;
|
|
16
|
+
scope: ActiveCacheScope;
|
|
17
|
+
/** Epoch ms the sessions array was captured. */
|
|
18
|
+
capturedAt: number;
|
|
19
|
+
sessions: ActiveSession[];
|
|
20
|
+
/** Peer count from the last fleet gather (fleet scope only). */
|
|
21
|
+
remoteDeviceCount?: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Per-session fields that are stable until the transcript changes. Keyed on
|
|
25
|
+
* transcript mtime so a rewrite invalidates them. Live status is intentionally
|
|
26
|
+
* absent — see {@link LIVE_STATUS_KEYS}.
|
|
27
|
+
*/
|
|
28
|
+
export interface ImmutableSessionFields {
|
|
29
|
+
topic?: string;
|
|
30
|
+
label?: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
cwd?: string;
|
|
33
|
+
project?: string | null;
|
|
34
|
+
attachments?: ActiveSession['attachments'];
|
|
35
|
+
startedAtMs?: number;
|
|
36
|
+
version?: string;
|
|
37
|
+
pr?: ActiveSession['pr'];
|
|
38
|
+
worktree?: ActiveSession['worktree'];
|
|
39
|
+
ticket?: ActiveSession['ticket'];
|
|
40
|
+
createdTickets?: string[];
|
|
41
|
+
spawnedTeam?: string;
|
|
42
|
+
sessionFile?: string;
|
|
43
|
+
owner?: string;
|
|
44
|
+
assignedTask?: string;
|
|
45
|
+
kind?: string;
|
|
46
|
+
context?: ActiveSession['context'];
|
|
47
|
+
}
|
|
48
|
+
/** Keys stored in the immutable memo (transcript-stable). */
|
|
49
|
+
export declare const IMMUTABLE_FIELD_KEYS: readonly ["topic", "label", "name", "cwd", "project", "attachments", "startedAtMs", "version", "pr", "worktree", "ticket", "createdTickets", "spawnedTeam", "sessionFile", "owner", "assignedTask", "kind", "context"];
|
|
50
|
+
/**
|
|
51
|
+
* Live / volatile fields that MUST NOT be served from the immutable memo.
|
|
52
|
+
* They either change without a transcript write (pid death, attach state) or
|
|
53
|
+
* are short-window signals (preview, tok/s). The short snapshot TTL is the
|
|
54
|
+
* only cache that may carry them — and only as a whole-row snapshot.
|
|
55
|
+
*/
|
|
56
|
+
export declare const LIVE_STATUS_KEYS: readonly ["status", "activity", "preview", "tokPerSec", "awaitingReason", "question", "todos", "tail", "lastActivityMs", "hostLink", "presence", "pidAlive", "tmuxClients", "windowHeartbeatMs", "provenance", "rateLimited", "plan"];
|
|
57
|
+
/** Test seam: redirect the snapshot file. Returns the previous override. */
|
|
58
|
+
export declare function setActiveSessionsSnapshotPathForTest(p: string | null): string | null;
|
|
59
|
+
/** Test seam: redirect the immutable-memo file. Returns the previous override. */
|
|
60
|
+
export declare function setImmutableMemoPathForTest(p: string | null): string | null;
|
|
61
|
+
/** Read one scope from the snapshot file (best-effort; missing/corrupt → null). */
|
|
62
|
+
export declare function readActiveSessionsCache(scope: ActiveCacheScope): ActiveSessionsSnapshot | null;
|
|
63
|
+
/**
|
|
64
|
+
* Persist a snapshot for one scope (best-effort). Other scopes are preserved
|
|
65
|
+
* so a local warm never drops a fleet snapshot a reader just wrote.
|
|
66
|
+
*/
|
|
67
|
+
export declare function writeActiveSessionsCache(scope: ActiveCacheScope, sessions: ActiveSession[], opts?: {
|
|
68
|
+
capturedAt?: number;
|
|
69
|
+
remoteDeviceCount?: number;
|
|
70
|
+
}): ActiveSessionsSnapshot;
|
|
71
|
+
/**
|
|
72
|
+
* True when a snapshot is still within the freshness window. Pure — the
|
|
73
|
+
* staleness invariant for live status lives here.
|
|
74
|
+
*/
|
|
75
|
+
export declare function isActiveSnapshotFresh(capturedAt: number, nowMs: number, maxAgeMs?: number): boolean;
|
|
76
|
+
/** Pull only the transcript-stable fields from a live row. */
|
|
77
|
+
export declare function pickImmutableFields(s: ActiveSession): ImmutableSessionFields;
|
|
78
|
+
/**
|
|
79
|
+
* Transcript mtime used as the memo key. Prefer {@link ActiveSession.lastActivityMs}
|
|
80
|
+
* (the transcript's last write); fall back to `startedAtMs` when no activity
|
|
81
|
+
* stamp exists. Returns null when neither is known — caller must not memoize.
|
|
82
|
+
*/
|
|
83
|
+
export declare function transcriptMtimeMs(s: ActiveSession): number | null;
|
|
84
|
+
/**
|
|
85
|
+
* Read memoized immutable fields for `(sessionId, mtimeMs)`.
|
|
86
|
+
* Returns null when missing OR when the stored mtime does not match — a
|
|
87
|
+
* transcript rewrite must force re-derivation of topic/label/etc.
|
|
88
|
+
*/
|
|
89
|
+
export declare function readImmutableMemo(sessionId: string, mtimeMs: number): ImmutableSessionFields | null;
|
|
90
|
+
/** Persist immutable fields for `(sessionId, mtimeMs)`. Live keys are stripped. */
|
|
91
|
+
export declare function writeImmutableMemo(sessionId: string, mtimeMs: number, fields: ImmutableSessionFields, nowMs?: number): void;
|
|
92
|
+
/** Drop every live-status key from a field bag (defence in depth). */
|
|
93
|
+
export declare function stripLiveStatusKeys<T extends Record<string, unknown>>(fields: T): T;
|
|
94
|
+
/**
|
|
95
|
+
* True when `fields` contains no live-status key. The invariant the tests pin:
|
|
96
|
+
* the immutable memo never carries status/activity/preview/etc.
|
|
97
|
+
*/
|
|
98
|
+
export declare function assertNoLiveStatusFields(fields: Record<string, unknown>): boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Write immutable memos for every session that has an id + transcript mtime.
|
|
101
|
+
* Called after a live gather so the next gather (when mtime is unchanged) can
|
|
102
|
+
* refill identity fields without re-deriving them.
|
|
103
|
+
*/
|
|
104
|
+
export declare function updateImmutableMemos(sessions: ReadonlyArray<ActiveSession>, nowMs?: number): void;
|
|
105
|
+
/**
|
|
106
|
+
* Fill missing immutable fields on a live row from the memo when the transcript
|
|
107
|
+
* mtime matches. Never overwrites a field the live gather already set, and
|
|
108
|
+
* never copies live-status keys.
|
|
109
|
+
*/
|
|
110
|
+
export declare function applyImmutableMemo(s: ActiveSession): ActiveSession;
|
|
111
|
+
export interface LoadLocalActiveSessionsOptions {
|
|
112
|
+
/** Skip the cache and re-gather (the force-refresh path). */
|
|
113
|
+
forceRefresh?: boolean;
|
|
114
|
+
/** Freshness window; defaults to {@link DEFAULT_ACTIVE_CACHE_MAX_AGE_MS}. */
|
|
115
|
+
maxAgeMs?: number;
|
|
116
|
+
/** Clock (injectable for tests). */
|
|
117
|
+
nowMs?: number;
|
|
118
|
+
/**
|
|
119
|
+
* Live gather. Defaults to `getActiveSessions({ localOnly: true })` so a
|
|
120
|
+
* warm never dials a remote-host teammate (RUSH-2118).
|
|
121
|
+
*/
|
|
122
|
+
gather?: () => Promise<ActiveSession[]>;
|
|
123
|
+
/** Injectable cache IO for tests. */
|
|
124
|
+
readCache?: typeof readActiveSessionsCache;
|
|
125
|
+
writeCache?: typeof writeActiveSessionsCache;
|
|
126
|
+
}
|
|
127
|
+
export interface LoadLocalActiveSessionsResult {
|
|
128
|
+
sessions: ActiveSession[];
|
|
129
|
+
/** True when the row set came from the warm snapshot, not a live gather. */
|
|
130
|
+
servedFromCache: boolean;
|
|
131
|
+
capturedAt: number;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Cache-first load of THIS host's active sessions. Default path serves the
|
|
135
|
+
* daemon-warmed snapshot when fresh; `forceRefresh` or an expired snapshot
|
|
136
|
+
* re-gathers and rewrites the cache.
|
|
137
|
+
*/
|
|
138
|
+
export declare function loadLocalActiveSessions(opts?: LoadLocalActiveSessionsOptions): Promise<LoadLocalActiveSessionsResult>;
|
|
139
|
+
export interface LoadFleetActiveSessionsOptions {
|
|
140
|
+
forceRefresh?: boolean;
|
|
141
|
+
maxAgeMs?: number;
|
|
142
|
+
nowMs?: number;
|
|
143
|
+
/** Live fleet gather (local + remote). Required — this module does not own SSH. */
|
|
144
|
+
gather: () => Promise<{
|
|
145
|
+
sessions: ActiveSession[];
|
|
146
|
+
remoteDeviceCount: number;
|
|
147
|
+
}>;
|
|
148
|
+
readCache?: typeof readActiveSessionsCache;
|
|
149
|
+
writeCache?: typeof writeActiveSessionsCache;
|
|
150
|
+
}
|
|
151
|
+
export interface LoadFleetActiveSessionsResult {
|
|
152
|
+
sessions: ActiveSession[];
|
|
153
|
+
remoteDeviceCount: number;
|
|
154
|
+
servedFromCache: boolean;
|
|
155
|
+
capturedAt: number;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Cache-first load of the fleet-wide active set. A prior gather (or any surface
|
|
159
|
+
* that just paid the SSH cost) leaves a fleet snapshot; subsequent menubar /
|
|
160
|
+
* Factory / CLI / watchdog calls within the freshness window share it.
|
|
161
|
+
*/
|
|
162
|
+
export declare function loadFleetActiveSessions(opts: LoadFleetActiveSessionsOptions): Promise<LoadFleetActiveSessionsResult>;
|
|
163
|
+
/**
|
|
164
|
+
* Daemon warm entry point: live-gather THIS host and write the local snapshot.
|
|
165
|
+
* Never SSHes. Returns the published row count for the daemon log line.
|
|
166
|
+
*/
|
|
167
|
+
export declare function publishLocalActiveSessions(opts?: {
|
|
168
|
+
gather?: () => Promise<ActiveSession[]>;
|
|
169
|
+
nowMs?: number;
|
|
170
|
+
}): Promise<{
|
|
171
|
+
sessions: ActiveSession[];
|
|
172
|
+
capturedAt: number;
|
|
173
|
+
}>;
|