@phnx-labs/agents-cli 1.20.92 → 1.21.0
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 +385 -0
- package/README.md +3 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/beta.js +3 -5
- package/dist/commands/doctor.js +23 -0
- package/dist/commands/events.js +91 -1
- package/dist/commands/exec.js +13 -0
- package/dist/commands/mailboxes.js +39 -1
- package/dist/commands/message.js +12 -1
- package/dist/commands/packages.js +14 -5
- package/dist/commands/projects.d.ts +38 -0
- package/dist/commands/projects.js +355 -48
- package/dist/commands/routines.js +27 -1
- package/dist/commands/rules.js +6 -0
- package/dist/commands/secrets.d.ts +17 -0
- package/dist/commands/secrets.js +198 -7
- package/dist/commands/send.d.ts +14 -12
- package/dist/commands/send.js +105 -35
- package/dist/commands/sessions.d.ts +17 -0
- package/dist/commands/sessions.js +16 -8
- package/dist/commands/setup-browser.js +5 -1
- package/dist/commands/setup-fleet.js +5 -0
- package/dist/commands/setup-preferences.d.ts +53 -0
- package/dist/commands/setup-preferences.js +142 -0
- package/dist/commands/setup.js +8 -9
- package/dist/commands/ssh.js +220 -10
- package/dist/commands/sync.d.ts +2 -2
- package/dist/commands/sync.js +13 -12
- package/dist/commands/view.js +4 -0
- package/dist/commands/watchdog.js +26 -3
- package/dist/index.js +38 -6
- package/dist/lib/activity.d.ts +15 -0
- package/dist/lib/activity.js +26 -2
- package/dist/lib/auto-dispatch.d.ts +6 -1
- package/dist/lib/auto-dispatch.js +7 -2
- package/dist/lib/beta.d.ts +1 -1
- package/dist/lib/beta.js +1 -1
- package/dist/lib/channels/send.d.ts +83 -0
- package/dist/lib/channels/send.js +112 -0
- package/dist/lib/daemon.d.ts +13 -0
- package/dist/lib/daemon.js +80 -60
- package/dist/lib/device-config.d.ts +82 -0
- package/dist/lib/device-config.js +296 -0
- package/dist/lib/event-provenance.d.ts +19 -0
- package/dist/lib/event-provenance.js +48 -0
- package/dist/lib/events-ingest.d.ts +46 -0
- package/dist/lib/events-ingest.js +182 -0
- package/dist/lib/events.d.ts +17 -5
- package/dist/lib/events.js +59 -56
- package/dist/lib/feed.d.ts +1 -1
- package/dist/lib/feed.js +2 -0
- package/dist/lib/git.d.ts +13 -2
- package/dist/lib/git.js +38 -6
- package/dist/lib/linear-project-counts.d.ts +129 -0
- package/dist/lib/linear-project-counts.js +206 -0
- package/dist/lib/linear-projects.d.ts +62 -0
- package/dist/lib/linear-projects.js +144 -0
- package/dist/lib/mailbox-gc.js +30 -7
- package/dist/lib/mailbox.d.ts +14 -1
- package/dist/lib/mailbox.js +35 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +5 -21
- package/dist/lib/menubar/install-menubar.js +35 -44
- package/dist/lib/menubar/notify-desktop.d.ts +17 -2
- package/dist/lib/menubar/notify-desktop.js +8 -2
- package/dist/lib/migrate.js +1 -1
- package/dist/lib/models.js +21 -11
- package/dist/lib/overdue.d.ts +1 -1
- package/dist/lib/overdue.js +54 -19
- package/dist/lib/project-import.d.ts +96 -0
- package/dist/lib/project-import.js +171 -0
- package/dist/lib/project-probe.d.ts +75 -0
- package/dist/lib/project-probe.js +160 -0
- package/dist/lib/project-resources.d.ts +8 -0
- package/dist/lib/project-resources.js +31 -3
- package/dist/lib/project-status.d.ts +32 -1
- package/dist/lib/project-status.js +82 -1
- package/dist/lib/projects.d.ts +6 -0
- package/dist/lib/projects.js +12 -0
- package/dist/lib/remote-agents-json.d.ts +14 -1
- package/dist/lib/remote-agents-json.js +21 -3
- package/dist/lib/routine-notify.d.ts +11 -0
- package/dist/lib/routine-notify.js +22 -0
- package/dist/lib/routines.d.ts +53 -0
- package/dist/lib/routines.js +105 -4
- package/dist/lib/rules/run-sync.d.ts +18 -0
- package/dist/lib/rules/run-sync.js +92 -0
- package/dist/lib/run-notify.js +3 -0
- package/dist/lib/scheduler.js +12 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +53 -10
- package/dist/lib/secrets/index.d.ts +4 -5
- package/dist/lib/secrets/index.js +4 -5
- package/dist/lib/secrets/list-filter.d.ts +20 -5
- package/dist/lib/secrets/list-filter.js +22 -6
- package/dist/lib/secrets/usage-db.d.ts +106 -0
- package/dist/lib/secrets/usage-db.js +236 -0
- package/dist/lib/session/active.d.ts +16 -2
- package/dist/lib/session/active.js +8 -4
- package/dist/lib/session/bundle.js +5 -1
- package/dist/lib/session/remote-active.d.ts +5 -1
- package/dist/lib/session/remote-active.js +4 -1
- package/dist/lib/session/remote-list.d.ts +1 -15
- package/dist/lib/session/remote-list.js +22 -111
- package/dist/lib/session/sync/config.d.ts +13 -5
- package/dist/lib/session/sync/config.js +21 -13
- package/dist/lib/sqlite.js +28 -1
- package/dist/lib/startup/command-registry.d.ts +0 -2
- package/dist/lib/startup/command-registry.js +1 -5
- package/dist/lib/state.d.ts +19 -2
- package/dist/lib/state.js +99 -10
- package/dist/lib/sync-umbrella.d.ts +11 -23
- package/dist/lib/sync-umbrella.js +14 -45
- package/dist/lib/teams/agents.d.ts +35 -3
- package/dist/lib/teams/agents.js +52 -8
- package/dist/lib/teams/scheduler.d.ts +29 -2
- package/dist/lib/teams/scheduler.js +60 -16
- package/dist/lib/types.d.ts +22 -5
- package/dist/lib/versions.d.ts +6 -0
- package/dist/lib/versions.js +6 -4
- package/package.json +1 -2
- package/dist/commands/drive.d.ts +0 -10
- package/dist/commands/drive.js +0 -183
- package/dist/commands/hq.d.ts +0 -2
- package/dist/commands/hq.js +0 -58
- package/dist/commands/sessions-sync.d.ts +0 -17
- package/dist/commands/sessions-sync.js +0 -135
- package/dist/commands/sync-provision.d.ts +0 -23
- package/dist/commands/sync-provision.js +0 -107
- package/dist/lib/drive-sync.d.ts +0 -45
- package/dist/lib/drive-sync.js +0 -238
- package/dist/lib/hq/floor.d.ts +0 -87
- package/dist/lib/hq/floor.js +0 -243
- package/dist/lib/session/sync/crdt.d.ts +0 -44
- package/dist/lib/session/sync/crdt.js +0 -119
- package/dist/lib/session/sync/manifest.d.ts +0 -62
- package/dist/lib/session/sync/manifest.js +0 -100
- package/dist/lib/session/sync/provision.d.ts +0 -49
- package/dist/lib/session/sync/provision.js +0 -91
- package/dist/lib/session/sync/r2.d.ts +0 -32
- package/dist/lib/session/sync/r2.js +0 -121
- package/dist/lib/session/sync/sync.d.ts +0 -106
- package/dist/lib/session/sync/sync.js +0 -374
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite-backed usage read-model for `agents secrets`.
|
|
3
|
+
*
|
|
4
|
+
* A small local database at ~/.agents/secrets/secrets.db that records one
|
|
5
|
+
* value-free row for every secret lifecycle/access event a bundle accrues over
|
|
6
|
+
* its life — created, imported, exported, viewed, accessed (read for injection),
|
|
7
|
+
* unlocked. It is the queryable, per-bundle counterpart to the append-only
|
|
8
|
+
* ~/.agents/events.jsonl audit log: the SAME chokepoint (`emitSecretAudit`,
|
|
9
|
+
* lib/secrets/audit.ts) feeds both, and this store answers "how often / how
|
|
10
|
+
* recently / by whom was THIS bundle used?" without scanning the whole event
|
|
11
|
+
* stream. It is a DERIVED index fed off the real access flow — the way
|
|
12
|
+
* sessions.db indexes session metadata — never a second write path an operation
|
|
13
|
+
* has to remember to call.
|
|
14
|
+
*
|
|
15
|
+
* Contract, mirroring the audit log: NEVER a secret value. Only metadata — the
|
|
16
|
+
* bundle name, the event kind, key counts, the resolving agent/host, a status.
|
|
17
|
+
*
|
|
18
|
+
* Every write is best-effort: a failure here (missing runtime SQLite, a locked
|
|
19
|
+
* db, a read-only fs) is swallowed so usage telemetry can never break secret
|
|
20
|
+
* resolution. Set AGENTS_NO_USAGE_TRACK=1 to disable recording entirely (used by
|
|
21
|
+
* tests and by callers that must stay perfectly silent).
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from 'fs';
|
|
24
|
+
import * as path from 'path';
|
|
25
|
+
import Database from '../sqlite.js';
|
|
26
|
+
import { getSecretsDbPath } from '../state.js';
|
|
27
|
+
/** All event kinds, in the order `view` prints them. */
|
|
28
|
+
export const SECRET_USAGE_EVENTS = [
|
|
29
|
+
'access',
|
|
30
|
+
'unlock',
|
|
31
|
+
'import',
|
|
32
|
+
'export',
|
|
33
|
+
'create',
|
|
34
|
+
'view',
|
|
35
|
+
];
|
|
36
|
+
/** Events older than this are pruned on open so the history table stays bounded. */
|
|
37
|
+
const EVENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
|
|
38
|
+
const SCHEMA = `
|
|
39
|
+
CREATE TABLE IF NOT EXISTS usage_events (
|
|
40
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
|
+
ts TEXT NOT NULL,
|
|
42
|
+
bundle TEXT NOT NULL,
|
|
43
|
+
event TEXT NOT NULL,
|
|
44
|
+
agent TEXT,
|
|
45
|
+
host TEXT,
|
|
46
|
+
source TEXT,
|
|
47
|
+
status TEXT,
|
|
48
|
+
key_count INTEGER
|
|
49
|
+
);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_usage_bundle ON usage_events(bundle);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_usage_bundle_event ON usage_events(bundle, event);
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_events(ts DESC);
|
|
53
|
+
`;
|
|
54
|
+
// Cached handle keyed by the resolved path, so a test that redirects
|
|
55
|
+
// AGENTS_SECRETS_DB to a fresh temp file transparently reopens instead of
|
|
56
|
+
// reusing a stale handle pointed at the previous path.
|
|
57
|
+
let cached = null;
|
|
58
|
+
function emptyEvents() {
|
|
59
|
+
return {
|
|
60
|
+
access: { count: 0, last: null },
|
|
61
|
+
unlock: { count: 0, last: null },
|
|
62
|
+
import: { count: 0, last: null },
|
|
63
|
+
export: { count: 0, last: null },
|
|
64
|
+
create: { count: 0, last: null },
|
|
65
|
+
view: { count: 0, last: null },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Open (creating if needed) the usage DB, returning null on any failure so
|
|
70
|
+
* every caller degrades to a no-op rather than throwing into secret resolution.
|
|
71
|
+
*/
|
|
72
|
+
function open() {
|
|
73
|
+
const dbPath = getSecretsDbPath();
|
|
74
|
+
if (cached && cached.path === dbPath)
|
|
75
|
+
return cached.db;
|
|
76
|
+
if (cached) {
|
|
77
|
+
try {
|
|
78
|
+
cached.db.close();
|
|
79
|
+
}
|
|
80
|
+
catch { /* ignore */ }
|
|
81
|
+
cached = null;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
85
|
+
const db = new Database(dbPath);
|
|
86
|
+
// WAL + a busy timeout so concurrent agent runs writing usage rows don't
|
|
87
|
+
// fail each other under load; every write is still best-effort besides.
|
|
88
|
+
db.pragma('journal_mode = WAL');
|
|
89
|
+
db.pragma('busy_timeout = 2000');
|
|
90
|
+
db.exec(SCHEMA);
|
|
91
|
+
// Bounded retention: this is a usage history for operators, not a compliance
|
|
92
|
+
// log (that is events.jsonl). Prune once per open on a 90-day window.
|
|
93
|
+
try {
|
|
94
|
+
db.prepare(`DELETE FROM usage_events WHERE ts < ?`).run(new Date(Date.now() - EVENT_RETENTION_MS).toISOString());
|
|
95
|
+
}
|
|
96
|
+
catch { /* prune is best-effort */ }
|
|
97
|
+
cached = { path: dbPath, db };
|
|
98
|
+
return db;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Record one usage event. Best-effort and value-free — swallows every error and
|
|
106
|
+
* honors AGENTS_NO_USAGE_TRACK so telemetry never blocks or slows a read. Rows
|
|
107
|
+
* with an empty bundle name are ignored (usage is per-bundle by definition).
|
|
108
|
+
*
|
|
109
|
+
* This is called from ONE place only — `emitSecretAudit` (lib/secrets/audit.ts)
|
|
110
|
+
* — so every recorded event has already been written to the events.jsonl audit
|
|
111
|
+
* log through the same chokepoint. Do not call it from a command handler; emit
|
|
112
|
+
* the audit event instead.
|
|
113
|
+
*/
|
|
114
|
+
export function recordSecretUsage(p) {
|
|
115
|
+
if (process.env.AGENTS_NO_USAGE_TRACK)
|
|
116
|
+
return;
|
|
117
|
+
if (!p.bundle)
|
|
118
|
+
return;
|
|
119
|
+
const db = open();
|
|
120
|
+
if (!db)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
db.prepare(`INSERT INTO usage_events (ts, bundle, event, agent, host, source, status, key_count)
|
|
124
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(new Date().toISOString(), p.bundle, p.event, p.agent ?? null, p.host ?? null, p.source ?? null, p.status ?? 'success', p.keyCount ?? null);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Telemetry must never break secret resolution.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function toSummary(bundle, rows, byAgent) {
|
|
131
|
+
const events = emptyEvents();
|
|
132
|
+
let total = 0;
|
|
133
|
+
let lastUsedAt = null;
|
|
134
|
+
let firstUsedAt = null;
|
|
135
|
+
for (const r of rows) {
|
|
136
|
+
if (r.event in events) {
|
|
137
|
+
const stat = events[r.event];
|
|
138
|
+
stat.count = r.n;
|
|
139
|
+
stat.last = r.last;
|
|
140
|
+
}
|
|
141
|
+
total += r.n;
|
|
142
|
+
if (r.last && (!lastUsedAt || r.last > lastUsedAt))
|
|
143
|
+
lastUsedAt = r.last;
|
|
144
|
+
if (r.first && (!firstUsedAt || r.first < firstUsedAt))
|
|
145
|
+
firstUsedAt = r.first;
|
|
146
|
+
}
|
|
147
|
+
return { bundle, total, events, lastUsedAt, firstUsedAt, byAgent };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Usage summary for one bundle, or undefined when nothing has ever been
|
|
151
|
+
* recorded (or the DB is unavailable). Never throws.
|
|
152
|
+
*/
|
|
153
|
+
export function getBundleUsage(bundle) {
|
|
154
|
+
const db = open();
|
|
155
|
+
if (!db)
|
|
156
|
+
return undefined;
|
|
157
|
+
try {
|
|
158
|
+
const rows = db
|
|
159
|
+
.prepare(`SELECT event, COUNT(*) AS n, MAX(ts) AS last, MIN(ts) AS first
|
|
160
|
+
FROM usage_events WHERE bundle = ? GROUP BY event`)
|
|
161
|
+
.all(bundle);
|
|
162
|
+
if (rows.length === 0)
|
|
163
|
+
return undefined;
|
|
164
|
+
const agents = db
|
|
165
|
+
.prepare(`SELECT agent, COUNT(*) AS n FROM usage_events
|
|
166
|
+
WHERE bundle = ? AND agent IS NOT NULL GROUP BY agent ORDER BY n DESC`)
|
|
167
|
+
.all(bundle);
|
|
168
|
+
return toSummary(bundle, rows, agents.map((a) => ({ agent: a.agent, count: a.n })));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Usage summaries for every bundle that has any recorded event, keyed by bundle
|
|
176
|
+
* name. Powers `secrets list --sort uses|used`. Empty map when the DB is
|
|
177
|
+
* unavailable or has no rows. Never throws.
|
|
178
|
+
*/
|
|
179
|
+
export function getAllBundleUsage() {
|
|
180
|
+
const out = new Map();
|
|
181
|
+
const db = open();
|
|
182
|
+
if (!db)
|
|
183
|
+
return out;
|
|
184
|
+
try {
|
|
185
|
+
const rows = db
|
|
186
|
+
.prepare(`SELECT bundle, event, COUNT(*) AS n, MAX(ts) AS last, MIN(ts) AS first
|
|
187
|
+
FROM usage_events GROUP BY bundle, event`)
|
|
188
|
+
.all();
|
|
189
|
+
const byBundle = new Map();
|
|
190
|
+
for (const r of rows) {
|
|
191
|
+
const list = byBundle.get(r.bundle) ?? [];
|
|
192
|
+
list.push(r);
|
|
193
|
+
byBundle.set(r.bundle, list);
|
|
194
|
+
}
|
|
195
|
+
for (const [bundle, list] of byBundle)
|
|
196
|
+
out.set(bundle, toSummary(bundle, list, []));
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Recent events for the `secrets activity` timeline — one bundle when named,
|
|
205
|
+
* else across all bundles — newest first. Empty when the DB is unavailable.
|
|
206
|
+
* Never throws.
|
|
207
|
+
*/
|
|
208
|
+
export function getUsageHistory(bundle, limit = 20) {
|
|
209
|
+
const db = open();
|
|
210
|
+
if (!db)
|
|
211
|
+
return [];
|
|
212
|
+
try {
|
|
213
|
+
const sql = bundle
|
|
214
|
+
? `SELECT ts, bundle, event, agent, host, source, status, key_count AS keyCount
|
|
215
|
+
FROM usage_events WHERE bundle = ? ORDER BY ts DESC, id DESC LIMIT ?`
|
|
216
|
+
: `SELECT ts, bundle, event, agent, host, source, status, key_count AS keyCount
|
|
217
|
+
FROM usage_events ORDER BY ts DESC, id DESC LIMIT ?`;
|
|
218
|
+
const rows = bundle
|
|
219
|
+
? db.prepare(sql).all(bundle, limit)
|
|
220
|
+
: db.prepare(sql).all(limit);
|
|
221
|
+
return rows;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Close the cached handle. Used by tests between temp-db swaps. */
|
|
228
|
+
export function closeSecretsUsageDb() {
|
|
229
|
+
if (cached) {
|
|
230
|
+
try {
|
|
231
|
+
cached.db.close();
|
|
232
|
+
}
|
|
233
|
+
catch { /* ignore */ }
|
|
234
|
+
cached = null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -265,6 +265,14 @@ export declare function activeStatusFromCloudStatus(status: CloudTaskStatus): Ac
|
|
|
265
265
|
export interface ActiveQueryOptions {
|
|
266
266
|
/** Skip the `ps` scan for ad-hoc headless agents. */
|
|
267
267
|
skipHeadless?: boolean;
|
|
268
|
+
/**
|
|
269
|
+
* A `--local` query: this machine only. Never dial a remote-host teammate
|
|
270
|
+
* (one dispatched via `agents teams add --device`) over ssh — report its
|
|
271
|
+
* last-persisted meta.json state instead of polling it. RUSH-2118: without
|
|
272
|
+
* this, `agents sessions --active --local` still fired one ssh round-trip
|
|
273
|
+
* per remote-host teammate (finished or not) on every call.
|
|
274
|
+
*/
|
|
275
|
+
localOnly?: boolean;
|
|
268
276
|
}
|
|
269
277
|
/**
|
|
270
278
|
* A live process can only borrow an indexed session file if that transcript
|
|
@@ -421,8 +429,14 @@ export declare function computeLiveSignals(kind: string, sessionFile: string | u
|
|
|
421
429
|
* and truncates. Exported for tests.
|
|
422
430
|
*/
|
|
423
431
|
export declare function summarizeMission(prompt: string | null | undefined): string | undefined;
|
|
424
|
-
/**
|
|
425
|
-
|
|
432
|
+
/**
|
|
433
|
+
* Live teams teammates. Reuses AgentManager which already polls PIDs via
|
|
434
|
+
* `kill -0`. `localOnly` (RUSH-2118) skips the ssh round-trip AgentManager
|
|
435
|
+
* would otherwise issue for every distributed (remote-host) teammate.
|
|
436
|
+
*/
|
|
437
|
+
export declare function listTeamsActive(opts?: {
|
|
438
|
+
localOnly?: boolean;
|
|
439
|
+
}): Promise<ActiveSession[]>;
|
|
426
440
|
/** Live editor-terminal agents across every IDE window. */
|
|
427
441
|
export declare function listTerminalsActive(): Promise<ActiveSession[]>;
|
|
428
442
|
/** Cloud tasks still in a non-terminal state. `tasks.db` may not exist; that's fine. */
|
|
@@ -697,9 +697,13 @@ export function summarizeMission(prompt) {
|
|
|
697
697
|
return undefined;
|
|
698
698
|
return cleaned.length > 80 ? `${cleaned.slice(0, 79)}…` : cleaned;
|
|
699
699
|
}
|
|
700
|
-
/**
|
|
701
|
-
|
|
702
|
-
|
|
700
|
+
/**
|
|
701
|
+
* Live teams teammates. Reuses AgentManager which already polls PIDs via
|
|
702
|
+
* `kill -0`. `localOnly` (RUSH-2118) skips the ssh round-trip AgentManager
|
|
703
|
+
* would otherwise issue for every distributed (remote-host) teammate.
|
|
704
|
+
*/
|
|
705
|
+
export async function listTeamsActive(opts = {}) {
|
|
706
|
+
const mgr = new AgentManager(undefined, undefined, undefined, undefined, undefined, opts.localOnly ?? false);
|
|
703
707
|
const running = await mgr.listRunning();
|
|
704
708
|
return running.map((a) => {
|
|
705
709
|
// The teammate's OWN transcript is `remoteSessionId` (captured from its first
|
|
@@ -1438,7 +1442,7 @@ export async function listTmuxAgentSessions() {
|
|
|
1438
1442
|
export async function getActiveSessions(opts = {}) {
|
|
1439
1443
|
const [tmuxAgents, teams, terminals, cloud] = await Promise.all([
|
|
1440
1444
|
listTmuxAgentSessions().catch(() => []),
|
|
1441
|
-
listTeamsActive().catch(() => []),
|
|
1445
|
+
listTeamsActive({ localOnly: opts.localOnly }).catch(() => []),
|
|
1442
1446
|
listTerminalsActive().catch(() => []),
|
|
1443
1447
|
Promise.resolve(listCloudActive()),
|
|
1444
1448
|
]);
|
|
@@ -19,12 +19,16 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import * as fs from 'fs';
|
|
21
21
|
import * as path from 'path';
|
|
22
|
+
import * as crypto from 'crypto';
|
|
22
23
|
import { SYNC_AGENTS, mirrorPath } from './sync/agents.js';
|
|
23
|
-
import { hashContent } from './sync/manifest.js';
|
|
24
24
|
import { redactSecrets } from '../redact.js';
|
|
25
25
|
import { encryptTranscript, decryptTranscriptBody } from './sync/transcript-crypto.js';
|
|
26
26
|
export const BUNDLE_KIND = 'agents-session-bundle';
|
|
27
27
|
export const BUNDLE_VERSION = 1;
|
|
28
|
+
/** SHA-256 of a file body, used for the bundle's dedup/conflict check on import. */
|
|
29
|
+
function hashContent(content) {
|
|
30
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
31
|
+
}
|
|
28
32
|
/** Look up the sync spec for an agent id (undefined → agent not sync-representable). */
|
|
29
33
|
export function specForAgent(agentId) {
|
|
30
34
|
return SYNC_AGENTS.find(s => s.id === agentId);
|
|
@@ -25,5 +25,9 @@ export interface RemoteActiveResult {
|
|
|
25
25
|
* (from `--host`), fan out to exactly those. Otherwise sweep the registered,
|
|
26
26
|
* online devices from `ag devices`, excluding this machine and any without an
|
|
27
27
|
* address. Results from all peers run in parallel and are flattened.
|
|
28
|
+
* `opts.quiet` suppresses the per-device stderr line for callers that report
|
|
29
|
+
* skipped peers once, compactly, themselves.
|
|
28
30
|
*/
|
|
29
|
-
export declare function gatherRemoteActive(hosts?: string[]
|
|
31
|
+
export declare function gatherRemoteActive(hosts?: string[], opts?: {
|
|
32
|
+
quiet?: boolean;
|
|
33
|
+
}): Promise<RemoteActiveResult>;
|
|
@@ -56,13 +56,16 @@ export function parseRemoteActive(stdout, machine) {
|
|
|
56
56
|
* (from `--host`), fan out to exactly those. Otherwise sweep the registered,
|
|
57
57
|
* online devices from `ag devices`, excluding this machine and any without an
|
|
58
58
|
* address. Results from all peers run in parallel and are flattened.
|
|
59
|
+
* `opts.quiet` suppresses the per-device stderr line for callers that report
|
|
60
|
+
* skipped peers once, compactly, themselves.
|
|
59
61
|
*/
|
|
60
|
-
export async function gatherRemoteActive(hosts) {
|
|
62
|
+
export async function gatherRemoteActive(hosts, opts) {
|
|
61
63
|
const result = await gatherRemoteAgentsJson({
|
|
62
64
|
args: ['sessions', '--active', '--json'],
|
|
63
65
|
noFanoutEnv: NO_FANOUT_ENV,
|
|
64
66
|
hosts,
|
|
65
67
|
parse: parseRemoteActive,
|
|
68
|
+
quiet: opts?.quiet,
|
|
66
69
|
});
|
|
67
70
|
return { sessions: result.items, deviceCount: result.deviceCount };
|
|
68
71
|
}
|
|
@@ -1,12 +1,4 @@
|
|
|
1
1
|
import type { SessionMeta } from './types.js';
|
|
2
|
-
/**
|
|
3
|
-
* The command run on each peer: answer for itself, as JSON, without recursing.
|
|
4
|
-
* `forwardedArgs` carry the caller's own query/filters (already including the
|
|
5
|
-
* leading `sessions` and a `--json`) so each peer returns a comparable slice.
|
|
6
|
-
* A Windows peer gets a PowerShell invocation (ssh lands in cmd.exe/PowerShell
|
|
7
|
-
* there, where `bash -lc` is not a command); every other OS keeps `bash -lc`.
|
|
8
|
-
*/
|
|
9
|
-
export declare function remoteListCommand(forwardedArgs: string[], os?: string): string;
|
|
10
2
|
/**
|
|
11
3
|
* Parse a peer's `sessions --json` stdout into `SessionMeta[]`, tagging each
|
|
12
4
|
* with `machine`. Defensive against version skew / partial output: non-JSON or
|
|
@@ -17,15 +9,9 @@ export declare function remoteListCommand(forwardedArgs: string[], os?: string):
|
|
|
17
9
|
*/
|
|
18
10
|
export declare function parseRemoteList(stdout: string, machine: string): SessionMeta[];
|
|
19
11
|
export declare function parseRemoteListPayload(stdout: string, machine: string, safeResolver?: boolean): {
|
|
20
|
-
|
|
12
|
+
items: SessionMeta[];
|
|
21
13
|
valid: boolean;
|
|
22
14
|
};
|
|
23
|
-
/** Convert one completed peer process into the exact aggregation result. This
|
|
24
|
-
* is the production parent/peer seam and is exercised with real child output. */
|
|
25
|
-
export declare function remoteListCaptureResult(code: number | null, stdout: string, machine: string, display: string, safeResolver?: boolean): {
|
|
26
|
-
sessions: SessionMeta[];
|
|
27
|
-
unreachable?: string;
|
|
28
|
-
};
|
|
29
15
|
export interface RemoteListResult {
|
|
30
16
|
sessions: SessionMeta[];
|
|
31
17
|
/** How many peer machines we attempted to reach (drives the empty-fleet tip). */
|
|
@@ -17,32 +17,12 @@ import { spawn } from 'child_process';
|
|
|
17
17
|
import chalk from 'chalk';
|
|
18
18
|
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
19
|
import { sshTargetFor } from '../devices/connect.js';
|
|
20
|
-
import {
|
|
21
|
-
import { loadDevices, isControlDevice, isDialableDevice } from '../devices/registry.js';
|
|
20
|
+
import { loadDevices } from '../devices/registry.js';
|
|
22
21
|
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
23
|
-
import {
|
|
22
|
+
import { gatherRemoteAgentsJson } from '../remote-agents-json.js';
|
|
23
|
+
import { normalizeHost } from './sync/config.js';
|
|
24
24
|
import { NO_FANOUT_ENV } from './remote-active.js';
|
|
25
25
|
import { terminalWidth } from './width.js';
|
|
26
|
-
/** Per-host SSH budget. Slightly above SSH_OPTS' ConnectTimeout=10 so a
|
|
27
|
-
* reachable-but-slow remote still answers before we give up. */
|
|
28
|
-
const REMOTE_TIMEOUT_MS = 12_000;
|
|
29
|
-
/**
|
|
30
|
-
* The command run on each peer: answer for itself, as JSON, without recursing.
|
|
31
|
-
* `forwardedArgs` carry the caller's own query/filters (already including the
|
|
32
|
-
* leading `sessions` and a `--json`) so each peer returns a comparable slice.
|
|
33
|
-
* A Windows peer gets a PowerShell invocation (ssh lands in cmd.exe/PowerShell
|
|
34
|
-
* there, where `bash -lc` is not a command); every other OS keeps `bash -lc`.
|
|
35
|
-
*/
|
|
36
|
-
export function remoteListCommand(forwardedArgs, os) {
|
|
37
|
-
if (remoteShellFor(os) === 'powershell') {
|
|
38
|
-
return buildWindowsAgentsCommand({
|
|
39
|
-
args: forwardedArgs,
|
|
40
|
-
env: { [NO_FANOUT_ENV]: '1' },
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
const inner = [`${NO_FANOUT_ENV}=1`, 'agents', ...forwardedArgs].map((t, i) => i === 0 ? t : shellQuote(t)).join(' ');
|
|
44
|
-
return `bash -lc ${shellQuote(inner)}`;
|
|
45
|
-
}
|
|
46
26
|
/**
|
|
47
27
|
* Parse a peer's `sessions --json` stdout into `SessionMeta[]`, tagging each
|
|
48
28
|
* with `machine`. Defensive against version skew / partial output: non-JSON or
|
|
@@ -83,61 +63,22 @@ export function parseRemoteListPayload(stdout, machine, safeResolver = false) {
|
|
|
83
63
|
parsed = JSON.parse(stdout);
|
|
84
64
|
}
|
|
85
65
|
catch {
|
|
86
|
-
return {
|
|
66
|
+
return { items: [], valid: false };
|
|
87
67
|
}
|
|
88
68
|
if (!Array.isArray(parsed))
|
|
89
|
-
return {
|
|
69
|
+
return { items: [], valid: false };
|
|
90
70
|
const out = [];
|
|
91
71
|
for (const x of parsed) {
|
|
92
72
|
if (!x || typeof x !== 'object' || Array.isArray(x))
|
|
93
|
-
return {
|
|
73
|
+
return { items: [], valid: false };
|
|
94
74
|
if (safeResolver && !isSafeResolverRow(x)) {
|
|
95
|
-
return {
|
|
75
|
+
return { items: [], valid: false };
|
|
96
76
|
}
|
|
97
77
|
// `_remote` marks these as living on the peer's disk (not a local mirror),
|
|
98
78
|
// so the picker routes read/resume back over SSH instead of the local FS.
|
|
99
79
|
out.push({ ...x, machine, _remote: true });
|
|
100
80
|
}
|
|
101
|
-
return {
|
|
102
|
-
}
|
|
103
|
-
/** Convert one completed peer process into the exact aggregation result. This
|
|
104
|
-
* is the production parent/peer seam and is exercised with real child output. */
|
|
105
|
-
export function remoteListCaptureResult(code, stdout, machine, display, safeResolver = false) {
|
|
106
|
-
if (code !== 0)
|
|
107
|
-
return { sessions: [], unreachable: display };
|
|
108
|
-
const parsed = parseRemoteListPayload(stdout, machine, safeResolver);
|
|
109
|
-
return parsed.valid ? { sessions: parsed.sessions } : { sessions: [], unreachable: display };
|
|
110
|
-
}
|
|
111
|
-
/** Run one remote `agents sessions … --json` and capture stdout. Resolves
|
|
112
|
-
* `{ code: null }` on spawn error or timeout (host treated as dead). */
|
|
113
|
-
function sshCapture(target, remoteCmd, timeoutMs) {
|
|
114
|
-
assertValidSshTarget(target);
|
|
115
|
-
return new Promise((resolve) => {
|
|
116
|
-
const args = [...SSH_OPTS, ...controlOpts(), target, remoteCmd];
|
|
117
|
-
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
118
|
-
let stdout = '';
|
|
119
|
-
let settled = false;
|
|
120
|
-
const done = (code) => {
|
|
121
|
-
if (settled)
|
|
122
|
-
return;
|
|
123
|
-
settled = true;
|
|
124
|
-
clearTimeout(timer);
|
|
125
|
-
resolve({ code, stdout });
|
|
126
|
-
};
|
|
127
|
-
const timer = setTimeout(() => { child.kill('SIGKILL'); done(null); }, timeoutMs);
|
|
128
|
-
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
129
|
-
child.on('error', () => done(null));
|
|
130
|
-
child.on('close', (code) => done(code));
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
async function fetchByTarget(target, machine, display, forwardedArgs, os) {
|
|
134
|
-
const { code, stdout } = await sshCapture(target, remoteListCommand(forwardedArgs, os), REMOTE_TIMEOUT_MS);
|
|
135
|
-
const safeResolver = forwardedArgs.includes('--resolve-safe-v1');
|
|
136
|
-
const result = remoteListCaptureResult(code, stdout, machine, display, safeResolver);
|
|
137
|
-
if (result.unreachable) {
|
|
138
|
-
process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
|
|
139
|
-
}
|
|
140
|
-
return result;
|
|
81
|
+
return { items: out, valid: true };
|
|
141
82
|
}
|
|
142
83
|
/**
|
|
143
84
|
* Gather listing sessions from other machines. With an explicit `hosts` list
|
|
@@ -147,51 +88,21 @@ async function fetchByTarget(target, machine, display, forwardedArgs, os) {
|
|
|
147
88
|
* already `--json`) so every peer returns the same slice this machine asked for.
|
|
148
89
|
*/
|
|
149
90
|
export async function gatherRemoteList(forwardedArgs, hosts) {
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
else {
|
|
158
|
-
let reg;
|
|
159
|
-
try {
|
|
160
|
-
reg = await loadDevices();
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
return { sessions: [], deviceCount: 0, unreachable: ['device registry'] };
|
|
164
|
-
}
|
|
165
|
-
for (const d of Object.values(reg)) {
|
|
166
|
-
// Live SSH-probe verdict first, cached tailscale snapshot only as a
|
|
167
|
-
// fallback — see isDialableDevice. A manually-registered device has no
|
|
168
|
-
// tailscale peer entry, so gating on `online` alone hid its sessions.
|
|
169
|
-
if (!isDialableDevice(d))
|
|
170
|
-
continue;
|
|
171
|
-
if (normalizeHost(d.name) === self)
|
|
172
|
-
continue;
|
|
173
|
-
// Control-only devices (a phone/tablet running the cockpit) drive the fleet
|
|
174
|
-
// but never run agents — never dial them, whatever their platform reads as.
|
|
175
|
-
if (isControlDevice(d))
|
|
176
|
-
continue;
|
|
177
|
-
// Only machines that can actually run the CLI. iOS/tablet nodes register as
|
|
178
|
-
// `unknown` platform and can never answer, so skip them rather than burn a
|
|
179
|
-
// full ConnectTimeout on each.
|
|
180
|
-
if (d.platform !== 'windows' && d.platform !== 'linux' && d.platform !== 'macos')
|
|
181
|
-
continue;
|
|
182
|
-
try {
|
|
183
|
-
targets.push({ target: sshTargetFor(d), machine: normalizeHost(d.name), name: d.name, os: d.platform });
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
// No address on the profile — nothing to dial; skip silently.
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name, forwardedArgs, t.os)));
|
|
91
|
+
const safeResolver = forwardedArgs.includes('--resolve-safe-v1');
|
|
92
|
+
const result = await gatherRemoteAgentsJson({
|
|
93
|
+
args: forwardedArgs,
|
|
94
|
+
noFanoutEnv: NO_FANOUT_ENV,
|
|
95
|
+
hosts,
|
|
96
|
+
parse: (stdout, machine) => parseRemoteListPayload(stdout, machine, safeResolver),
|
|
97
|
+
});
|
|
191
98
|
return {
|
|
192
|
-
sessions:
|
|
193
|
-
deviceCount:
|
|
194
|
-
unreachable:
|
|
99
|
+
sessions: result.items,
|
|
100
|
+
deviceCount: result.deviceCount,
|
|
101
|
+
unreachable: [
|
|
102
|
+
...(result.discoveryFailed ? ['device registry'] : []),
|
|
103
|
+
...result.skipped,
|
|
104
|
+
...result.parseFailed,
|
|
105
|
+
],
|
|
195
106
|
};
|
|
196
107
|
}
|
|
197
108
|
/** Resolve a peer's SSH target (and OS) from the device registry by its
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* R2 credential resolution + this machine's stable identity. The only field
|
|
3
|
+
* `agents sessions export --encrypt` / `import` actually consume is the
|
|
4
|
+
* shared `R2_SYNC_ENC_KEY` transcript key (`resolveSyncEncKey`); the other R2
|
|
5
|
+
* fields (account/bucket/access keys) are validated here as a presence gate —
|
|
6
|
+
* no `r2.backups` bundle configured means no shared key, so export/import
|
|
7
|
+
* fall back to an ephemeral one. There is no R2 network client in this
|
|
8
|
+
* codebase today (it moved with the rest of the R2/CRDT background sync this
|
|
9
|
+
* bundle used to back — see git history for `./r2.ts`); a future R2-backed
|
|
10
|
+
* feature (an export destination, a fleet cache) would add one where it's
|
|
11
|
+
* actually wired. Credentials come from the `r2.backups` secrets bundle (OS
|
|
12
|
+
* keychain on macOS, libsecret on Linux) — never from env or disk.
|
|
5
13
|
*/
|
|
6
14
|
/** Secrets bundle holding the R2 credentials. */
|
|
7
15
|
export declare const SYNC_BUNDLE = "r2.backups";
|
|
@@ -16,8 +24,8 @@ export interface R2Config {
|
|
|
16
24
|
* Shared 32-byte key (hex or base64) for client-side transcript encryption,
|
|
17
25
|
* held in the bundle as `R2_SYNC_ENC_KEY`. Optional and deliberately separate
|
|
18
26
|
* from the R2 credentials so rotating the access token never orphans already
|
|
19
|
-
* encrypted
|
|
20
|
-
*
|
|
27
|
+
* encrypted bundles. `agents sessions export --encrypt` prefers this key when
|
|
28
|
+
* present, else mints and prints an ephemeral one — see transcript-crypto.ts.
|
|
21
29
|
*/
|
|
22
30
|
syncEncKey?: string;
|
|
23
31
|
}
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* R2 credential resolution + this machine's stable identity. The only field
|
|
3
|
+
* `agents sessions export --encrypt` / `import` actually consume is the
|
|
4
|
+
* shared `R2_SYNC_ENC_KEY` transcript key (`resolveSyncEncKey`); the other R2
|
|
5
|
+
* fields (account/bucket/access keys) are validated here as a presence gate —
|
|
6
|
+
* no `r2.backups` bundle configured means no shared key, so export/import
|
|
7
|
+
* fall back to an ephemeral one. There is no R2 network client in this
|
|
8
|
+
* codebase today (it moved with the rest of the R2/CRDT background sync this
|
|
9
|
+
* bundle used to back — see git history for `./r2.ts`); a future R2-backed
|
|
10
|
+
* feature (an export destination, a fleet cache) would add one where it's
|
|
11
|
+
* actually wired. Credentials come from the `r2.backups` secrets bundle (OS
|
|
12
|
+
* keychain on macOS, libsecret on Linux) — never from env or disk.
|
|
5
13
|
*/
|
|
6
14
|
import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../../secrets/bundles.js';
|
|
7
15
|
/** Secrets bundle holding the R2 credentials. */
|
|
@@ -12,13 +20,13 @@ export const SYNC_BUNDLE = 'r2.backups';
|
|
|
12
20
|
* without real credentials (no silent fallback).
|
|
13
21
|
*/
|
|
14
22
|
function resolveR2Config() {
|
|
15
|
-
//
|
|
16
|
-
// isHeadlessSecretsContext()
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// and lets any interactive caller of loadR2Config still prompt.
|
|
21
|
-
const { env } = readAndResolveBundleEnv(SYNC_BUNDLE, { caller: '
|
|
23
|
+
// A headless caller (no TTY — e.g. a routine or SSH-dispatched command) must
|
|
24
|
+
// resolve broker-only: isHeadlessSecretsContext() true means a broker miss
|
|
25
|
+
// can never pop an unattended Touch ID sheet on the user's screen (the same
|
|
26
|
+
// broker-only-when-headless rationale the secrets readers use). Using the
|
|
27
|
+
// shared predicate rather than a literal keeps it consistent with the other
|
|
28
|
+
// callers and lets any interactive caller of loadR2Config still prompt.
|
|
29
|
+
const { env } = readAndResolveBundleEnv(SYNC_BUNDLE, { caller: 'session-transport', agentOnly: isHeadlessSecretsContext() });
|
|
22
30
|
const accountId = env.R2_ACCOUNT_ID?.trim();
|
|
23
31
|
const bucket = env.R2_BUCKET_NAME?.trim();
|
|
24
32
|
const accessKeyId = env.R2_ACCESS_KEY_ID?.trim();
|
|
@@ -31,7 +39,7 @@ function resolveR2Config() {
|
|
|
31
39
|
!secretAccessKey && 'R2_SECRET_ACCESS_KEY',
|
|
32
40
|
].filter(Boolean);
|
|
33
41
|
if (missing.length > 0) {
|
|
34
|
-
throw new Error(`
|
|
42
|
+
throw new Error(`Session R2 transport: bundle '${SYNC_BUNDLE}' is missing ${missing.join(', ')}. ` +
|
|
35
43
|
`Add them with: agents secrets add ${SYNC_BUNDLE} <KEY>`);
|
|
36
44
|
}
|
|
37
45
|
return {
|
|
@@ -40,8 +48,8 @@ function resolveR2Config() {
|
|
|
40
48
|
accessKeyId: accessKeyId,
|
|
41
49
|
secretAccessKey: secretAccessKey,
|
|
42
50
|
// Default to the account's R2 endpoint; an explicit R2_ENDPOINT override
|
|
43
|
-
// points
|
|
44
|
-
//
|
|
51
|
+
// points at any S3-compatible store (MinIO, another provider) — which is
|
|
52
|
+
// also how the feature is verified end-to-end without live R2.
|
|
45
53
|
endpoint: env.R2_ENDPOINT?.trim() || `https://${accountId}.r2.cloudflarestorage.com`,
|
|
46
54
|
syncEncKey,
|
|
47
55
|
};
|