@phnx-labs/agents-cli 1.20.36 → 1.20.38

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 (36) hide show
  1. package/dist/commands/computer-actions.d.ts +10 -0
  2. package/dist/commands/computer-actions.js +47 -17
  3. package/dist/commands/doctor.js +48 -1
  4. package/dist/commands/go.d.ts +28 -0
  5. package/dist/commands/go.js +238 -0
  6. package/dist/commands/sessions-picker.d.ts +2 -0
  7. package/dist/commands/sessions-picker.js +10 -1
  8. package/dist/commands/sessions-sync.d.ts +3 -0
  9. package/dist/commands/sessions-sync.js +44 -4
  10. package/dist/commands/sessions.d.ts +8 -1
  11. package/dist/commands/sessions.js +155 -36
  12. package/dist/index.js +59 -68
  13. package/dist/lib/daemon.js +4 -2
  14. package/dist/lib/devices/resolve-target.d.ts +24 -0
  15. package/dist/lib/devices/resolve-target.js +80 -0
  16. package/dist/lib/session/active.d.ts +25 -0
  17. package/dist/lib/session/active.js +11 -5
  18. package/dist/lib/session/db.d.ts +2 -1
  19. package/dist/lib/session/db.js +41 -5
  20. package/dist/lib/session/discover.d.ts +2 -0
  21. package/dist/lib/session/discover.js +16 -1
  22. package/dist/lib/session/ghostty-tabs.d.ts +33 -0
  23. package/dist/lib/session/ghostty-tabs.js +126 -0
  24. package/dist/lib/session/relative-time.js +6 -2
  25. package/dist/lib/session/remote-active.js +4 -14
  26. package/dist/lib/session/remote-list.js +4 -12
  27. package/dist/lib/session/remote.js +4 -2
  28. package/dist/lib/session/sync/config.d.ts +13 -0
  29. package/dist/lib/session/sync/config.js +56 -0
  30. package/dist/lib/session/types.d.ts +6 -0
  31. package/dist/lib/shims.d.ts +65 -1
  32. package/dist/lib/shims.js +237 -20
  33. package/dist/lib/sync-umbrella.js +4 -4
  34. package/dist/lib/tmux/session.d.ts +10 -0
  35. package/dist/lib/tmux/session.js +31 -0
  36. package/package.json +1 -1
@@ -66,11 +66,35 @@ export interface ActiveSession {
66
66
  * two windows have the same cwd open. Only populated for `terminal` context.
67
67
  */
68
68
  windowId?: string;
69
+ /**
70
+ * Controlling TTY of the agent process (e.g. 'ttys003'), from the `ps -A`
71
+ * read. macOS/Linux terminal sessions only; '??'/none normalized to undefined.
72
+ * A disambiguation bridge (and the basis for future terminal addressing).
73
+ */
74
+ tty?: string;
75
+ /**
76
+ * Ghostty tab index (1-based) the session is shown in, when it can be matched
77
+ * to a Ghostty surface by working directory (+ title). Transient, populated by
78
+ * the renderer just before printing — NOT part of the pure discovery path.
79
+ */
80
+ ghosttyTab?: number;
81
+ /**
82
+ * Resolved tmux attach target (`session:window.pane`) for a tmux-hosted local
83
+ * session, from the pane id via `mapPanesToTargets`. Transient, renderer-set
84
+ * (after the --json/--waiting gates) — NOT emitted on the discovery path.
85
+ */
86
+ tmuxTarget?: string;
69
87
  }
70
88
  export interface ActiveQueryOptions {
71
89
  /** Skip the `ps` scan for ad-hoc headless agents. */
72
90
  skipHeadless?: boolean;
73
91
  }
92
+ /**
93
+ * Locate the live transcript for an agent process. Claude files are keyed by
94
+ * cwd (+ optional session uuid); Codex files are date-partitioned, so we resolve
95
+ * the newest indexed Codex session for the cwd instead.
96
+ */
97
+ export declare function findSessionFileForKind(kind: string, cwd?: string, sessionId?: string): string | undefined;
74
98
  /** Live teams teammates. Reuses AgentManager which already polls PIDs via `kill -0`. */
75
99
  export declare function listTeamsActive(): Promise<ActiveSession[]>;
76
100
  /** Live editor-terminal agents across every IDE window. */
@@ -80,6 +104,7 @@ export declare function listCloudActive(): ActiveSession[];
80
104
  interface ProcRow {
81
105
  pid: number;
82
106
  ppid: number;
107
+ tty?: string;
83
108
  comm: string;
84
109
  kind?: string;
85
110
  }
@@ -161,7 +161,7 @@ function classifyActivity(sessionFile) {
161
161
  * cwd (+ optional session uuid); Codex files are date-partitioned, so we resolve
162
162
  * the newest indexed Codex session for the cwd instead.
163
163
  */
164
- function findSessionFileForKind(kind, cwd, sessionId) {
164
+ export function findSessionFileForKind(kind, cwd, sessionId) {
165
165
  if (!cwd)
166
166
  return undefined;
167
167
  if (kind === 'claude')
@@ -336,6 +336,7 @@ export async function listTerminalsActive() {
336
336
  context: 'terminal',
337
337
  kind: t.kind,
338
338
  host: detectHost(t.pid, procByPid),
339
+ tty: procByPid.get(t.pid)?.tty,
339
340
  pid: t.pid,
340
341
  sessionId: t.sessionId ?? sessionIdFromFile(sessionFile),
341
342
  cwd: t.cwd ?? undefined,
@@ -406,22 +407,26 @@ async function readProcessTable() {
406
407
  return readProcessTableWin32();
407
408
  let out;
408
409
  try {
409
- ({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }));
410
+ ({ stdout: out } = await execFileAsync('ps', ['-A', '-o', 'pid=,ppid=,tty=,comm='], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }));
410
411
  }
411
412
  catch {
412
413
  return [];
413
414
  }
414
415
  const rows = [];
415
416
  for (const line of out.split('\n')) {
416
- const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
417
+ // pid ppid tty comm — tty is a single token ('ttys003', 's003', or '??'/'?'
418
+ // for none); comm stays last so it may contain spaces.
419
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
417
420
  if (!m)
418
421
  continue;
419
422
  const pid = parseInt(m[1], 10);
420
423
  const ppid = parseInt(m[2], 10);
421
424
  if (!Number.isFinite(pid) || !Number.isFinite(ppid))
422
425
  continue;
423
- const commRaw = m[3].trim();
424
- rows.push({ pid, ppid, comm: commRaw, kind: agentKindFromComm(commRaw) });
426
+ const ttyRaw = m[3];
427
+ const tty = ttyRaw === '??' || ttyRaw === '?' || ttyRaw === '-' ? undefined : ttyRaw;
428
+ const commRaw = m[4].trim();
429
+ rows.push({ pid, ppid, tty, comm: commRaw, kind: agentKindFromComm(commRaw) });
425
430
  }
426
431
  return rows;
427
432
  }
@@ -684,6 +689,7 @@ export async function listUnattributedActive(attributed) {
684
689
  context,
685
690
  kind,
686
691
  host,
692
+ tty: procByPid.get(pid)?.tty,
687
693
  pid,
688
694
  cwd,
689
695
  sessionId: entry?.sessionId ?? sessionIdFromFile(sessionFile),
@@ -16,6 +16,7 @@ export interface SessionRow {
16
16
  version: string | null;
17
17
  account: string | null;
18
18
  timestamp: string;
19
+ last_activity: string | null;
19
20
  project: string | null;
20
21
  cwd: string | null;
21
22
  git_branch: string | null;
@@ -144,7 +145,7 @@ export declare function syncTopics(topicMap: Map<string, string>): number;
144
145
  * session hasn't been scanned yet — the caller degrades to no live state.
145
146
  */
146
147
  export declare function latestSessionFileForCwd(agent: SessionAgentId, cwd: string): string | undefined;
147
- /** Query sessions from the database, applying filters and ordering by timestamp descending. */
148
+ /** Query sessions from the database, applying filters and ordering by last-activity descending (default). */
148
149
  export declare function querySessions(options?: QueryOptions): SessionMeta[];
149
150
  /** Count sessions matching the given filter options. */
150
151
  export declare function countSessions(options?: QueryOptions): number;
@@ -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;