@phnx-labs/agents-cli 1.20.27 → 1.20.28
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 +3 -0
- package/dist/commands/doctor.js +57 -4
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +177 -6
- package/dist/commands/hosts.d.ts +11 -0
- package/dist/commands/hosts.js +229 -0
- package/dist/commands/repo.d.ts +29 -0
- package/dist/commands/repo.js +174 -38
- package/dist/commands/secrets.d.ts +2 -7
- package/dist/commands/secrets.js +15 -23
- package/dist/commands/sessions.d.ts +2 -0
- package/dist/commands/sessions.js +7 -24
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +22 -5
- package/dist/commands/view.js +27 -11
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +1 -0
- package/dist/lib/agents.js +44 -4
- package/dist/lib/browser/drivers/ssh.d.ts +47 -2
- package/dist/lib/browser/drivers/ssh.js +113 -24
- package/dist/lib/browser/profiles.js +28 -1
- package/dist/lib/browser/runtime-state.js +28 -8
- package/dist/lib/browser/types.d.ts +10 -1
- package/dist/lib/cli-resources.js +10 -1
- package/dist/lib/doctor-diff.d.ts +12 -0
- package/dist/lib/doctor-diff.js +89 -2
- package/dist/lib/exec.d.ts +27 -0
- package/dist/lib/exec.js +62 -19
- package/dist/lib/hooks.d.ts +17 -0
- package/dist/lib/hooks.js +127 -3
- package/dist/lib/hosts/dispatch.d.ts +26 -0
- package/dist/lib/hosts/dispatch.js +71 -0
- package/dist/lib/hosts/progress.d.ts +21 -0
- package/dist/lib/hosts/progress.js +49 -0
- package/dist/lib/hosts/providers/local.d.ts +17 -0
- package/dist/lib/hosts/providers/local.js +81 -0
- package/dist/lib/hosts/ready.d.ts +37 -0
- package/dist/lib/hosts/ready.js +88 -0
- package/dist/lib/hosts/registry.d.ts +22 -0
- package/dist/lib/hosts/registry.js +65 -0
- package/dist/lib/hosts/ssh-config.d.ts +37 -0
- package/dist/lib/hosts/ssh-config.js +157 -0
- package/dist/lib/hosts/tasks.d.ts +32 -0
- package/dist/lib/hosts/tasks.js +58 -0
- package/dist/lib/hosts/types.d.ts +51 -0
- package/dist/lib/hosts/types.js +21 -0
- package/dist/lib/loop.d.ts +9 -0
- package/dist/lib/loop.js +13 -1
- package/dist/lib/mcp.js +12 -3
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.js +9 -5
- package/dist/lib/platform/exec.d.ts +10 -0
- package/dist/lib/platform/exec.js +17 -0
- package/dist/lib/platform/index.d.ts +1 -0
- package/dist/lib/platform/index.js +1 -0
- package/dist/lib/platform/links.d.ts +15 -0
- package/dist/lib/platform/links.js +42 -0
- package/dist/lib/platform/paths.d.ts +18 -0
- package/dist/lib/platform/paths.js +22 -0
- package/dist/lib/platform/posixpath.d.ts +28 -0
- package/dist/lib/platform/posixpath.js +153 -0
- package/dist/lib/plugins.d.ts +10 -0
- package/dist/lib/plugins.js +1 -1
- package/dist/lib/project-launch.js +6 -3
- package/dist/lib/sandbox.js +5 -2
- package/dist/lib/self-update.js +7 -2
- package/dist/lib/session/db.d.ts +23 -0
- package/dist/lib/session/db.js +76 -1
- package/dist/lib/session/discover.d.ts +26 -0
- package/dist/lib/session/discover.js +75 -4
- package/dist/lib/session/relative-time.d.ts +7 -0
- package/dist/lib/session/relative-time.js +28 -0
- package/dist/lib/session/remote.d.ts +31 -3
- package/dist/lib/session/remote.js +121 -14
- package/dist/lib/ssh-exec.d.ts +45 -0
- package/dist/lib/ssh-exec.js +61 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/types.d.ts +21 -0
- package/dist/lib/versions.d.ts +6 -2
- package/dist/lib/versions.js +8 -4
- package/package.json +1 -1
- package/scripts/postinstall.js +62 -0
package/dist/lib/session/db.js
CHANGED
|
@@ -65,6 +65,7 @@ CREATE INDEX IF NOT EXISTS idx_sessions_timestamp ON sessions(timestamp DESC);
|
|
|
65
65
|
CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd);
|
|
66
66
|
CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent);
|
|
67
67
|
CREATE INDEX IF NOT EXISTS idx_sessions_file_path ON sessions(file_path);
|
|
68
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_short_id ON sessions(short_id);
|
|
68
69
|
|
|
69
70
|
CREATE VIRTUAL TABLE IF NOT EXISTS session_text USING fts5(
|
|
70
71
|
session_id UNINDEXED,
|
|
@@ -558,6 +559,48 @@ export function syncLabels(labelMap) {
|
|
|
558
559
|
txn(updates);
|
|
559
560
|
return updates.length;
|
|
560
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Sync topics (session titles) for a set of sessions, keyed by id. For agents
|
|
564
|
+
* whose human-readable title lives in a side index that updates independently
|
|
565
|
+
* of the transcript (Codex `session_index.jsonl`), the per-file scan can't see
|
|
566
|
+
* a title that lands later. This applies those titles by id, updating both
|
|
567
|
+
* `sessions.topic` and the FTS5 topic column. Only ever sets a non-empty title
|
|
568
|
+
* and only when it differs from the stored value — cheap to call every run.
|
|
569
|
+
* Returns the number of rows updated.
|
|
570
|
+
*/
|
|
571
|
+
export function syncTopics(topicMap) {
|
|
572
|
+
if (topicMap.size === 0)
|
|
573
|
+
return 0;
|
|
574
|
+
const db = getDB();
|
|
575
|
+
const ids = [...topicMap.keys()];
|
|
576
|
+
const CHUNK = 500;
|
|
577
|
+
const updates = [];
|
|
578
|
+
for (let i = 0; i < ids.length; i += CHUNK) {
|
|
579
|
+
const chunk = ids.slice(i, i + CHUNK);
|
|
580
|
+
const placeholders = chunk.map(() => '?').join(',');
|
|
581
|
+
const rows = db
|
|
582
|
+
.prepare(`SELECT id, topic FROM sessions WHERE id IN (${placeholders})`)
|
|
583
|
+
.all(...chunk);
|
|
584
|
+
for (const row of rows) {
|
|
585
|
+
const live = topicMap.get(row.id) ?? '';
|
|
586
|
+
if (live && live !== (row.topic ?? '')) {
|
|
587
|
+
updates.push({ id: row.id, topic: live });
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (updates.length === 0)
|
|
592
|
+
return 0;
|
|
593
|
+
const updSessions = db.prepare(`UPDATE sessions SET topic = ? WHERE id = ?`);
|
|
594
|
+
const updFts = db.prepare(`UPDATE session_text SET topic = ? WHERE session_id = ?`);
|
|
595
|
+
const txn = db.transaction((items) => {
|
|
596
|
+
for (const { id, topic } of items) {
|
|
597
|
+
updSessions.run(topic, id);
|
|
598
|
+
updFts.run(topic, id);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
txn(updates);
|
|
602
|
+
return updates.length;
|
|
603
|
+
}
|
|
561
604
|
/** Convert a raw database row into a SessionMeta object. */
|
|
562
605
|
function rowToMeta(row) {
|
|
563
606
|
return {
|
|
@@ -601,13 +644,28 @@ function buildSessionWhere(options) {
|
|
|
601
644
|
params.push(options.cwd);
|
|
602
645
|
}
|
|
603
646
|
if (options.cwdPrefix) {
|
|
647
|
+
// Stored cwd uses the host path separator (normalizeCwd → path.resolve), so
|
|
648
|
+
// the subdir wildcard must too — a hardcoded '/' never matches a Windows
|
|
649
|
+
// `C:\a\b` subpath and the listing comes back empty.
|
|
604
650
|
where.push('(cwd = ? OR cwd LIKE ?)');
|
|
605
|
-
params.push(options.cwdPrefix, options.cwdPrefix + '
|
|
651
|
+
params.push(options.cwdPrefix, options.cwdPrefix + path.sep + '%');
|
|
606
652
|
}
|
|
607
653
|
if (options.project) {
|
|
608
654
|
where.push('LOWER(IFNULL(project, \'\')) LIKE ?');
|
|
609
655
|
params.push(`%${options.project.toLowerCase()}%`);
|
|
610
656
|
}
|
|
657
|
+
// id lookup. SQLite's LIKE is case-insensitive for ASCII, so a lowercased
|
|
658
|
+
// pattern matches mixed-case ids; the `=` exact compare adds COLLATE NOCASE
|
|
659
|
+
// for the same reason. short_id carries its own index (idx_sessions_short_id);
|
|
660
|
+
// id is the PRIMARY KEY.
|
|
661
|
+
if (options.idExact) {
|
|
662
|
+
where.push('(id = ? COLLATE NOCASE OR short_id = ? COLLATE NOCASE)');
|
|
663
|
+
params.push(options.idExact, options.idExact);
|
|
664
|
+
}
|
|
665
|
+
if (options.idPrefix) {
|
|
666
|
+
where.push('(id LIKE ? OR short_id LIKE ?)');
|
|
667
|
+
params.push(`${options.idPrefix}%`, `${options.idPrefix}%`);
|
|
668
|
+
}
|
|
611
669
|
if (typeof options.sinceMs === 'number') {
|
|
612
670
|
// Compare as strings; ISO 8601 timestamps sort lexicographically.
|
|
613
671
|
where.push('timestamp >= ?');
|
|
@@ -739,6 +797,23 @@ export function getSessionById(id) {
|
|
|
739
797
|
const row = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id);
|
|
740
798
|
return row ? rowToMeta(row) : null;
|
|
741
799
|
}
|
|
800
|
+
/**
|
|
801
|
+
* Resolve a full-or-partial session id against the index, exact-first then
|
|
802
|
+
* prefix — the DB-backed equivalent of resolveSessionById() that runs over the
|
|
803
|
+
* SQLite table instead of a pre-loaded array. Matches both the full id and the
|
|
804
|
+
* short id. An exact hit short-circuits so a complete id never also drags in its
|
|
805
|
+
* prefix siblings. `scope` narrows by agent / version / project (cwd) so an
|
|
806
|
+
* ambiguous prefix disambiguates against the caller's context.
|
|
807
|
+
*/
|
|
808
|
+
export function findSessionsById(idQuery, scope = {}) {
|
|
809
|
+
const q = idQuery.trim();
|
|
810
|
+
if (!q)
|
|
811
|
+
return [];
|
|
812
|
+
const exact = querySessions({ ...scope, idExact: q });
|
|
813
|
+
if (exact.length > 0)
|
|
814
|
+
return exact;
|
|
815
|
+
return querySessions({ ...scope, idPrefix: q });
|
|
816
|
+
}
|
|
742
817
|
/**
|
|
743
818
|
* Escape a raw user query into a safe FTS5 MATCH expression.
|
|
744
819
|
* Splits on non-word characters, keeps tokens >= 2 chars, and OR-joins
|
|
@@ -36,6 +36,27 @@ export interface ScanProgress {
|
|
|
36
36
|
parsed: number;
|
|
37
37
|
total: number;
|
|
38
38
|
}
|
|
39
|
+
/** Lightweight metadata extracted from a Claude JSONL file during incremental scan. */
|
|
40
|
+
interface ClaudeSessionScan {
|
|
41
|
+
timestamp?: string;
|
|
42
|
+
cwd?: string;
|
|
43
|
+
gitBranch?: string;
|
|
44
|
+
version?: string;
|
|
45
|
+
topic?: string;
|
|
46
|
+
messageCount: number;
|
|
47
|
+
tokenCount?: number;
|
|
48
|
+
/** Total USD cost accumulated from per-(model, direction) token usage. */
|
|
49
|
+
costUsd?: number;
|
|
50
|
+
/** Wall-clock duration in ms between the first and last timestamped event. */
|
|
51
|
+
durationMs?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Value of the JSONL `entrypoint` field on the first event that carries it.
|
|
54
|
+
* 'cli' for real interactive sessions, 'sdk-cli' for team-spawned ones.
|
|
55
|
+
*/
|
|
56
|
+
entrypoint?: string;
|
|
57
|
+
/** Concatenated user message text, ready to hand to FTS5. */
|
|
58
|
+
contentText?: string;
|
|
59
|
+
}
|
|
39
60
|
/**
|
|
40
61
|
* Discover sessions. Scans only files whose (mtime, size) have changed since
|
|
41
62
|
* the last run; everything else is served from the SQLite cache.
|
|
@@ -77,7 +98,12 @@ export declare function getAgentSessionDirs(agent: string, subdir: string): stri
|
|
|
77
98
|
* recent startedAt.
|
|
78
99
|
*/
|
|
79
100
|
export declare function buildClaudeLabelMap(): Map<string, string | null>;
|
|
101
|
+
/** Parse the lines of a Codex `session_index.jsonl` into a session id -> title map. */
|
|
102
|
+
export declare function parseCodexThreadNameIndex(raw: string): Map<string, string>;
|
|
103
|
+
/** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
|
|
104
|
+
export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessionScan>;
|
|
80
105
|
/** Read up to maxLines non-empty lines from the beginning of a file. */
|
|
81
106
|
export declare function readFirstLines(filePath: string, maxLines: number): Promise<string[]>;
|
|
82
107
|
/** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
|
|
83
108
|
export declare function parseTimeFilter(input: string): number;
|
|
109
|
+
export {};
|
|
@@ -21,7 +21,7 @@ import { getConfigSymlinkVersion } from '../shims.js';
|
|
|
21
21
|
import { SESSION_AGENTS } from './types.js';
|
|
22
22
|
import { extractSessionTopic } from './prompt.js';
|
|
23
23
|
import { costOfUsage } from '../pricing/index.js';
|
|
24
|
-
import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
|
|
24
|
+
import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
|
|
25
25
|
const HOME = os.homedir();
|
|
26
26
|
// Versions can live under either repo: the user repo (current canonical
|
|
27
27
|
// location, ~/.agents/.history/versions/) or the system repo (legacy / npm-shipped,
|
|
@@ -486,8 +486,14 @@ async function scanCodexIncremental(onProgress) {
|
|
|
486
486
|
}
|
|
487
487
|
}
|
|
488
488
|
const changed = filterChangedFiles(filePaths);
|
|
489
|
-
|
|
489
|
+
// Codex keeps human-readable titles (`thread_name`) in `session_index.jsonl`,
|
|
490
|
+
// which updates independently of the rollout files — apply them by id on every
|
|
491
|
+
// scan so a title that lands after a session was first indexed still surfaces.
|
|
492
|
+
const titles = readCodexThreadNames();
|
|
493
|
+
if (changed.length === 0) {
|
|
494
|
+
syncTopics(titles);
|
|
490
495
|
return;
|
|
496
|
+
}
|
|
491
497
|
onProgress?.({ agent: 'codex', parsed: 0, total: changed.length });
|
|
492
498
|
const entries = [];
|
|
493
499
|
const touched = [];
|
|
@@ -498,6 +504,10 @@ async function scanCodexIncremental(onProgress) {
|
|
|
498
504
|
const result = await readCodexMeta(filePath, account, currentVersion);
|
|
499
505
|
if (result && !seen.has(result.meta.id)) {
|
|
500
506
|
seen.add(result.meta.id);
|
|
507
|
+
// Prefer the Codex-generated title over the first-prompt fallback.
|
|
508
|
+
const title = titles.get(result.meta.id);
|
|
509
|
+
if (title)
|
|
510
|
+
result.meta.topic = title;
|
|
501
511
|
entries.push({ meta: result.meta, content: result.content, scan });
|
|
502
512
|
}
|
|
503
513
|
else {
|
|
@@ -512,6 +522,48 @@ async function scanCodexIncremental(onProgress) {
|
|
|
512
522
|
}
|
|
513
523
|
upsertSessionsBatch(entries);
|
|
514
524
|
recordScans(touched);
|
|
525
|
+
// Catch sessions whose rollout file was unchanged but gained a title since the
|
|
526
|
+
// last scan (the index changed, the transcript did not).
|
|
527
|
+
syncTopics(titles);
|
|
528
|
+
}
|
|
529
|
+
/** Parse the lines of a Codex `session_index.jsonl` into a session id -> title map. */
|
|
530
|
+
export function parseCodexThreadNameIndex(raw) {
|
|
531
|
+
const titles = new Map();
|
|
532
|
+
for (const line of raw.split('\n')) {
|
|
533
|
+
if (!line.trim())
|
|
534
|
+
continue;
|
|
535
|
+
try {
|
|
536
|
+
const entry = JSON.parse(line);
|
|
537
|
+
const id = typeof entry.id === 'string' ? entry.id : '';
|
|
538
|
+
const name = typeof entry.thread_name === 'string' ? entry.thread_name.trim() : '';
|
|
539
|
+
if (id && name)
|
|
540
|
+
titles.set(id, name);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
// skip malformed line
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return titles;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Read Codex session titles across every Codex home (live + versioned). The
|
|
550
|
+
* `session_index.jsonl` file sits beside each `sessions/` rollout tree.
|
|
551
|
+
*/
|
|
552
|
+
function readCodexThreadNames() {
|
|
553
|
+
const titles = new Map();
|
|
554
|
+
for (const sessionsDir of getAgentSessionDirs('codex', 'sessions')) {
|
|
555
|
+
const indexPath = path.join(path.dirname(sessionsDir), 'session_index.jsonl');
|
|
556
|
+
let raw;
|
|
557
|
+
try {
|
|
558
|
+
raw = fs.readFileSync(indexPath, 'utf-8');
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
continue; // no index in this home
|
|
562
|
+
}
|
|
563
|
+
for (const [id, name] of parseCodexThreadNameIndex(raw))
|
|
564
|
+
titles.set(id, name);
|
|
565
|
+
}
|
|
566
|
+
return titles;
|
|
515
567
|
}
|
|
516
568
|
/** Stream-parse a single Codex JSONL file to extract session metadata. */
|
|
517
569
|
async function readCodexMeta(filePath, account, currentVersion) {
|
|
@@ -1240,7 +1292,7 @@ function extractHermesMessageText(content) {
|
|
|
1240
1292
|
.trim();
|
|
1241
1293
|
}
|
|
1242
1294
|
/** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
|
|
1243
|
-
async function scanClaudeSession(filePath) {
|
|
1295
|
+
export async function scanClaudeSession(filePath) {
|
|
1244
1296
|
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
1245
1297
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
1246
1298
|
let timestamp;
|
|
@@ -1248,6 +1300,10 @@ async function scanClaudeSession(filePath) {
|
|
|
1248
1300
|
let gitBranch;
|
|
1249
1301
|
let version;
|
|
1250
1302
|
let topic;
|
|
1303
|
+
// Explicit session titles: `/rename` writes a `custom-title` event; Claude
|
|
1304
|
+
// auto-generates an `ai-title`. Both can repeat across the file — last wins.
|
|
1305
|
+
let customTitle;
|
|
1306
|
+
let aiTitle;
|
|
1251
1307
|
let entrypoint;
|
|
1252
1308
|
let messageCount = 0;
|
|
1253
1309
|
let tokenCount = 0;
|
|
@@ -1291,6 +1347,18 @@ async function scanClaudeSession(filePath) {
|
|
|
1291
1347
|
gitBranch = parsed.gitBranch || undefined;
|
|
1292
1348
|
version = parsed.version || undefined;
|
|
1293
1349
|
}
|
|
1350
|
+
if (parsed.type === 'custom-title') {
|
|
1351
|
+
const t = typeof parsed.customTitle === 'string' ? parsed.customTitle.trim() : '';
|
|
1352
|
+
if (t)
|
|
1353
|
+
customTitle = t;
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
if (parsed.type === 'ai-title') {
|
|
1357
|
+
const t = typeof parsed.aiTitle === 'string' ? parsed.aiTitle.trim() : '';
|
|
1358
|
+
if (t)
|
|
1359
|
+
aiTitle = t;
|
|
1360
|
+
continue;
|
|
1361
|
+
}
|
|
1294
1362
|
if (parsed.type === 'user') {
|
|
1295
1363
|
const text = extractClaudeUserText(parsed);
|
|
1296
1364
|
if (text) {
|
|
@@ -1344,12 +1412,15 @@ async function scanClaudeSession(filePath) {
|
|
|
1344
1412
|
const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
|
|
1345
1413
|
? lastTsMs - firstTsMs
|
|
1346
1414
|
: undefined;
|
|
1415
|
+
// Prefer an explicit session title (user `/rename` > Claude auto-title) over
|
|
1416
|
+
// the first-prompt topic.
|
|
1417
|
+
const resolvedTopic = customTitle || aiTitle || topic;
|
|
1347
1418
|
return {
|
|
1348
1419
|
timestamp,
|
|
1349
1420
|
cwd,
|
|
1350
1421
|
gitBranch,
|
|
1351
1422
|
version,
|
|
1352
|
-
topic,
|
|
1423
|
+
topic: resolvedTopic,
|
|
1353
1424
|
entrypoint,
|
|
1354
1425
|
messageCount,
|
|
1355
1426
|
tokenCount: sawTokenCount ? tokenCount : undefined,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable "time since" formatting for session timestamps. Lives here (not
|
|
3
|
+
* inline in `sessions.ts`) so both the session list renderer and the remote
|
|
4
|
+
* offline-cache banner (`remote.ts`) share one formatter — `sessions.ts` imports
|
|
5
|
+
* `remote.ts`, so a back-import would cycle.
|
|
6
|
+
*/
|
|
7
|
+
export declare function formatRelativeTime(isoTimestamp: string): string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable "time since" formatting for session timestamps. Lives here (not
|
|
3
|
+
* inline in `sessions.ts`) so both the session list renderer and the remote
|
|
4
|
+
* offline-cache banner (`remote.ts`) share one formatter — `sessions.ts` imports
|
|
5
|
+
* `remote.ts`, so a back-import would cycle.
|
|
6
|
+
*/
|
|
7
|
+
export function formatRelativeTime(isoTimestamp) {
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
const then = new Date(isoTimestamp).getTime();
|
|
10
|
+
if (isNaN(then))
|
|
11
|
+
return isoTimestamp;
|
|
12
|
+
const diffMs = now - then;
|
|
13
|
+
const diffMin = Math.floor(diffMs / 60_000);
|
|
14
|
+
const diffHrs = Math.floor(diffMs / 3_600_000);
|
|
15
|
+
const diffDays = Math.floor(diffMs / 86_400_000);
|
|
16
|
+
if (diffMin < 1)
|
|
17
|
+
return 'just now';
|
|
18
|
+
if (diffMin < 60)
|
|
19
|
+
return `${diffMin} min ago`;
|
|
20
|
+
if (diffHrs < 24)
|
|
21
|
+
return `${diffHrs} hour${diffHrs === 1 ? '' : 's'} ago`;
|
|
22
|
+
if (diffDays < 7)
|
|
23
|
+
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
|
24
|
+
// Older: show date
|
|
25
|
+
const d = new Date(then);
|
|
26
|
+
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
27
|
+
return `${months[d.getMonth()]} ${d.getDate()}`;
|
|
28
|
+
}
|
|
@@ -24,10 +24,38 @@ export declare function buildForwardedArgs(argv: string[], hosts?: Set<string>):
|
|
|
24
24
|
* quoted again so it survives `bash -lc <...>`.
|
|
25
25
|
*/
|
|
26
26
|
export declare function buildRemoteCommand(forwardedArgs: string[]): string;
|
|
27
|
+
/** The four outcomes of one `ssh <host> agents sessions …` invocation. */
|
|
28
|
+
export type SshOutcome = 'ok' | 'unreachable' | 'query-failed' | 'spawn-error';
|
|
29
|
+
/**
|
|
30
|
+
* Classify an ssh `spawnSync` result. ssh(1) reserves exit 255 for its own
|
|
31
|
+
* connection-layer failures (host down, timeout, refused, auth, changed host
|
|
32
|
+
* key) — distinct from any other non-zero, which is the remote `agents sessions`
|
|
33
|
+
* exit code forwarded back (the query ran but failed). The two must be handled
|
|
34
|
+
* differently: 255 may fall back to cache, a forwarded failure must surface.
|
|
35
|
+
*/
|
|
36
|
+
export declare function classifySshFailure(res: {
|
|
37
|
+
error?: Error | null;
|
|
38
|
+
status: number | null;
|
|
39
|
+
}): SshOutcome;
|
|
40
|
+
/**
|
|
41
|
+
* Deterministic cache path for a (host, forwarded-args) pair. The forwarded args
|
|
42
|
+
* are hashed so distinct queries cache independently; the host stays readable in
|
|
43
|
+
* the filename (sanitised so `user@host` and aliases are filesystem-safe).
|
|
44
|
+
*/
|
|
45
|
+
export declare function remoteCachePath(host: string, forwardedArgs: string[]): string;
|
|
46
|
+
/** Banner shown above replayed cache rows when the peer is offline. */
|
|
47
|
+
export declare function formatStaleBanner(host: string, mtimeMs: number): string;
|
|
48
|
+
/** Message shown when a host is unreachable and there is no cache to fall back to. */
|
|
49
|
+
export declare function formatUnreachable(host: string): string;
|
|
27
50
|
/**
|
|
28
51
|
* Run the current `agents sessions` invocation on one or more remote machines over
|
|
29
|
-
* SSH,
|
|
30
|
-
*
|
|
31
|
-
* `
|
|
52
|
+
* SSH, writing each remote's output to the terminal. A successful fetch is cached;
|
|
53
|
+
* an unreachable host falls back to that cache (with a stale banner) when present.
|
|
54
|
+
* Sets `process.exitCode = 1` if any host could not be answered (live or cached).
|
|
55
|
+
* Reads the invocation from `process.argv` (override via `argv` for testing).
|
|
56
|
+
*
|
|
57
|
+
* Output is captured rather than `stdio: 'inherit'`-streamed so it can be cached.
|
|
58
|
+
* Session output is small and the remote returns quickly, so buffering is
|
|
59
|
+
* imperceptible; `maxBuffer` is generous for the rare large `--markdown <id>` dump.
|
|
32
60
|
*/
|
|
33
61
|
export declare function runRemoteSessions(hosts: string[], argv?: string[]): void;
|
|
@@ -9,12 +9,25 @@
|
|
|
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
|
+
* Offline degradation (no sync, still fetch-first): every *successful* fetch is
|
|
13
|
+
* cached to `~/.agents/.cache/remote-sessions/`, keyed by host + the exact query.
|
|
14
|
+
* When a later run finds the host unreachable, the cache is replayed with a clearly
|
|
15
|
+
* labelled "showing cached results" banner instead of returning nothing. The cache
|
|
16
|
+
* is a byproduct of fetches you already made — never a background job, freely
|
|
17
|
+
* deletable — so the fetch-don't-replicate model holds; this is just graceful
|
|
18
|
+
* degradation when the peer is asleep.
|
|
19
|
+
*
|
|
20
|
+
* Mirrors the transport already used by `agents secrets export --host`
|
|
13
21
|
* (`src/commands/secrets.ts`): `ssh -o BatchMode=yes <host> bash -lc '<cmd>'`,
|
|
14
22
|
* with `bash -lc` so the remote login PATH resolves `agents`.
|
|
15
23
|
*/
|
|
16
24
|
import { spawnSync } from 'child_process';
|
|
25
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from 'fs';
|
|
26
|
+
import { join } from 'path';
|
|
27
|
+
import { createHash } from 'crypto';
|
|
17
28
|
import chalk from 'chalk';
|
|
29
|
+
import { getCacheDir } from '../state.js';
|
|
30
|
+
import { formatRelativeTime } from './relative-time.js';
|
|
18
31
|
/**
|
|
19
32
|
* SSH target: a bare ssh-config host alias (e.g. `yosemite-s1`) or `user@host`.
|
|
20
33
|
* The strict allowlist blocks shell metacharacters and a leading `-`, so a target
|
|
@@ -83,30 +96,124 @@ const SSH_OPTS = [
|
|
|
83
96
|
'-o', 'StrictHostKeyChecking=accept-new',
|
|
84
97
|
'-o', 'ConnectTimeout=10',
|
|
85
98
|
];
|
|
99
|
+
/**
|
|
100
|
+
* Classify an ssh `spawnSync` result. ssh(1) reserves exit 255 for its own
|
|
101
|
+
* connection-layer failures (host down, timeout, refused, auth, changed host
|
|
102
|
+
* key) — distinct from any other non-zero, which is the remote `agents sessions`
|
|
103
|
+
* exit code forwarded back (the query ran but failed). The two must be handled
|
|
104
|
+
* differently: 255 may fall back to cache, a forwarded failure must surface.
|
|
105
|
+
*/
|
|
106
|
+
export function classifySshFailure(res) {
|
|
107
|
+
if (res.error)
|
|
108
|
+
return 'spawn-error';
|
|
109
|
+
if (res.status === 0)
|
|
110
|
+
return 'ok';
|
|
111
|
+
if (res.status === 255)
|
|
112
|
+
return 'unreachable';
|
|
113
|
+
return 'query-failed';
|
|
114
|
+
}
|
|
115
|
+
/** Root of the offline-replay cache (`~/.agents/.cache/remote-sessions/`). */
|
|
116
|
+
const REMOTE_CACHE_DIR = join(getCacheDir(), 'remote-sessions');
|
|
117
|
+
/**
|
|
118
|
+
* Deterministic cache path for a (host, forwarded-args) pair. The forwarded args
|
|
119
|
+
* are hashed so distinct queries cache independently; the host stays readable in
|
|
120
|
+
* the filename (sanitised so `user@host` and aliases are filesystem-safe).
|
|
121
|
+
*/
|
|
122
|
+
export function remoteCachePath(host, forwardedArgs) {
|
|
123
|
+
const hash = createHash('sha256').update(forwardedArgs.join('\u0000')).digest('hex').slice(0, 16);
|
|
124
|
+
const safeHost = host.replace(/[^a-zA-Z0-9._@-]/g, '_');
|
|
125
|
+
return join(REMOTE_CACHE_DIR, `${safeHost}__${hash}.txt`);
|
|
126
|
+
}
|
|
127
|
+
/** Banner shown above replayed cache rows when the peer is offline. */
|
|
128
|
+
export function formatStaleBanner(host, mtimeMs) {
|
|
129
|
+
const ago = formatRelativeTime(new Date(mtimeMs).toISOString());
|
|
130
|
+
return chalk.yellow(`${host}: offline — showing cached results from ${ago}`);
|
|
131
|
+
}
|
|
132
|
+
/** Message shown when a host is unreachable and there is no cache to fall back to. */
|
|
133
|
+
export function formatUnreachable(host) {
|
|
134
|
+
return chalk.red(`${host}: unreachable over SSH (asleep, offline, or host key changed?) — ConnectTimeout 10s`);
|
|
135
|
+
}
|
|
136
|
+
/** Persist a successful fetch for later offline replay. Best-effort: a cache
|
|
137
|
+
* write must never break the live query. */
|
|
138
|
+
function writeRemoteCache(host, forwardedArgs, output) {
|
|
139
|
+
try {
|
|
140
|
+
mkdirSync(REMOTE_CACHE_DIR, { recursive: true });
|
|
141
|
+
writeFileSync(remoteCachePath(host, forwardedArgs), output);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// ignore — caching is an optimisation, not a guarantee
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Replay a cached fetch for an unreachable host. Banner goes to stderr (so a
|
|
148
|
+
* piped stdout stays exactly the cached rows); returns false when nothing is
|
|
149
|
+
* cached for this exact (host, query). */
|
|
150
|
+
function replayRemoteCache(host, forwardedArgs) {
|
|
151
|
+
try {
|
|
152
|
+
const p = remoteCachePath(host, forwardedArgs);
|
|
153
|
+
if (!existsSync(p))
|
|
154
|
+
return false;
|
|
155
|
+
process.stderr.write(formatStaleBanner(host, statSync(p).mtimeMs) + '\n');
|
|
156
|
+
process.stdout.write(readFileSync(p, 'utf8'));
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
86
163
|
/**
|
|
87
164
|
* Run the current `agents sessions` invocation on one or more remote machines over
|
|
88
|
-
* SSH,
|
|
89
|
-
*
|
|
90
|
-
* `
|
|
165
|
+
* SSH, writing each remote's output to the terminal. A successful fetch is cached;
|
|
166
|
+
* an unreachable host falls back to that cache (with a stale banner) when present.
|
|
167
|
+
* Sets `process.exitCode = 1` if any host could not be answered (live or cached).
|
|
168
|
+
* Reads the invocation from `process.argv` (override via `argv` for testing).
|
|
169
|
+
*
|
|
170
|
+
* Output is captured rather than `stdio: 'inherit'`-streamed so it can be cached.
|
|
171
|
+
* Session output is small and the remote returns quickly, so buffering is
|
|
172
|
+
* imperceptible; `maxBuffer` is generous for the rare large `--markdown <id>` dump.
|
|
91
173
|
*/
|
|
92
174
|
export function runRemoteSessions(hosts, argv = process.argv) {
|
|
93
175
|
for (const host of hosts)
|
|
94
176
|
assertValidSshTarget(host); // fail fast on any bad target
|
|
95
|
-
const
|
|
177
|
+
const forwarded = buildForwardedArgs(argv, new Set(hosts));
|
|
178
|
+
const remoteCmd = buildRemoteCommand(forwarded);
|
|
96
179
|
const multi = hosts.length > 1;
|
|
97
180
|
let failures = 0;
|
|
98
181
|
for (const host of hosts) {
|
|
99
182
|
if (multi)
|
|
100
183
|
process.stdout.write(chalk.cyan(`\n── ${host} ──\n`));
|
|
101
|
-
const res = spawnSync('ssh', [...SSH_OPTS, host, remoteCmd], {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
184
|
+
const res = spawnSync('ssh', [...SSH_OPTS, host, remoteCmd], {
|
|
185
|
+
encoding: 'utf8',
|
|
186
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
187
|
+
});
|
|
188
|
+
switch (classifySshFailure(res)) {
|
|
189
|
+
case 'ok':
|
|
190
|
+
process.stdout.write(res.stdout ?? '');
|
|
191
|
+
if (res.stderr)
|
|
192
|
+
process.stderr.write(res.stderr);
|
|
193
|
+
writeRemoteCache(host, forwarded, res.stdout ?? '');
|
|
194
|
+
break;
|
|
195
|
+
case 'unreachable':
|
|
196
|
+
// Served-from-cache counts as answered (degraded, but with data + a clear
|
|
197
|
+
// banner), so it does not increment failures. No cache → a real failure.
|
|
198
|
+
if (!replayRemoteCache(host, forwarded)) {
|
|
199
|
+
failures++;
|
|
200
|
+
console.error(formatUnreachable(host));
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
case 'spawn-error':
|
|
204
|
+
failures++;
|
|
205
|
+
console.error(chalk.red(`${host}: ${res.error?.message ?? 'failed to launch ssh'}`));
|
|
206
|
+
break;
|
|
207
|
+
case 'query-failed':
|
|
208
|
+
// The remote ran but its query exited non-zero — surface its own output
|
|
209
|
+
// and exit code; never mask a genuine error with stale cache.
|
|
210
|
+
failures++;
|
|
211
|
+
if (res.stdout)
|
|
212
|
+
process.stdout.write(res.stdout);
|
|
213
|
+
if (res.stderr)
|
|
214
|
+
process.stderr.write(res.stderr);
|
|
215
|
+
console.error(chalk.red(`${host}: remote query failed (exit ${res.status ?? 'signal'}).`));
|
|
216
|
+
break;
|
|
110
217
|
}
|
|
111
218
|
}
|
|
112
219
|
if (failures > 0)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SSH exec primitive — the single hardened choke point for running a
|
|
3
|
+
* command on a remote host over the system `ssh`.
|
|
4
|
+
*
|
|
5
|
+
* `agents hosts` dispatch and the browser driver both go through here so the
|
|
6
|
+
* connection hardening (`BatchMode`, `accept-new`, `ConnectTimeout`) and the
|
|
7
|
+
* target-injection guard live in exactly one place. Target validation is the
|
|
8
|
+
* canonical definition; `commands/secrets.ts` re-exports it.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* SSH target: a bare ssh-config host alias (e.g. `yosemite-s0`) or `user@host`.
|
|
12
|
+
* The strict allowlist blocks shell metacharacters so a target can't be
|
|
13
|
+
* smuggled in as part of a remote command, and `sshExec` additionally rejects a
|
|
14
|
+
* leading `-` so it can never be parsed as an ssh argv flag.
|
|
15
|
+
*/
|
|
16
|
+
export declare const SSH_TARGET_RE: RegExp;
|
|
17
|
+
export declare function assertValidSshTarget(host: string): void;
|
|
18
|
+
/** POSIX single-quote a string for safe interpolation into a remote shell command. */
|
|
19
|
+
export declare function shellQuote(s: string): string;
|
|
20
|
+
/** Hardened ssh options applied to every connection. */
|
|
21
|
+
export declare const SSH_OPTS: readonly string[];
|
|
22
|
+
export interface SshExecOptions {
|
|
23
|
+
/** Piped to the remote command's stdin (never interpolated into the shell). */
|
|
24
|
+
input?: string;
|
|
25
|
+
/** Kill the ssh process after this many ms. */
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
/** Extra ssh flags inserted before the target (e.g. `-tt`). */
|
|
28
|
+
extraSshArgs?: string[];
|
|
29
|
+
}
|
|
30
|
+
export interface SshExecResult {
|
|
31
|
+
/** Remote exit status, or null if ssh itself failed / timed out. */
|
|
32
|
+
code: number | null;
|
|
33
|
+
stdout: string;
|
|
34
|
+
stderr: string;
|
|
35
|
+
timedOut: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Run `remoteCmd` on `target` over ssh and capture stdout/stderr/exit.
|
|
39
|
+
*
|
|
40
|
+
* `remoteCmd` is passed as a single argv to ssh (the remote login shell parses
|
|
41
|
+
* it); callers that build it from user input must `shellQuote` the pieces.
|
|
42
|
+
*/
|
|
43
|
+
export declare function sshExec(target: string, remoteCmd: string, opts?: SshExecOptions): SshExecResult;
|
|
44
|
+
/** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
|
|
45
|
+
export declare function sshReachable(target: string, timeoutMs?: number): boolean;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SSH exec primitive — the single hardened choke point for running a
|
|
3
|
+
* command on a remote host over the system `ssh`.
|
|
4
|
+
*
|
|
5
|
+
* `agents hosts` dispatch and the browser driver both go through here so the
|
|
6
|
+
* connection hardening (`BatchMode`, `accept-new`, `ConnectTimeout`) and the
|
|
7
|
+
* target-injection guard live in exactly one place. Target validation is the
|
|
8
|
+
* canonical definition; `commands/secrets.ts` re-exports it.
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from 'child_process';
|
|
11
|
+
/**
|
|
12
|
+
* SSH target: a bare ssh-config host alias (e.g. `yosemite-s0`) or `user@host`.
|
|
13
|
+
* The strict allowlist blocks shell metacharacters so a target can't be
|
|
14
|
+
* smuggled in as part of a remote command, and `sshExec` additionally rejects a
|
|
15
|
+
* leading `-` so it can never be parsed as an ssh argv flag.
|
|
16
|
+
*/
|
|
17
|
+
export const SSH_TARGET_RE = /^[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
|
|
18
|
+
export function assertValidSshTarget(host) {
|
|
19
|
+
if (host.startsWith('-') || !SSH_TARGET_RE.test(host)) {
|
|
20
|
+
throw new Error(`Invalid SSH target ${JSON.stringify(host)}. Expected a host alias or user@host (letters, digits, '.', '_', '-').`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** POSIX single-quote a string for safe interpolation into a remote shell command. */
|
|
24
|
+
export function shellQuote(s) {
|
|
25
|
+
if (/^[A-Za-z0-9_./:=@%+-]+$/.test(s))
|
|
26
|
+
return s;
|
|
27
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
28
|
+
}
|
|
29
|
+
/** Hardened ssh options applied to every connection. */
|
|
30
|
+
export const SSH_OPTS = [
|
|
31
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
32
|
+
'-o', 'BatchMode=yes',
|
|
33
|
+
'-o', 'ConnectTimeout=10',
|
|
34
|
+
];
|
|
35
|
+
/**
|
|
36
|
+
* Run `remoteCmd` on `target` over ssh and capture stdout/stderr/exit.
|
|
37
|
+
*
|
|
38
|
+
* `remoteCmd` is passed as a single argv to ssh (the remote login shell parses
|
|
39
|
+
* it); callers that build it from user input must `shellQuote` the pieces.
|
|
40
|
+
*/
|
|
41
|
+
export function sshExec(target, remoteCmd, opts = {}) {
|
|
42
|
+
assertValidSshTarget(target);
|
|
43
|
+
const args = [...SSH_OPTS, ...(opts.extraSshArgs ?? []), target, remoteCmd];
|
|
44
|
+
const res = spawnSync('ssh', args, {
|
|
45
|
+
input: opts.input,
|
|
46
|
+
encoding: 'utf-8',
|
|
47
|
+
timeout: opts.timeoutMs,
|
|
48
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
49
|
+
});
|
|
50
|
+
const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT');
|
|
51
|
+
return {
|
|
52
|
+
code: typeof res.status === 'number' ? res.status : null,
|
|
53
|
+
stdout: res.stdout ?? '',
|
|
54
|
+
stderr: res.stderr ?? '',
|
|
55
|
+
timedOut,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
|
|
59
|
+
export function sshReachable(target, timeoutMs = 10000) {
|
|
60
|
+
return sshExec(target, 'true', { timeoutMs }).code === 0;
|
|
61
|
+
}
|
|
@@ -67,6 +67,7 @@ export declare const loadPty: ModuleLoader;
|
|
|
67
67
|
export declare const loadTmux: ModuleLoader;
|
|
68
68
|
export declare const loadBrowser: ModuleLoader;
|
|
69
69
|
export declare const loadComputer: ModuleLoader;
|
|
70
|
+
export declare const loadHosts: ModuleLoader;
|
|
70
71
|
export declare const loadPull: ModuleLoader;
|
|
71
72
|
export declare const loadPush: ModuleLoader;
|
|
72
73
|
export declare const loadRepo: ModuleLoader;
|