@phnx-labs/agents-cli 1.20.36 → 1.20.37

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.
@@ -13,7 +13,7 @@ import { getSessionsDir, getSessionsDbPath } from '../state.js';
13
13
  const SESSIONS_DIR = getSessionsDir();
14
14
  const DB_PATH = getSessionsDbPath();
15
15
  /** Current schema version; bumped when migrations are added. */
16
- const SCHEMA_VERSION = 7;
16
+ const SCHEMA_VERSION = 8;
17
17
  /**
18
18
  * Canonicalize a file path for use as a scan_ledger key. The same physical
19
19
  * session file is reachable via multiple aliases — `~/.claude/projects/x.jsonl`
@@ -46,6 +46,7 @@ CREATE TABLE IF NOT EXISTS sessions (
46
46
  version TEXT,
47
47
  account TEXT,
48
48
  timestamp TEXT NOT NULL,
49
+ last_activity TEXT,
49
50
  project TEXT,
50
51
  cwd TEXT,
51
52
  git_branch TEXT,
@@ -176,6 +177,17 @@ function migrateSchema(db, fromVersion) {
176
177
  db.exec(`ALTER TABLE sessions ADD COLUMN ticket_id TEXT`);
177
178
  db.exec(`DELETE FROM scan_ledger;`);
178
179
  }
180
+ if (fromVersion < 8) {
181
+ // v7 → v8: the listing now sorts and labels by last-activity (last message
182
+ // time) instead of creation time. Add the column, seed it to `timestamp` so
183
+ // no row sorts as NULL before the rescan lands, then force a full rescan so
184
+ // every session gets its true last_activity (from lastTsMs) repopulated.
185
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
186
+ if (!cols.some(c => c.name === 'last_activity'))
187
+ db.exec(`ALTER TABLE sessions ADD COLUMN last_activity TEXT`);
188
+ db.exec(`UPDATE sessions SET last_activity = timestamp WHERE last_activity IS NULL`);
189
+ db.exec(`DELETE FROM scan_ledger;`);
190
+ }
179
191
  }
180
192
  /** Open (or return the cached) sessions database, applying migrations as needed. */
181
193
  export function getDB() {
@@ -202,6 +214,11 @@ export function getDB() {
202
214
  migrateSchema(db, currentVersion);
203
215
  db.prepare(`INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)`).run(String(SCHEMA_VERSION));
204
216
  }
217
+ // Index last_activity only after the column is guaranteed to exist — fresh DBs
218
+ // get it from CREATE TABLE above, existing pre-v8 DBs from the migration just
219
+ // run. It must NOT live in SCHEMA (executed before migration) or an existing
220
+ // DB would fail the index build on a column it doesn't have yet.
221
+ db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)`);
205
222
  // One-shot cleanup of the pre-SQLite JSONL indexes. Safe — nothing reads
206
223
  // them anymore. Guarded by a meta flag so we only try once.
207
224
  const cleaned = db.prepare(`SELECT value FROM meta WHERE key = 'legacy_indexes_removed'`).get();
@@ -383,13 +400,13 @@ export function recordScans(entries) {
383
400
  }
384
401
  const upsertSessionStmt = (db) => db.prepare(`
385
402
  INSERT INTO sessions (
386
- id, short_id, agent, version, account, timestamp,
403
+ id, short_id, agent, version, account, timestamp, last_activity,
387
404
  project, cwd, git_branch, topic, label, message_count, token_count,
388
405
  cost_usd, duration_ms,
389
406
  file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
390
407
  pr_url, pr_number, worktree_slug, ticket_id
391
408
  ) VALUES (
392
- @id, @short_id, @agent, @version, @account, @timestamp,
409
+ @id, @short_id, @agent, @version, @account, @timestamp, @last_activity,
393
410
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
394
411
  @cost_usd, @duration_ms,
395
412
  @file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
@@ -401,6 +418,7 @@ const upsertSessionStmt = (db) => db.prepare(`
401
418
  version = excluded.version,
402
419
  account = excluded.account,
403
420
  timestamp = excluded.timestamp,
421
+ last_activity = excluded.last_activity,
404
422
  project = excluded.project,
405
423
  cwd = excluded.cwd,
406
424
  git_branch = excluded.git_branch,
@@ -447,6 +465,7 @@ export function upsertSession(meta, content, scan) {
447
465
  version: meta.version ?? null,
448
466
  account: meta.account ?? null,
449
467
  timestamp: meta.timestamp,
468
+ last_activity: resolveLastActivity(meta, scan),
450
469
  project: meta.project ?? null,
451
470
  cwd: meta.cwd ?? null,
452
471
  git_branch: meta.gitBranch ?? null,
@@ -526,6 +545,7 @@ export function upsertSessionsBatch(entries) {
526
545
  version: meta.version ?? null,
527
546
  account: meta.account ?? null,
528
547
  timestamp: meta.timestamp,
548
+ last_activity: resolveLastActivity(meta, scan),
529
549
  project: meta.project ?? null,
530
550
  cwd: meta.cwd ?? null,
531
551
  git_branch: meta.gitBranch ?? null,
@@ -641,6 +661,7 @@ function rowToMeta(row) {
641
661
  shortId: row.short_id,
642
662
  agent: row.agent,
643
663
  timestamp: row.timestamp,
664
+ lastActivity: row.last_activity ?? undefined,
644
665
  project: row.project ?? undefined,
645
666
  cwd: row.cwd ?? undefined,
646
667
  filePath: row.file_path,
@@ -660,6 +681,21 @@ function rowToMeta(row) {
660
681
  ticketId: row.ticket_id ?? undefined,
661
682
  };
662
683
  }
684
+ /**
685
+ * The recency signal used to sort and label the listing: last-message time when
686
+ * a parser computed it (`meta.lastActivity` from `lastTsMs`), else the file's
687
+ * mtime (its last write), else creation time. Guarded on `filePath` so synthetic
688
+ * / cloud rows (no local file) fall to their creation timestamp rather than a
689
+ * bogus scan-time mtime. Always an ISO string, so it sorts lexicographically
690
+ * against `timestamp` and feeds `formatRelativeTime` unchanged.
691
+ */
692
+ function resolveLastActivity(meta, scan) {
693
+ if (meta.lastActivity)
694
+ return meta.lastActivity;
695
+ if (scan?.fileMtimeMs && meta.filePath)
696
+ return new Date(scan.fileMtimeMs).toISOString();
697
+ return meta.timestamp;
698
+ }
663
699
  /**
664
700
  * Newest indexed session file for an agent working in `cwd`. Lets the live
665
701
  * `--active` scanner locate a Codex transcript (whose files are date-partitioned,
@@ -741,7 +777,7 @@ function buildSessionWhere(options) {
741
777
  const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
742
778
  return { clause, params };
743
779
  }
744
- /** Query sessions from the database, applying filters and ordering by timestamp descending. */
780
+ /** Query sessions from the database, applying filters and ordering by last-activity descending (default). */
745
781
  export function querySessions(options = {}) {
746
782
  const db = getDB();
747
783
  const { clause, params } = buildSessionWhere(options);
@@ -757,7 +793,7 @@ export function querySessions(options = {}) {
757
793
  ? 'ORDER BY cost_usd IS NULL, cost_usd DESC, timestamp DESC'
758
794
  : options.sortBy === 'duration'
759
795
  ? 'ORDER BY duration_ms IS NULL, duration_ms DESC, timestamp DESC'
760
- : 'ORDER BY timestamp DESC';
796
+ : 'ORDER BY IFNULL(last_activity, timestamp) DESC, timestamp DESC';
761
797
  const sql = `SELECT * FROM sessions ${clause} ${orderClause} ${limitClause}`;
762
798
  const rows = db.prepare(sql).all(...params);
763
799
  // Belt-and-suspenders: drop rows whose JSONL no longer exists on disk. The
@@ -50,6 +50,8 @@ interface ClaudeSessionScan {
50
50
  costUsd?: number;
51
51
  /** Wall-clock duration in ms between the first and last timestamped event. */
52
52
  durationMs?: number;
53
+ /** ISO time of the last timestamped event — the session's last activity. */
54
+ lastActivity?: string;
53
55
  /**
54
56
  * Value of the JSONL `entrypoint` field on the first event that carries it.
55
57
  * 'cli' for real interactive sessions, 'sdk-cli' for team-spawned ones.
@@ -459,6 +459,7 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
459
459
  shortId: sessionId.slice(0, 8),
460
460
  agent: 'claude',
461
461
  timestamp: scan.timestamp,
462
+ lastActivity: scan.lastActivity,
462
463
  project: cwd ? path.basename(cwd) : undefined,
463
464
  cwd,
464
465
  filePath,
@@ -485,6 +486,7 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
485
486
  shortId: sessionId.slice(0, 8),
486
487
  agent: 'claude',
487
488
  timestamp: stat ? stat.mtime.toISOString() : new Date().toISOString(),
489
+ lastActivity: scan.lastActivity,
488
490
  filePath,
489
491
  account,
490
492
  label,
@@ -693,6 +695,7 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion) {
693
695
  // Codex `session_meta` only carries the start time; use file mtime when
694
696
  // it's newer so long-running sessions register as recently active.
695
697
  timestamp: pickLatestCodexTimestamp(scan.timestamp, filePath),
698
+ lastActivity: scan.lastActivity,
696
699
  project: cwd ? path.basename(cwd) : undefined,
697
700
  cwd,
698
701
  filePath,
@@ -887,6 +890,7 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
887
890
  shortId: sessionId.slice(0, 8),
888
891
  agent: 'gemini',
889
892
  timestamp: startTime || (stat ? stat.mtime.toISOString() : new Date().toISOString()),
893
+ lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
890
894
  project,
891
895
  cwd,
892
896
  filePath,
@@ -1093,6 +1097,7 @@ async function scanOpenCodeIncremental() {
1093
1097
  s.directory,
1094
1098
  s.version,
1095
1099
  s.time_created,
1100
+ s.time_updated,
1096
1101
  COALESCE(stats.message_count, 0),
1097
1102
  stats.token_count,
1098
1103
  COALESCE(stats.has_token_data, 0)
@@ -1125,20 +1130,26 @@ async function scanOpenCodeIncremental() {
1125
1130
  for (const line of out.split('\n')) {
1126
1131
  if (!line.trim())
1127
1132
  continue;
1128
- const [id, title, directory, version, timeCreatedStr, messageCountStr, tokenCountStr, hasTokenDataStr] = line.split('|||');
1133
+ const [id, title, directory, version, timeCreatedStr, timeUpdatedStr, messageCountStr, tokenCountStr, hasTokenDataStr] = line.split('|||');
1129
1134
  if (!id)
1130
1135
  continue;
1131
1136
  const timeCreated = parseInt(timeCreatedStr, 10);
1137
+ const timeUpdated = parseInt(timeUpdatedStr, 10);
1132
1138
  const messageCount = parseInt(messageCountStr, 10);
1133
1139
  const tokenCount = parseInt(tokenCountStr, 10);
1134
1140
  const hasTokenData = hasTokenDataStr === '1';
1135
1141
  const timestamp = isNaN(timeCreated) ? new Date().toISOString() : new Date(timeCreated).toISOString();
1142
+ // OpenCode is one shared DB, not one file per session — its row carries a
1143
+ // per-session updated time. Set lastActivity explicitly (falling back to
1144
+ // creation, never the whole-DB mtime the ScanStamp would otherwise supply).
1145
+ const lastActivity = Number.isNaN(timeUpdated) ? timestamp : new Date(timeUpdated).toISOString();
1136
1146
  const topic = title || undefined;
1137
1147
  const meta = {
1138
1148
  id,
1139
1149
  shortId: id.replace(/^ses_/, '').slice(0, 8),
1140
1150
  agent: 'opencode',
1141
1151
  timestamp,
1152
+ lastActivity,
1142
1153
  project: directory ? path.basename(directory) : undefined,
1143
1154
  cwd: directory ? normalizeCwd(directory) : undefined,
1144
1155
  filePath: `${OPENCODE_DB}#${id}`,
@@ -1571,6 +1582,7 @@ async function readDroidMeta(filePath, currentVersion) {
1571
1582
  shortId: sessionId.slice(0, 8),
1572
1583
  agent: 'droid',
1573
1584
  timestamp: scan.timestamp || (stat ? stat.mtime.toISOString() : new Date().toISOString()),
1585
+ lastActivity: scan.lastActivity,
1574
1586
  project: cwd ? path.basename(cwd) : undefined,
1575
1587
  cwd,
1576
1588
  filePath,
@@ -1692,6 +1704,7 @@ async function scanDroidSession(filePath) {
1692
1704
  model,
1693
1705
  messageCount,
1694
1706
  durationMs,
1707
+ lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
1695
1708
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
1696
1709
  };
1697
1710
  }
@@ -1877,6 +1890,7 @@ export async function scanClaudeSession(filePath) {
1877
1890
  tokenCount: sawTokenCount ? tokenCount : undefined,
1878
1891
  costUsd: sawCost ? costUsd : undefined,
1879
1892
  durationMs,
1893
+ lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
1880
1894
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
1881
1895
  prUrl,
1882
1896
  prNumber,
@@ -2020,6 +2034,7 @@ async function scanCodexSession(filePath) {
2020
2034
  tokenCount,
2021
2035
  costUsd,
2022
2036
  durationMs,
2037
+ lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
2023
2038
  contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
2024
2039
  prUrl,
2025
2040
  prNumber,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Best-effort Ghostty tab-number detection for `agents sessions --active`.
3
+ *
4
+ * Ghostty (macOS) exposes read-only AppleScript: every tab has an `index` and a
5
+ * `name` (title); every surface a `working directory`. It exposes NO per-tab env
6
+ * var and NO tty/pid on a surface — so a session is matched to its tab by
7
+ * `working directory` (with title as a tiebreak). This is display sugar only:
8
+ * one bounded, non-fatal osascript call, run by the renderer, never on the
9
+ * discovery / --json / --waiting path. Any failure (Ghostty not running,
10
+ * Automation permission denied, timeout) degrades silently to no tab number.
11
+ */
12
+ import type { ActiveSession } from './active.js';
13
+ /** One Ghostty surface (terminal), flattened from window -> tab -> surface. */
14
+ export interface GhosttySurface {
15
+ windowIndex: number;
16
+ tabIndex: number;
17
+ cwd: string;
18
+ title: string;
19
+ }
20
+ /**
21
+ * Enumerate every Ghostty surface (window/tab/cwd/title) via one read-only
22
+ * osascript call. Returns [] on ANY failure — Ghostty not running, Automation
23
+ * permission not granted, timeout, or a parse miss. Never throws, never prompts.
24
+ */
25
+ export declare function enumerateGhosttyTabs(timeoutMs?: number): Promise<GhosttySurface[]>;
26
+ /**
27
+ * Assign a Ghostty tab number to each `host === 'ghostty'` session by matching
28
+ * its cwd to a surface's working directory; ties (same cwd) are broken by title
29
+ * containment against the session's label/topic/kind. Deliberately conservative:
30
+ * a session that can't be uniquely resolved gets NO number — a wrong jump target
31
+ * is worse than none. Pure and unit-tested.
32
+ */
33
+ export declare function assignGhosttyTabs(sessions: ActiveSession[], surfaces: GhosttySurface[]): Map<ActiveSession, number>;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Best-effort Ghostty tab-number detection for `agents sessions --active`.
3
+ *
4
+ * Ghostty (macOS) exposes read-only AppleScript: every tab has an `index` and a
5
+ * `name` (title); every surface a `working directory`. It exposes NO per-tab env
6
+ * var and NO tty/pid on a surface — so a session is matched to its tab by
7
+ * `working directory` (with title as a tiebreak). This is display sugar only:
8
+ * one bounded, non-fatal osascript call, run by the renderer, never on the
9
+ * discovery / --json / --waiting path. Any failure (Ghostty not running,
10
+ * Automation permission denied, timeout) degrades silently to no tab number.
11
+ */
12
+ import { execFile } from 'child_process';
13
+ import { promisify } from 'util';
14
+ const execFileAsync = promisify(execFile);
15
+ // Field delimiter: ASCII Unit Separator (0x1F). AppleScript's `tab` keyword does
16
+ // not resolve to a tab byte via `osascript -e`, but `character id 31` does — and
17
+ // 0x1F never appears in a cwd or a tab title, so parsing is unambiguous.
18
+ const ENUM_SCRIPT = `tell application "Ghostty"
19
+ set fd to (character id 31)
20
+ set out to ""
21
+ set wi to 0
22
+ repeat with w in windows
23
+ set wi to wi + 1
24
+ repeat with t in tabs of w
25
+ set ti to index of t
26
+ repeat with s in terminals of t
27
+ set out to out & wi & fd & ti & fd & (working directory of s) & fd & (name of s) & linefeed
28
+ end repeat
29
+ end repeat
30
+ end repeat
31
+ return out
32
+ end tell`;
33
+ /**
34
+ * Enumerate every Ghostty surface (window/tab/cwd/title) via one read-only
35
+ * osascript call. Returns [] on ANY failure — Ghostty not running, Automation
36
+ * permission not granted, timeout, or a parse miss. Never throws, never prompts.
37
+ */
38
+ export async function enumerateGhosttyTabs(timeoutMs = 1500) {
39
+ if (process.platform !== 'darwin')
40
+ return [];
41
+ let stdout;
42
+ try {
43
+ ({ stdout } = await execFileAsync('osascript', ['-e', ENUM_SCRIPT], {
44
+ timeout: timeoutMs,
45
+ killSignal: 'SIGKILL',
46
+ maxBuffer: 1024 * 1024,
47
+ encoding: 'utf8',
48
+ }));
49
+ }
50
+ catch {
51
+ return [];
52
+ }
53
+ const out = [];
54
+ for (const line of stdout.split('\n')) {
55
+ if (!line)
56
+ continue;
57
+ const f = line.split('\u001f');
58
+ if (f.length < 4)
59
+ continue;
60
+ const windowIndex = parseInt(f[0], 10);
61
+ const tabIndex = parseInt(f[1], 10);
62
+ if (!Number.isFinite(windowIndex) || !Number.isFinite(tabIndex))
63
+ continue;
64
+ out.push({ windowIndex, tabIndex, cwd: f[2], title: f[3] });
65
+ }
66
+ return out;
67
+ }
68
+ /** Trailing-slash-insensitive cwd key. */
69
+ function cwdKey(p) {
70
+ return (p ?? '').replace(/\/+$/, '');
71
+ }
72
+ /**
73
+ * Normalize a tab title / hint for containment matching: drop a leading run of
74
+ * non-alphanumerics (Ghostty prefixes the title with a spinner glyph like `⠐ `
75
+ * or `✳ ` while the agent runs) and lowercase. Without this, a title that starts
76
+ * with the session's exact topic still fails a substring test.
77
+ */
78
+ function normText(s) {
79
+ return s.replace(/^[^\p{L}\p{N}]+/u, '').toLowerCase().trim();
80
+ }
81
+ /**
82
+ * Assign a Ghostty tab number to each `host === 'ghostty'` session by matching
83
+ * its cwd to a surface's working directory; ties (same cwd) are broken by title
84
+ * containment against the session's label/topic/kind. Deliberately conservative:
85
+ * a session that can't be uniquely resolved gets NO number — a wrong jump target
86
+ * is worse than none. Pure and unit-tested.
87
+ */
88
+ export function assignGhosttyTabs(sessions, surfaces) {
89
+ const result = new Map();
90
+ if (surfaces.length === 0)
91
+ return result;
92
+ const byCwd = new Map();
93
+ for (const s of surfaces) {
94
+ const k = cwdKey(s.cwd);
95
+ const bucket = byCwd.get(k);
96
+ if (bucket)
97
+ bucket.push(s);
98
+ else
99
+ byCwd.set(k, [s]);
100
+ }
101
+ for (const sess of sessions) {
102
+ if (sess.host !== 'ghostty')
103
+ continue;
104
+ const candidates = byCwd.get(cwdKey(sess.cwd));
105
+ if (!candidates || candidates.length === 0)
106
+ continue;
107
+ if (candidates.length === 1) {
108
+ result.set(sess, candidates[0].tabIndex);
109
+ continue;
110
+ }
111
+ // Tie: break by title containment (glyph-stripped, lowercased). Hints are
112
+ // what the session knows about itself; a good hint is >=8 chars so short
113
+ // fragments don't cause spurious matches.
114
+ const hints = [sess.label, sess.topic, sess.preview]
115
+ .filter((h) => !!h && normText(h).length >= 8)
116
+ .map(normText);
117
+ const matches = candidates.filter(c => {
118
+ const title = normText(c.title);
119
+ return title.length >= 8 && hints.some(h => title.includes(h) || h.includes(title));
120
+ });
121
+ // Only assign when the tiebreak is unambiguous (exactly one title match).
122
+ if (matches.length === 1)
123
+ result.set(sess, matches[0].tabIndex);
124
+ }
125
+ return result;
126
+ }
@@ -21,8 +21,12 @@ export function formatRelativeTime(isoTimestamp) {
21
21
  return `${diffHrs} hour${diffHrs === 1 ? '' : 's'} ago`;
22
22
  if (diffDays < 7)
23
23
  return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
24
- // Older: show date
24
+ // Older: show the date. Append a 2-digit year for anything outside the current
25
+ // year so "Jun 28" is never ambiguous across years (e.g. "Jun 28 '25").
25
26
  const d = new Date(then);
26
27
  const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
27
- return `${months[d.getMonth()]} ${d.getDate()}`;
28
+ const label = `${months[d.getMonth()]} ${d.getDate()}`;
29
+ return d.getFullYear() === new Date(now).getFullYear()
30
+ ? label
31
+ : `${label} '${String(d.getFullYear()).slice(-2)}`;
28
32
  }
@@ -18,9 +18,9 @@ import { spawn } from 'child_process';
18
18
  import chalk from 'chalk';
19
19
  import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
20
20
  import { sshTargetFor } from '../devices/connect.js';
21
+ import { resolveExplicitTargets } from '../devices/resolve-target.js';
21
22
  import { loadDevices } from '../devices/registry.js';
22
23
  import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
23
- import { resolveRemoteOsSync } from '../hosts/remote-os.js';
24
24
  import { machineId, normalizeHost } from './sync/config.js';
25
25
  /** Per-host SSH budget. Slightly above SSH_OPTS' ConnectTimeout=10 so a
26
26
  * reachable-but-slow remote still answers before we give up. */
@@ -108,19 +108,9 @@ export async function gatherRemoteActive(hosts) {
108
108
  const self = machineId();
109
109
  const targets = [];
110
110
  if (hosts && hosts.length > 0) {
111
- for (const h of hosts) {
112
- try {
113
- assertValidSshTarget(h);
114
- }
115
- catch {
116
- process.stderr.write(chalk.gray(` ${h}: not a valid ssh target — skipped\n`));
117
- continue;
118
- }
119
- const bareHost = h.split('@').pop() || h;
120
- // Resolve the OS by the name the user passed (a bare alias like `win-mini`
121
- // matches a device-registry entry; a raw `user@host` falls back to POSIX).
122
- targets.push({ target: h, machine: normalizeHost(bareHost), name: h, os: resolveRemoteOsSync(h) });
123
- }
111
+ // Resolve each token through the device registry so an explicit --host/--device
112
+ // dials the exact same address (and machine id) as the auto-discovery sweep.
113
+ targets.push(...await resolveExplicitTargets(hosts));
124
114
  }
125
115
  else {
126
116
  let reg;
@@ -17,9 +17,9 @@ 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 { resolveExplicitTargets } from '../devices/resolve-target.js';
20
21
  import { loadDevices } from '../devices/registry.js';
21
22
  import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
22
- import { resolveRemoteOsSync } from '../hosts/remote-os.js';
23
23
  import { machineId, normalizeHost } from './sync/config.js';
24
24
  import { NO_FANOUT_ENV } from './remote-active.js';
25
25
  import { terminalWidth } from './width.js';
@@ -112,17 +112,9 @@ export async function gatherRemoteList(forwardedArgs, hosts) {
112
112
  const self = machineId();
113
113
  const targets = [];
114
114
  if (hosts && hosts.length > 0) {
115
- for (const h of hosts) {
116
- try {
117
- assertValidSshTarget(h);
118
- }
119
- catch {
120
- process.stderr.write(chalk.gray(` ${h}: not a valid ssh target — skipped\n`));
121
- continue;
122
- }
123
- const bareHost = h.split('@').pop() || h;
124
- targets.push({ target: h, machine: normalizeHost(bareHost), name: h, os: resolveRemoteOsSync(h) });
125
- }
115
+ // Resolve each token through the device registry so an explicit --host/--device
116
+ // dials the exact same address (and machine id) as the auto-discovery sweep.
117
+ targets.push(...await resolveExplicitTargets(hosts));
126
118
  }
127
119
  else {
128
120
  let reg;
@@ -63,10 +63,12 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
63
63
  const out = [];
64
64
  for (let i = 0; i < args.length; i++) {
65
65
  const a = args[i];
66
- if (a === '--host' || a === '-H') {
66
+ if (a === '--host' || a === '-H' || a === '--device') {
67
67
  // Commander's `<target...>` variadic accepts both `--host a --host b` and
68
68
  // `--host a b` — consume every consecutive token that is a known host so
69
69
  // the variadic form doesn't leak the extra hosts into the remote argv.
70
+ // `--device` is an alias for `--host` (its values are merged into the same
71
+ // host set), so strip it the same way — else the peer would re-fan-out.
70
72
  // Fall back to consuming the single next token when we have no host set
71
73
  // (e.g. malformed input) so the flag value never leaks either way.
72
74
  if (hosts.size > 0) {
@@ -78,7 +80,7 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
78
80
  }
79
81
  continue;
80
82
  }
81
- if (a.startsWith('--host=') || a.startsWith('-H='))
83
+ if (a.startsWith('--host=') || a.startsWith('-H=') || a.startsWith('--device='))
82
84
  continue;
83
85
  if (/^-H.+/.test(a))
84
86
  continue; // glued short form: -Hyosemite-s1
@@ -5,6 +5,19 @@
5
5
  */
6
6
  /** Secrets bundle holding the R2 credentials. */
7
7
  export declare const SYNC_BUNDLE = "r2.backups";
8
+ /** Env var that overrides the persisted enable flag (on/off/true/false/1/0/yes/no). */
9
+ export declare const SYNC_ENABLED_ENV = "AGENTS_SESSIONS_SYNC";
10
+ /** Durable, machine-local path holding the sync enable flag. */
11
+ export declare function syncStateFilePath(): string;
12
+ /**
13
+ * Whether automatic session sync is enabled on this machine. Defaults to true;
14
+ * an unrecognized env value falls through to the file; an absent/unreadable file
15
+ * falls through to the default. Read fresh every call (no memoization) so a
16
+ * `--disable` takes effect on the daemon's next ~90s cycle without a restart.
17
+ */
18
+ export declare function isSyncEnabled(): boolean;
19
+ /** Persist the machine-local sync enable flag (durable across cache wipes). */
20
+ export declare function setSyncEnabled(enabled: boolean): void;
8
21
  export interface R2Config {
9
22
  accountId: string;
10
23
  bucket: string;
@@ -3,9 +3,65 @@
3
3
  * machine's stable identity. Credentials come from the `r2.backups` secrets
4
4
  * bundle (OS keychain on macOS, libsecret on Linux) — never from env or disk.
5
5
  */
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
6
8
  import { readAndResolveBundleEnv } from '../../secrets/bundles.js';
9
+ import { getHistoryDir } from '../../state.js';
7
10
  /** Secrets bundle holding the R2 credentials. */
8
11
  export const SYNC_BUNDLE = 'r2.backups';
12
+ // ── Enable / disable switch ─────────────────────────────────────────────────
13
+ // Whether the daemon's automatic cross-machine sync (and `agents sync
14
+ // --sessions`) may run on THIS machine. Independent of credential presence
15
+ // (isSyncConfigured): a machine can hold valid R2 creds yet still opt out of the
16
+ // background push/pull — e.g. when on-demand `agents sessions --host` is
17
+ // preferred over the ad-hoc R2 mirror. Manual `agents sessions sync` is an
18
+ // explicit user action and is deliberately NOT gated by this switch.
19
+ //
20
+ // Resolution order: the AGENTS_SESSIONS_SYNC env var (a recognized on/off value
21
+ // wins outright, for ad-hoc overrides and tests), then a durable machine-local
22
+ // flag file, then the default (enabled). The flag lives in the durable
23
+ // ~/.agents/.history tree — NOT .cache — so a cache wipe can never silently
24
+ // re-enable a sync the operator turned off.
25
+ /** Env var that overrides the persisted enable flag (on/off/true/false/1/0/yes/no). */
26
+ export const SYNC_ENABLED_ENV = 'AGENTS_SESSIONS_SYNC';
27
+ const SYNC_ENABLED_FILE = 'sessions-sync.json';
28
+ const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disabled']);
29
+ const ON_VALUES = new Set(['1', 'on', 'true', 'yes', 'enabled']);
30
+ /** Durable, machine-local path holding the sync enable flag. */
31
+ export function syncStateFilePath() {
32
+ return path.join(getHistoryDir(), SYNC_ENABLED_FILE);
33
+ }
34
+ /**
35
+ * Whether automatic session sync is enabled on this machine. Defaults to true;
36
+ * an unrecognized env value falls through to the file; an absent/unreadable file
37
+ * falls through to the default. Read fresh every call (no memoization) so a
38
+ * `--disable` takes effect on the daemon's next ~90s cycle without a restart.
39
+ */
40
+ export function isSyncEnabled() {
41
+ const envRaw = process.env[SYNC_ENABLED_ENV]?.trim().toLowerCase();
42
+ if (envRaw) {
43
+ if (OFF_VALUES.has(envRaw))
44
+ return false;
45
+ if (ON_VALUES.has(envRaw))
46
+ return true;
47
+ // Unrecognized value: ignore and consult the persisted flag.
48
+ }
49
+ try {
50
+ const parsed = JSON.parse(fs.readFileSync(syncStateFilePath(), 'utf-8'));
51
+ if (parsed && typeof parsed.enabled === 'boolean')
52
+ return parsed.enabled;
53
+ }
54
+ catch {
55
+ // Absent or unreadable → default enabled.
56
+ }
57
+ return true;
58
+ }
59
+ /** Persist the machine-local sync enable flag (durable across cache wipes). */
60
+ export function setSyncEnabled(enabled) {
61
+ const p = syncStateFilePath();
62
+ fs.mkdirSync(path.dirname(p), { recursive: true });
63
+ fs.writeFileSync(p, JSON.stringify({ enabled }, null, 2) + '\n', 'utf-8');
64
+ }
9
65
  /**
10
66
  * Resolve R2 credentials from the `r2.backups` bundle. Throws a clear,
11
67
  * actionable error if the bundle or any key is missing — sync cannot proceed
@@ -46,6 +46,12 @@ export interface SessionMeta {
46
46
  shortId: string;
47
47
  agent: SessionAgentId;
48
48
  timestamp: string;
49
+ /**
50
+ * Last-activity time (ISO): the last message timestamp when a parser computed
51
+ * it, else file mtime, else `timestamp`. This is the recency signal the
52
+ * listing sorts and labels by; `timestamp` stays the creation time.
53
+ */
54
+ lastActivity?: string;
49
55
  project?: string;
50
56
  cwd?: string;
51
57
  filePath: string;
@@ -111,10 +111,10 @@ export async function runUmbrellaSync(args) {
111
111
  }
112
112
  }
113
113
  if (plan.fetchSessions) {
114
- // Gate exactly like the daemon: a missing r2.backups bundle is a clean no-op,
115
- // not an error that fails the whole sync.
116
- const { isSyncConfigured } = await import('./session/sync/config.js');
117
- if (isSyncConfigured()) {
114
+ // Gate exactly like the daemon: an off switch or a missing r2.backups bundle
115
+ // is a clean no-op, not an error that fails the whole sync.
116
+ const { isSyncConfigured, isSyncEnabled } = await import('./session/sync/config.js');
117
+ if (isSyncEnabled() && isSyncConfigured()) {
118
118
  const { syncSessions } = await import('./session/sync/sync.js');
119
119
  const r = await syncSessions();
120
120
  result.sessions = { ran: true, pushed: r.pushed, pulled: r.pulled, merged: r.merged };
@@ -70,6 +70,16 @@ export declare function killSession(name: string, socket?: string): Promise<bool
70
70
  * meta files. Wipes the socket so the next `new` starts from a clean slate.
71
71
  */
72
72
  export declare function killAll(socket?: string): Promise<number>;
73
+ /**
74
+ * Map every pane id (`%N`) on a socket to its `session:window.pane` attach
75
+ * target, in one batched `tmux list-panes -a` call. `%116 -> main:2.0` is a
76
+ * valid `tmux attach -t main:2` / `tmux select-window -t main:2` target — a
77
+ * human jump target, unlike the bare `%pane` send-keys id. Because it walks
78
+ * every pane (not just one-per-session), it also surfaces multiple agents that
79
+ * share a session across windows. Best-effort: returns an empty map on any
80
+ * failure (tmux gone, foreign socket) so callers fall back to the raw pane id.
81
+ */
82
+ export declare function mapPanesToTargets(socket?: string): Promise<Map<string, string>>;
73
83
  /**
74
84
  * List live sessions on the socket. Reconciles meta JSONs against tmux's view:
75
85
  * - tmux session with no meta → returned without `meta` (external session)