@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.
@@ -7,6 +7,9 @@ import type { Command } from 'commander';
7
7
  interface SyncCmdOptions {
8
8
  verbose?: boolean;
9
9
  json?: boolean;
10
+ enable?: boolean;
11
+ disable?: boolean;
12
+ status?: boolean;
10
13
  }
11
14
  export declare function runSessionsSync(options: SyncCmdOptions): Promise<void>;
12
15
  export declare function registerSessionsSyncCommand(sessionsCmd: Command): void;
@@ -5,9 +5,34 @@
5
5
  */
6
6
  import chalk from 'chalk';
7
7
  import { setHelpSections } from '../lib/help.js';
8
- import { isSyncConfigured, SYNC_BUNDLE } from '../lib/session/sync/config.js';
8
+ import { isSyncConfigured, isSyncEnabled, setSyncEnabled, SYNC_BUNDLE, } from '../lib/session/sync/config.js';
9
9
  import { syncSessions } from '../lib/session/sync/sync.js';
10
10
  export async function runSessionsSync(options) {
11
+ // Toggle / status actions short-circuit before any network cycle.
12
+ if (options.disable) {
13
+ setSyncEnabled(false);
14
+ console.log(chalk.yellow('Automatic session sync disabled') +
15
+ chalk.dim(' — the daemon stops pushing/pulling within ~90s. Re-enable: agents sessions sync --enable'));
16
+ return;
17
+ }
18
+ if (options.enable) {
19
+ setSyncEnabled(true);
20
+ console.log(chalk.green('Automatic session sync enabled') + chalk.dim(' — the daemon resumes on its next cycle.'));
21
+ return;
22
+ }
23
+ if (options.status) {
24
+ const enabled = isSyncEnabled();
25
+ const configured = isSyncConfigured();
26
+ if (options.json) {
27
+ console.log(JSON.stringify({ enabled, configured }, null, 2));
28
+ }
29
+ else {
30
+ console.log(`automatic sync: ${enabled ? chalk.green('enabled') : chalk.yellow('disabled')}` +
31
+ chalk.dim(' · ') +
32
+ `credentials: ${configured ? chalk.green('configured') : chalk.yellow(`missing (${SYNC_BUNDLE})`)}`);
33
+ }
34
+ return;
35
+ }
11
36
  if (!isSyncConfigured()) {
12
37
  console.error(chalk.red(`Sessions sync is not configured.`) +
13
38
  `\nAdd R2 credentials to the '${SYNC_BUNDLE}' bundle:\n` +
@@ -51,7 +76,10 @@ export function registerSessionsSyncCommand(sessionsCmd) {
51
76
  .command('sync')
52
77
  .description('Sync session transcripts across machines via R2 (CRDT merge). Claude and Codex.')
53
78
  .option('-v, --verbose', 'Log each pushed and pulled session')
54
- .option('--json', 'Output the sync result as JSON');
79
+ .option('--json', 'Output the sync result as JSON')
80
+ .option('--enable', 'Turn ON automatic background sync on this machine (persisted)')
81
+ .option('--disable', 'Turn OFF automatic background sync on this machine (persisted)')
82
+ .option('--status', 'Show whether automatic sync is enabled and configured');
55
83
  setHelpSections(syncCmd, {
56
84
  examples: `
57
85
  # One sync cycle (push local changes, pull + merge from other machines)
@@ -59,15 +87,27 @@ export function registerSessionsSyncCommand(sessionsCmd) {
59
87
 
60
88
  # See exactly what moved
61
89
  agents sessions sync --verbose
90
+
91
+ # Stop this machine's daemon from auto-syncing (prefer on-demand --host reads)
92
+ agents sessions sync --disable
93
+
94
+ # Check the current switch + credential state
95
+ agents sessions sync --status
62
96
  `,
63
97
  notes: `
64
98
  - Credentials come from the '${SYNC_BUNDLE}' secrets bundle (R2 S3 API, read+write).
65
99
  - Each machine writes only its own prefix; conflicts are impossible by construction.
66
100
  - The daemon runs this automatically (~90s); this command forces an immediate cycle.
67
101
  - Sessions present locally always win; synced-in copies fill in other machines' sessions.
102
+ - --disable/--enable persist a machine-local switch (~/.agents/.history) that gates the
103
+ daemon's automatic sync; a bare 'agents sessions sync' still forces a manual cycle.
104
+ The AGENTS_SESSIONS_SYNC env var (on/off) overrides the switch for one invocation.
68
105
  `,
69
106
  });
70
- syncCmd.action(async (options) => {
71
- await runSessionsSync(options);
107
+ // `--json` is also declared on the parent `sessions` command, so a bare
108
+ // `options` arg would miss it (Commander binds the shared flag to the parent).
109
+ // optsWithGlobals() merges ancestor + local options so --json resolves here.
110
+ syncCmd.action(async (_options, cmd) => {
111
+ await runSessionsSync(cmd.optsWithGlobals());
72
112
  });
73
113
  }
@@ -2,6 +2,13 @@ import type { Command } from 'commander';
2
2
  import type { SessionAgentId, SessionMeta } from '../lib/session/types.js';
3
3
  import { type ActiveSession } from '../lib/session/active.js';
4
4
  import { type PickedSession } from './sessions-picker.js';
5
+ /**
6
+ * Strip terminal/harness noise from a preview so the column stays a single line
7
+ * of plain prose: OSC title escapes, CSI/SGR ANSI, and the harness wrapper tags
8
+ * (`<local-command-stdout>`, `<task-notification>`, `<command-*>`) that leak from
9
+ * a captured transcript tail. Collapses runs of whitespace.
10
+ */
11
+ export declare function cleanPreview(text: string): string;
5
12
  /**
6
13
  * Index live sessions by their full session UUID so a historical `SessionMeta`
7
14
  * row (`meta.id`) can be matched to the session that is still running now.
@@ -17,6 +17,8 @@ import { SESSION_AGENTS } from '../lib/session/types.js';
17
17
  import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
18
18
  import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, findExecutable } from '../lib/platform/index.js';
19
19
  import { getActiveSessions } from '../lib/session/active.js';
20
+ import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
21
+ import { mapPanesToTargets } from '../lib/tmux/session.js';
20
22
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
21
23
  import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
22
24
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
@@ -39,6 +41,24 @@ import { registerSessionsSyncCommand } from './sessions-sync.js';
39
41
  import { registerSessionsResumeCommand } from './sessions-resume.js';
40
42
  import { registerSessionsInjectCommand } from './sessions-inject.js';
41
43
  const SESSION_AGENT_FILTER_HELP = `Filter by agent, e.g. claude, codex, claude@2.0.65`;
44
+ /**
45
+ * The prioritized harnesses that get a boolean shorthand flag (e.g. `--claude`
46
+ * === `--agent claude`). The rest stay reachable via `--agent <name>`, which
47
+ * also carries version pins like `codex@0.116.0`.
48
+ */
49
+ const AGENT_SHORTHANDS = ['claude', 'codex', 'kimi', 'antigravity', 'grok', 'opencode'];
50
+ /**
51
+ * Resolve a per-agent shorthand (`--claude`, `--kimi`, …) into `options.agent`.
52
+ * An explicit `--agent` wins; if two shorthands are passed we take the first and
53
+ * ignore the rest (commander gives no ordering, so this is a best-effort alias).
54
+ */
55
+ function applyAgentShorthands(options) {
56
+ if (options.agent)
57
+ return;
58
+ const hit = AGENT_SHORTHANDS.find((name) => options[name] === true);
59
+ if (hit)
60
+ options.agent = hit;
61
+ }
42
62
  const CLAUDE_RESUME_MATCH_WINDOW_MS = 10 * 60_000;
43
63
  const LOAD_VERBS = ['Loading', 'Scanning', 'Gathering', 'Indexing', 'Reading'];
44
64
  const FIND_VERBS = ['Finding', 'Searching', 'Locating', 'Matching'];
@@ -174,13 +194,28 @@ function formatStartedAt(startedAtMs) {
174
194
  return '-';
175
195
  return formatRelativeTime(new Date(startedAtMs).toISOString());
176
196
  }
197
+ /**
198
+ * Strip terminal/harness noise from a preview so the column stays a single line
199
+ * of plain prose: OSC title escapes, CSI/SGR ANSI, and the harness wrapper tags
200
+ * (`<local-command-stdout>`, `<task-notification>`, `<command-*>`) that leak from
201
+ * a captured transcript tail. Collapses runs of whitespace.
202
+ */
203
+ export function cleanPreview(text) {
204
+ // eslint-disable-next-line no-control-regex
205
+ return text
206
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC (title) sequences
207
+ .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '') // CSI / SGR ANSI
208
+ .replace(/<\/?(?:local-command-stdout|command-name|command-message|command-args|task-notification|system-reminder)>/g, '')
209
+ .replace(/\s+/g, ' ')
210
+ .trim();
211
+ }
177
212
  /**
178
213
  * Build the live description for an active session: prefer the state engine's
179
214
  * preview (the latest turn), then a user label, then the first-prompt topic.
180
215
  */
181
216
  function buildSessionDescription(s) {
182
217
  if (s.context === 'cloud') {
183
- return s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`;
218
+ return cleanPreview(s.preview || `${s.cloudProvider ?? ''}${s.cloudTaskId ? ` · ${s.cloudTaskId.slice(0, 12)}` : ''}`);
184
219
  }
185
220
  if (s.context === 'teams') {
186
221
  const parts = [s.teamName];
@@ -190,10 +225,10 @@ function buildSessionDescription(s) {
190
225
  parts.push(s.label);
191
226
  else if (s.topic)
192
227
  parts.push(s.topic);
193
- return parts.filter(Boolean).join(' · ');
228
+ return cleanPreview(parts.filter(Boolean).join(' · '));
194
229
  }
195
230
  // Terminal or headless: prefer the live preview, then label, then topic.
196
- return s.preview || s.label || s.topic || '';
231
+ return cleanPreview(s.preview || s.label || s.topic || '');
197
232
  }
198
233
  /** Short human word for a session's activity (falls back to the coarse status). */
199
234
  function activityLabel(s) {
@@ -264,20 +299,25 @@ function signalBadges(s) {
264
299
  return parts.join(' ');
265
300
  }
266
301
  /**
267
- * Compact provenance badge: how to reach the session, not what it's doing.
268
- * `ssh` flags a remote host; the tmux pane id is the send-keys target the feed
269
- * would type back into. Local, non-tmux sessions add nothing (the common case).
302
+ * Compact locator badge: how to JUMP to the session, not what it's doing.
303
+ * `ssh` flags a remote host. For tmux, prefer the resolved `session:window.pane`
304
+ * (a real `tmux attach -t <session:window>` target) over the raw `%pane` id. For
305
+ * a local Ghostty session we know the tab, show `tab N`. Local, unlocatable
306
+ * sessions add nothing (the common case).
270
307
  */
271
- function provenanceBadge(p) {
272
- if (!p)
273
- return '';
308
+ function locatorBadge(s) {
309
+ const p = s.provenance;
274
310
  const parts = [];
275
- if (p.transport === 'ssh')
311
+ if (p?.transport === 'ssh')
276
312
  parts.push(chalk.red('ssh'));
277
- if (p.mux?.kind === 'tmux' && p.mux.pane)
278
- parts.push(chalk.green(`tmux ${p.mux.pane}`));
279
- else if (p.mux?.kind === 'screen')
313
+ if (p?.mux?.kind === 'tmux' && (s.tmuxTarget || p.mux.pane)) {
314
+ parts.push(chalk.green(s.tmuxTarget ?? p.mux.pane));
315
+ }
316
+ else if (p?.mux?.kind === 'screen') {
280
317
  parts.push(chalk.green('screen'));
318
+ }
319
+ if (s.ghosttyTab != null)
320
+ parts.push(chalk.green(`tab ${s.ghosttyTab}`));
281
321
  return parts.join(' ');
282
322
  }
283
323
  /**
@@ -293,7 +333,7 @@ function printActiveRow(s, indent) {
293
333
  const hostCol = chalk.gray(padToWidth(truncateToWidth(s.host ?? '-', 8), 9));
294
334
  const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), 8), 9));
295
335
  const fork = s.pidCount && s.pidCount > 1 ? chalk.dim(`×${s.pidCount} `) : '';
296
- const badges = (fork ? fork : '') + [signalBadges(s), provenanceBadge(s.provenance)].filter(Boolean).join(' ');
336
+ const badges = (fork ? fork : '') + [signalBadges(s), locatorBadge(s)].filter(Boolean).join(' ');
297
337
  const desc = buildSessionDescription(s) || '-';
298
338
  // Fill the remaining width with the preview so nothing wraps under tmux/SSH.
299
339
  const fixed = stringWidth(indent) + 9 + 9 + 9 + 9 + (badges ? stringWidth(badges) + 1 : 0);
@@ -466,6 +506,27 @@ export function mergeLocalFirst(sessions, localMachine) {
466
506
  });
467
507
  return keys.flatMap((k) => byMachine.get(k));
468
508
  }
509
+ /**
510
+ * `running N · idle N · waiting N · queued N` for a bucket of sessions (zero
511
+ * buckets omitted). Same bucketing as the grand-total summary so per-group
512
+ * counts reconcile with the `(total)` beside the header. Empty when nothing.
513
+ */
514
+ function groupTally(sessions) {
515
+ const running = sessions.filter(s => s.status === 'running').length;
516
+ const idle = sessions.filter(s => s.status === 'idle').length;
517
+ const waiting = sessions.filter(s => s.status === 'input_required').length;
518
+ const queued = sessions.filter(s => s.status === 'queued').length;
519
+ const parts = [];
520
+ if (running)
521
+ parts.push(`${running} running`);
522
+ if (idle)
523
+ parts.push(`${idle} idle`);
524
+ if (waiting)
525
+ parts.push(`${waiting} waiting`);
526
+ if (queued)
527
+ parts.push(`${queued} queued`);
528
+ return parts.join(' · ');
529
+ }
469
530
  /** Print one machine's workspace tree, indented under its machine header. */
470
531
  function renderWorkspaceLayout(layout, base) {
471
532
  let first = true;
@@ -478,7 +539,9 @@ function renderWorkspaceLayout(layout, base) {
478
539
  : ws.key === '__unknown__'
479
540
  ? chalk.gray.bold('unknown')
480
541
  : chalk.cyan.bold(shortCwd(ws.key));
481
- console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}`);
542
+ const wsSessions = [...ws.windows.flatMap(w => w.sessions), ...ws.flat];
543
+ const tally = groupTally(wsSessions);
544
+ console.log(`${base}${header} ${chalk.gray(`(${ws.total})`)}${tally ? chalk.gray(` ${tally}`) : ''}`);
482
545
  for (const win of ws.windows) {
483
546
  // Host is per-process, but every terminal in the same IDE window shares
484
547
  // an ancestor — take the first non-empty host as the window's label.
@@ -500,6 +563,43 @@ function printMachineHeader(mg) {
500
563
  const here = mg.isLocal ? chalk.cyan(' ← this machine') : '';
501
564
  console.log(`${marker}${name} ${chalk.gray(`(${mg.total})`)}${here}`);
502
565
  }
566
+ /**
567
+ * Attach display-only jump locators onto LOCAL sessions: the Ghostty tab number
568
+ * (one batched read-only osascript, only when a local ghostty session exists)
569
+ * and the tmux `session:window.pane` target (one `list-panes -a` per socket).
570
+ * Every step is best-effort and swallowed — a failure just leaves the raw pane
571
+ * id / no tab number, and the rows render as before. Mutates the sessions.
572
+ */
573
+ async function enrichLocalLocators(local) {
574
+ // Ghostty tab numbers.
575
+ try {
576
+ const ghostty = local.filter(s => s.host === 'ghostty' && s.provenance?.transport !== 'ssh');
577
+ if (ghostty.length > 0) {
578
+ const surfaces = await enumerateGhosttyTabs();
579
+ for (const [sess, tab] of assignGhosttyTabs(ghostty, surfaces))
580
+ sess.ghosttyTab = tab;
581
+ }
582
+ }
583
+ catch { /* non-fatal */ }
584
+ // tmux attach targets, one batched query per distinct socket.
585
+ try {
586
+ const tmux = local.filter(s => s.provenance?.mux?.kind === 'tmux' && s.provenance.mux.pane);
587
+ const sockets = new Set(tmux.map(s => s.provenance.mux.socket));
588
+ for (const socket of sockets) {
589
+ const paneMap = await mapPanesToTargets(socket);
590
+ if (paneMap.size === 0)
591
+ continue;
592
+ for (const s of tmux) {
593
+ if (s.provenance.mux.socket !== socket)
594
+ continue;
595
+ const target = paneMap.get(s.provenance.mux.pane);
596
+ if (target)
597
+ s.tmuxTarget = target;
598
+ }
599
+ }
600
+ }
601
+ catch { /* non-fatal */ }
602
+ }
503
603
  /**
504
604
  * Render the unified active-session view, grouped by machine. Local sessions
505
605
  * come from `getActiveSessions()`; unless `--local`, sessions from other
@@ -535,6 +635,10 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
535
635
  printCrossMachineTip();
536
636
  return;
537
637
  }
638
+ // Enrich LOCAL sessions with jump locators (display-only, after the --json /
639
+ // --waiting gates so scriptable output stays osascript-free). Remote sessions
640
+ // keep their raw pane id — their tmux/Ghostty live on the other machine.
641
+ await enrichLocalLocators(sessions.filter(s => !s.machine || s.machine === self));
538
642
  const grouped = groupSessionsByMachine(sessions, self);
539
643
  let firstMachine = true;
540
644
  for (const mg of grouped.machines) {
@@ -544,16 +648,7 @@ async function renderActiveSessions(asJson, waitingOnly = false, opts = {}) {
544
648
  printMachineHeader(mg);
545
649
  renderWorkspaceLayout(mg.layout, ' ');
546
650
  }
547
- const runningCount = sessions.filter(s => s.status === 'running').length;
548
- const idleCount = sessions.filter(s => s.status === 'idle').length;
549
- const queuedCount = sessions.filter(s => s.status === 'queued' || s.status === 'input_required').length;
550
- const parts = [];
551
- if (runningCount > 0)
552
- parts.push(`${runningCount} running`);
553
- if (idleCount > 0)
554
- parts.push(`${idleCount} idle`);
555
- if (queuedCount > 0)
556
- parts.push(`${queuedCount} queued`);
651
+ const parts = groupTally(sessions).split(' · ').filter(Boolean);
557
652
  const machineWord = grouped.machines.length === 1 ? 'machine' : 'machines';
558
653
  console.log(chalk.gray(`\n${sessions.length} active (${parts.join(', ')}) across ${grouped.machines.length} ${machineWord}.`));
559
654
  // Tip only when nothing else could be included and the user didn't opt out.
@@ -569,6 +664,13 @@ function printCrossMachineTip() {
569
664
  }
570
665
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
571
666
  async function sessionsAction(query, options) {
667
+ // Normalize convenience flags before any routing reads them: per-agent
668
+ // shorthands fold into --agent, and --device is an alias for --host (both
669
+ // resolve against the same device registry).
670
+ applyAgentShorthands(options);
671
+ if (options.device && options.device.length > 0) {
672
+ options.host = [...(options.host ?? []), ...options.device];
673
+ }
572
674
  // --host WITHOUT --active keeps the legacy per-host stream (each remote's raw
573
675
  // stdout under a `── host ──` banner). With --active, the hosts are folded
574
676
  // into the merged machine-grouped view instead (handled below).
@@ -666,9 +768,12 @@ async function sessionsAction(query, options) {
666
768
  const scope = {
667
769
  agent,
668
770
  version,
669
- all: pathFilter ? undefined : (wantsOverview ? true : options.all),
771
+ all: pathFilter ? undefined : options.all,
670
772
  cwd: process.cwd(),
671
- cwdPrefix: pathFilter,
773
+ // Default overview scopes to the current repo SUBTREE (prefix match), so a
774
+ // monorepo shows its sub-projects grouped instead of collapsing to the one
775
+ // exact-cwd project. `--all` clears the prefix and spans the whole index.
776
+ cwdPrefix: pathFilter ?? (wantsOverview && !options.all ? process.cwd() : undefined),
672
777
  project: options.project,
673
778
  since,
674
779
  until: options.until,
@@ -819,7 +924,7 @@ function metaSignals(s) {
819
924
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
820
925
  function flatSessionRow(session, live, showTicket = false, cols = {}) {
821
926
  const agentColor = colorAgent(session.agent);
822
- const when = formatRelativeTime(session.timestamp);
927
+ const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
823
928
  const project = session.project || '-';
824
929
  const tag = teamTag(session);
825
930
  const label = session.label;
@@ -858,7 +963,7 @@ function flatSessionRow(session, live, showTicket = false, cols = {}) {
858
963
  /** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
859
964
  function treeSessionRow(session, live) {
860
965
  const agentColor = colorAgent(session.agent);
861
- const when = formatRelativeTime(session.timestamp);
966
+ const when = formatRelativeTime(session.lastActivity ?? session.timestamp);
862
967
  const tag = teamTag(session);
863
968
  const label = session.label;
864
969
  const { glyph, preview } = liveGlyphAndPreview(live);
@@ -928,7 +1033,7 @@ export function buildOverviewGroups(pool, perProjectCap) {
928
1033
  const groups = [];
929
1034
  for (const [key, rows] of byKey) {
930
1035
  const shown = rows.slice(0, cap); // rows are recency-desc (pool was sorted)
931
- groups.push({ key, total: rows.length, shown, more: rows.length - shown.length, maxTs: rows[0].timestamp });
1036
+ groups.push({ key, total: rows.length, shown, more: rows.length - shown.length, maxTs: rows[0].lastActivity ?? rows[0].timestamp });
932
1037
  }
933
1038
  groups.sort((a, b) => (a.maxTs < b.maxTs ? 1 : a.maxTs > b.maxTs ? -1 : a.key.localeCompare(b.key)));
934
1039
  return { groups, projectCount: byKey.size };
@@ -960,10 +1065,10 @@ function printSessionOverview(pool, hiddenCount, liveIndex, opts) {
960
1065
  console.log(' ' + chalk.gray(`· ${g.more} more`));
961
1066
  }
962
1067
  console.log();
963
- const parts = [chalk.gray('newest first')];
1068
+ const parts = [chalk.gray('newest first (by last activity)')];
964
1069
  if (hiddenProjects > 0)
965
- parts.push(chalk.gray(`+${hiddenProjects} more project${hiddenProjects === 1 ? '' : 's'} · agents sessions --all`));
966
- parts.push(chalk.gray('agents sessions <project> to drill in · --flat for the plain list'));
1070
+ parts.push(chalk.gray(`+${hiddenProjects} more project${hiddenProjects === 1 ? '' : 's'}`));
1071
+ parts.push(chalk.gray('agents sessions --all spans every project on disk · <project> to drill in · --flat for the plain list'));
967
1072
  console.log(parts.join(chalk.gray(' · ')));
968
1073
  if (hiddenCount > 0)
969
1074
  console.log(chalk.gray(formatTeamHiddenFooter(hiddenCount)));
@@ -1213,7 +1318,7 @@ export function pickerColumnsFor(sessions) {
1213
1318
  }
1214
1319
  export function formatPickerLabel(s, query, cols = {}) {
1215
1320
  const agentColor = colorAgent(s.agent);
1216
- const when = formatRelativeTime(s.timestamp);
1321
+ const when = formatRelativeTime(s.lastActivity ?? s.timestamp);
1217
1322
  const project = s.project || '-';
1218
1323
  const tag = teamTag(s);
1219
1324
  const label = s.label;
@@ -1785,6 +1890,12 @@ export function registerSessionsCommands(program) {
1785
1890
  .argument('[query]', 'Session ID, search query, or path (., ../, /path) to filter by project')
1786
1891
  .description('Find, browse, and read agent conversation transcripts across Claude, Codex, Gemini, and OpenCode.')
1787
1892
  .option('-a, --agent <agent>', 'Filter by agent type and version (e.g., claude, codex@0.116.0)')
1893
+ .option('--claude', 'Shorthand for --agent claude')
1894
+ .option('--codex', 'Shorthand for --agent codex')
1895
+ .option('--kimi', 'Shorthand for --agent kimi')
1896
+ .option('--antigravity', 'Shorthand for --agent antigravity')
1897
+ .option('--grok', 'Shorthand for --agent grok')
1898
+ .option('--opencode', 'Shorthand for --agent opencode')
1788
1899
  .option('--all', 'Include sessions from every directory (not just current project)')
1789
1900
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
1790
1901
  .option('--project <name>', 'Filter by project name (searches across all directories)')
@@ -1808,7 +1919,8 @@ export function registerSessionsCommands(program) {
1808
1919
  .option('--flat', 'Plain flat table (one row per session) instead of the grouped project overview')
1809
1920
  .option('--no-live', 'Do not enrich the listing with live status/preview for running sessions')
1810
1921
  .option('--cloud', 'Source sessions from Rush Cloud (captured runs) instead of local disk')
1811
- .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)');
1922
+ .option('-H, --host <target...>', 'Run this query on remote machine(s) over SSH (host alias or user@host; repeatable)')
1923
+ .option('--device <target...>', 'Alias for --host (device alias from `agents devices`; repeatable)');
1812
1924
  setHelpSections(sessionsCmd, {
1813
1925
  examples: `
1814
1926
  # Search prior sessions in this project by topic, file path, or command
@@ -343,8 +343,10 @@ export async function runDaemon() {
343
343
  return;
344
344
  syncing = true;
345
345
  try {
346
- const { isSyncConfigured } = await import('./session/sync/config.js');
347
- if (!isSyncConfigured())
346
+ const { isSyncConfigured, isSyncEnabled } = await import('./session/sync/config.js');
347
+ // isSyncEnabled() first: a machine the operator turned off must skip the
348
+ // keychain read entirely, not just the network cycle.
349
+ if (!isSyncEnabled() || !isSyncConfigured())
348
350
  return;
349
351
  const { syncSessions } = await import('./session/sync/sync.js');
350
352
  const r = await syncSessions();
@@ -0,0 +1,24 @@
1
+ import { type DeviceRegistry } from './registry.js';
2
+ /** A dialable peer: the ssh target, the machine id used to tag its rows, a
3
+ * display name, and the OS family that picks the remote shell dialect. */
4
+ export interface ResolvedSshTarget {
5
+ target: string;
6
+ machine: string;
7
+ name: string;
8
+ os?: string;
9
+ }
10
+ /**
11
+ * Resolve one `--host`/`--device` token to a concrete ssh target through the
12
+ * registry. Registry hit → the device's real address + platform (so the machine
13
+ * id, route, and OS all match the auto-discovery sweep). Miss → a literal
14
+ * `user@host` fallback, its OS taken from the host overlay if enrolled. Returns
15
+ * undefined only when the token fails the shared ssh-target injection guard.
16
+ */
17
+ export declare function resolveSshTarget(token: string, reg: DeviceRegistry): ResolvedSshTarget | undefined;
18
+ /**
19
+ * Resolve an explicit `--host`/`--device` list to dialable targets, reading the
20
+ * registry once. A token that fails the injection guard is skipped with a
21
+ * stderr note (never fatal — one bad token must not blank the fan-out). Shared
22
+ * by every cross-machine fan-out so they can never diverge onto two routes.
23
+ */
24
+ export declare function resolveExplicitTargets(hosts: string[]): Promise<ResolvedSshTarget[]>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The one place a `--host` / `--device` token becomes a real ssh target.
3
+ *
4
+ * A device and a host are the same thing addressed two ways, so resolution must
5
+ * go through the device registry — the single source of truth. A token that
6
+ * names a registered device dials that device's real address (its Tailscale
7
+ * dnsName/ip + user, via `sshTargetFor`), *identical* to the auto-discovery
8
+ * sweep and `agents ssh`. Before this module, the explicit `--host` fan-out
9
+ * instead passed the bare token straight to `ssh`, so `--host yosemite-s0`
10
+ * dialed whatever `~/.ssh/config`/LAN DNS resolved `yosemite-s0` to — a
11
+ * different route than the sweep's `yosemite-s0.<tailnet>.ts.net`. That
12
+ * divergence broke ControlMaster socket reuse (different target → different
13
+ * `%C` hash → a cold dial every time) and could read a perfectly reachable box
14
+ * as "unreachable" when only the non-Tailscale route was down.
15
+ *
16
+ * A raw `user@host` that matches no registered device falls back to a literal
17
+ * target so ad-hoc boxes still work.
18
+ */
19
+ import chalk from 'chalk';
20
+ import { assertValidSshTarget } from '../ssh-exec.js';
21
+ import { normalizeHost } from '../machine-id.js';
22
+ import { resolveRemoteOsSync } from '../hosts/remote-os.js';
23
+ import { sshTargetFor } from './connect.js';
24
+ import { loadDevices } from './registry.js';
25
+ /**
26
+ * Resolve one `--host`/`--device` token to a concrete ssh target through the
27
+ * registry. Registry hit → the device's real address + platform (so the machine
28
+ * id, route, and OS all match the auto-discovery sweep). Miss → a literal
29
+ * `user@host` fallback, its OS taken from the host overlay if enrolled. Returns
30
+ * undefined only when the token fails the shared ssh-target injection guard.
31
+ */
32
+ export function resolveSshTarget(token, reg) {
33
+ try {
34
+ assertValidSshTarget(token);
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ const bare = token.split('@').pop() || token;
40
+ // An explicit `user@host` names an exact account/target — honour it literally.
41
+ // A bare alias (`yosemite-s0`) resolves through the registry to the device's
42
+ // real address, so it never diverges from the auto-discovery sweep.
43
+ const device = token.includes('@')
44
+ ? undefined
45
+ : reg[token] ?? Object.values(reg).find((d) => normalizeHost(d.name) === normalizeHost(bare));
46
+ if (device) {
47
+ try {
48
+ return { target: sshTargetFor(device), machine: normalizeHost(device.name), name: device.name, os: device.platform };
49
+ }
50
+ catch {
51
+ // Registered but has no address to dial — fall through to the literal token.
52
+ }
53
+ }
54
+ return { target: token, machine: normalizeHost(bare), name: token, os: resolveRemoteOsSync(token) };
55
+ }
56
+ /**
57
+ * Resolve an explicit `--host`/`--device` list to dialable targets, reading the
58
+ * registry once. A token that fails the injection guard is skipped with a
59
+ * stderr note (never fatal — one bad token must not blank the fan-out). Shared
60
+ * by every cross-machine fan-out so they can never diverge onto two routes.
61
+ */
62
+ export async function resolveExplicitTargets(hosts) {
63
+ let reg;
64
+ try {
65
+ reg = await loadDevices();
66
+ }
67
+ catch {
68
+ reg = {};
69
+ }
70
+ const out = [];
71
+ for (const h of hosts) {
72
+ const resolved = resolveSshTarget(h, reg);
73
+ if (!resolved) {
74
+ process.stderr.write(chalk.gray(` ${h}: not a valid ssh target — skipped\n`));
75
+ continue;
76
+ }
77
+ out.push(resolved);
78
+ }
79
+ return out;
80
+ }
@@ -66,6 +66,24 @@ 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. */
@@ -80,6 +98,7 @@ export declare function listCloudActive(): ActiveSession[];
80
98
  interface ProcRow {
81
99
  pid: number;
82
100
  ppid: number;
101
+ tty?: string;
83
102
  comm: string;
84
103
  kind?: string;
85
104
  }
@@ -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;