@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +3 -0
  2. package/dist/commands/doctor.js +57 -4
  3. package/dist/commands/exec.d.ts +1 -1
  4. package/dist/commands/exec.js +177 -6
  5. package/dist/commands/hosts.d.ts +11 -0
  6. package/dist/commands/hosts.js +229 -0
  7. package/dist/commands/repo.d.ts +29 -0
  8. package/dist/commands/repo.js +174 -38
  9. package/dist/commands/secrets.d.ts +2 -7
  10. package/dist/commands/secrets.js +15 -23
  11. package/dist/commands/sessions.d.ts +2 -0
  12. package/dist/commands/sessions.js +7 -24
  13. package/dist/commands/sync.d.ts +2 -0
  14. package/dist/commands/sync.js +22 -5
  15. package/dist/commands/view.js +27 -11
  16. package/dist/index.js +2 -1
  17. package/dist/lib/agents.d.ts +1 -0
  18. package/dist/lib/agents.js +44 -4
  19. package/dist/lib/browser/drivers/ssh.d.ts +47 -2
  20. package/dist/lib/browser/drivers/ssh.js +113 -24
  21. package/dist/lib/browser/profiles.js +28 -1
  22. package/dist/lib/browser/runtime-state.js +28 -8
  23. package/dist/lib/browser/types.d.ts +10 -1
  24. package/dist/lib/cli-resources.js +10 -1
  25. package/dist/lib/doctor-diff.d.ts +12 -0
  26. package/dist/lib/doctor-diff.js +89 -2
  27. package/dist/lib/exec.d.ts +27 -0
  28. package/dist/lib/exec.js +62 -19
  29. package/dist/lib/hooks.d.ts +17 -0
  30. package/dist/lib/hooks.js +127 -3
  31. package/dist/lib/hosts/dispatch.d.ts +26 -0
  32. package/dist/lib/hosts/dispatch.js +71 -0
  33. package/dist/lib/hosts/progress.d.ts +21 -0
  34. package/dist/lib/hosts/progress.js +49 -0
  35. package/dist/lib/hosts/providers/local.d.ts +17 -0
  36. package/dist/lib/hosts/providers/local.js +81 -0
  37. package/dist/lib/hosts/ready.d.ts +37 -0
  38. package/dist/lib/hosts/ready.js +88 -0
  39. package/dist/lib/hosts/registry.d.ts +22 -0
  40. package/dist/lib/hosts/registry.js +65 -0
  41. package/dist/lib/hosts/ssh-config.d.ts +37 -0
  42. package/dist/lib/hosts/ssh-config.js +157 -0
  43. package/dist/lib/hosts/tasks.d.ts +32 -0
  44. package/dist/lib/hosts/tasks.js +58 -0
  45. package/dist/lib/hosts/types.d.ts +51 -0
  46. package/dist/lib/hosts/types.js +21 -0
  47. package/dist/lib/loop.d.ts +9 -0
  48. package/dist/lib/loop.js +13 -1
  49. package/dist/lib/mcp.js +12 -3
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  51. package/dist/lib/migrate.js +9 -5
  52. package/dist/lib/platform/exec.d.ts +10 -0
  53. package/dist/lib/platform/exec.js +17 -0
  54. package/dist/lib/platform/index.d.ts +1 -0
  55. package/dist/lib/platform/index.js +1 -0
  56. package/dist/lib/platform/links.d.ts +15 -0
  57. package/dist/lib/platform/links.js +42 -0
  58. package/dist/lib/platform/paths.d.ts +18 -0
  59. package/dist/lib/platform/paths.js +22 -0
  60. package/dist/lib/platform/posixpath.d.ts +28 -0
  61. package/dist/lib/platform/posixpath.js +153 -0
  62. package/dist/lib/plugins.d.ts +10 -0
  63. package/dist/lib/plugins.js +1 -1
  64. package/dist/lib/project-launch.js +6 -3
  65. package/dist/lib/sandbox.js +5 -2
  66. package/dist/lib/self-update.js +7 -2
  67. package/dist/lib/session/db.d.ts +23 -0
  68. package/dist/lib/session/db.js +76 -1
  69. package/dist/lib/session/discover.d.ts +26 -0
  70. package/dist/lib/session/discover.js +75 -4
  71. package/dist/lib/session/relative-time.d.ts +7 -0
  72. package/dist/lib/session/relative-time.js +28 -0
  73. package/dist/lib/session/remote.d.ts +31 -3
  74. package/dist/lib/session/remote.js +121 -14
  75. package/dist/lib/ssh-exec.d.ts +45 -0
  76. package/dist/lib/ssh-exec.js +61 -0
  77. package/dist/lib/startup/command-registry.d.ts +1 -0
  78. package/dist/lib/startup/command-registry.js +2 -0
  79. package/dist/lib/types.d.ts +21 -0
  80. package/dist/lib/versions.d.ts +6 -2
  81. package/dist/lib/versions.js +8 -4
  82. package/package.json +1 -1
  83. package/scripts/postinstall.js +62 -0
@@ -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
- if (changed.length === 0)
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, streaming each remote's output to the terminal. Sets `process.exitCode = 1`
30
- * if any host fails. Reads the invocation from `process.argv` (override via
31
- * `argv` for testing).
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
- * Mirrors the transport already used by `agents secrets export --to-ssh`
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, streaming each remote's output to the terminal. Sets `process.exitCode = 1`
89
- * if any host fails. Reads the invocation from `process.argv` (override via
90
- * `argv` for testing).
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 remoteCmd = buildRemoteCommand(buildForwardedArgs(argv, new Set(hosts)));
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], { stdio: 'inherit' });
102
- if (res.error) {
103
- failures++;
104
- console.error(chalk.red(`${host}: ${res.error.message}`));
105
- continue;
106
- }
107
- if (res.status !== 0) {
108
- failures++;
109
- console.error(chalk.red(`${host}: remote query failed (exit ${res.status ?? 'signal'}).`));
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;