@phnx-labs/agents-cli 1.20.77 → 1.20.78

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 (70) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +29 -3
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +4 -2
  9. package/dist/commands/secrets.js +8 -2
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +118 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +15 -1
  15. package/dist/commands/sessions-picker.js +58 -4
  16. package/dist/commands/sessions.d.ts +95 -1
  17. package/dist/commands/sessions.js +224 -26
  18. package/dist/commands/ssh.js +9 -1
  19. package/dist/index.js +3 -5
  20. package/dist/lib/agents.d.ts +0 -21
  21. package/dist/lib/agents.js +0 -37
  22. package/dist/lib/auto-pull.d.ts +16 -8
  23. package/dist/lib/auto-pull.js +23 -28
  24. package/dist/lib/daemon.d.ts +9 -41
  25. package/dist/lib/daemon.js +16 -117
  26. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  27. package/dist/lib/devices/fleet-divergence.js +188 -0
  28. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  29. package/dist/lib/devices/fleet-inventory.js +57 -0
  30. package/dist/lib/devices/health-report.d.ts +10 -2
  31. package/dist/lib/devices/health-report.js +32 -1
  32. package/dist/lib/git.d.ts +13 -0
  33. package/dist/lib/git.js +102 -4
  34. package/dist/lib/hosts/remote-cmd.js +2 -0
  35. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  36. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  38. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  39. package/dist/lib/runner.js +29 -26
  40. package/dist/lib/sandbox.js +0 -16
  41. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  42. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  43. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  44. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  45. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  46. package/dist/lib/secrets/agent.d.ts +2 -0
  47. package/dist/lib/secrets/agent.js +26 -14
  48. package/dist/lib/secrets/bundles.d.ts +1 -1
  49. package/dist/lib/secrets/bundles.js +16 -8
  50. package/dist/lib/secrets/scope.d.ts +26 -0
  51. package/dist/lib/secrets/scope.js +29 -0
  52. package/dist/lib/secrets/session-store.d.ts +10 -0
  53. package/dist/lib/secrets/session-store.js +64 -11
  54. package/dist/lib/session/active.d.ts +7 -0
  55. package/dist/lib/session/active.js +38 -3
  56. package/dist/lib/session/db.d.ts +37 -0
  57. package/dist/lib/session/db.js +112 -4
  58. package/dist/lib/session/digest.js +126 -21
  59. package/dist/lib/session/discover.d.ts +15 -2
  60. package/dist/lib/session/discover.js +19 -11
  61. package/dist/lib/session/origin-machine.d.ts +18 -0
  62. package/dist/lib/session/origin-machine.js +34 -0
  63. package/dist/lib/session/state.d.ts +1 -0
  64. package/dist/lib/session/state.js +1 -1
  65. package/dist/lib/session/sync/config.js +2 -2
  66. package/dist/lib/smart-launch.d.ts +86 -0
  67. package/dist/lib/smart-launch.js +172 -0
  68. package/package.json +1 -1
  69. package/dist/lib/secrets/account-token.d.ts +0 -20
  70. package/dist/lib/secrets/account-token.js +0 -64
@@ -9,14 +9,15 @@
9
9
  * Built on {@link dynamicPicker}; every data source (discover, fleet, live index,
10
10
  * preview, resume dispatch) is reused from the existing sessions plumbing.
11
11
  */
12
+ import path from 'path';
12
13
  import { spawnSync } from 'child_process';
13
14
  import { dynamicPicker } from '../lib/picker.js';
14
- import { getActiveSessions } from '../lib/session/active.js';
15
+ import { isSessionTrackedAgent } from '../lib/session/types.js';
15
16
  import { discoverSessions } from '../lib/session/discover.js';
16
17
  import { gatherRemoteList } from '../lib/session/remote-list.js';
17
18
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
18
19
  import { buildPreview } from './sessions-picker.js';
19
- import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, indexActiveBySessionId, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, } from './sessions.js';
20
+ import { formatPickerLabel, pickerColumnsFor, ticketLabel, mergeLocalFirst, gatherActiveSessions, liveHostLabel, LIVE_ROW_PREFIX, cleanPreview, handlePickedSession, shouldIncludeLocal, remoteHostsToDial, } from './sessions.js';
20
21
  /** Ordered window cycle for the `W` key. `undefined` = all time. */
21
22
  const WINDOW_CYCLE = [undefined, '1d', '7d', '30d'];
22
23
  /** Return the next value in `[undefined, ...options]`, wrapping. */
@@ -102,20 +103,25 @@ export function activeBrowserSeed(opts) {
102
103
  agent: opts.agent,
103
104
  projectScope: 'all',
104
105
  device: normalizeDeviceSeed(opts.host?.[0]),
105
- window: opts.since ?? '30d',
106
+ // --all widens the window to all-time (project is already 'all' here);
107
+ // --since still overrides.
108
+ window: opts.since ?? (opts.all ? undefined : '30d'),
106
109
  };
107
110
  }
108
111
  /**
109
112
  * The initial filter for the bare interactive listing: current-repo subtree by
110
- * default (matches the static overview's cwd scoping), `--all` widens to every
111
- * directory, `--since` seeds the window.
113
+ * default (matches the static overview's cwd scoping). `--all` sets every
114
+ * non-status filter to its "all" value — every directory (project) AND all-time
115
+ * (window) — so one flag maxes the view; `--since` still overrides the window and
116
+ * `-a`/`--device` still narrow their axis.
112
117
  */
113
118
  export function bareBrowserSeed(opts) {
114
119
  return {
115
120
  teams: !!opts.teams,
116
121
  agent: opts.agent,
122
+ // --all maxes every non-status filter: all dirs AND all-time. --since wins.
117
123
  projectScope: opts.all ? 'all' : 'repo',
118
- window: opts.since ?? '30d',
124
+ window: opts.since ?? (opts.all ? undefined : '30d'),
119
125
  };
120
126
  }
121
127
  /** Copy text to the OS clipboard (best-effort; silently no-ops if unavailable). */
@@ -182,6 +188,99 @@ async function fetchRawPool(f, self, local, hosts) {
182
188
  }
183
189
  return { key: `${since ?? 'all'}|${f.teams}`, rows };
184
190
  }
191
+ /**
192
+ * A live session's stable row key: its session id when the agent reported one,
193
+ * else a per-machine handle so an id-less live session (a just-booted harness, a
194
+ * cloud task) still gets exactly one row instead of being dropped. Cloud rows key
195
+ * on the task id because they have no pid — keying them all on the machine alone
196
+ * would collapse two cloud tasks into one row, the same silent-drop this whole
197
+ * change exists to remove.
198
+ */
199
+ export function liveRowKey(a, self) {
200
+ if (a.sessionId)
201
+ return a.sessionId;
202
+ const handle = a.cloudTaskId ?? (a.pid != null ? String(a.pid) : 'unknown');
203
+ return `${LIVE_ROW_PREFIX}${a.machine ?? self}:${handle}`;
204
+ }
205
+ /** Index live sessions by {@link liveRowKey} — the join key between the live scan
206
+ * and the transcript pool. Id-carrying rows key on the session id, so a live row
207
+ * and its transcript row collapse to one. */
208
+ export function indexLiveRows(rows, self) {
209
+ const byKey = new Map();
210
+ for (const a of rows)
211
+ byKey.set(liveRowKey(a, self), a);
212
+ return byKey;
213
+ }
214
+ /**
215
+ * Project a live session onto the picker's row shape, for a session the transcript
216
+ * pool doesn't carry — a peer's session, a transcript outside the current window,
217
+ * or an agent that has not written one yet. `filePath` is the live transcript path
218
+ * when the scan resolved one and empty otherwise, which is what
219
+ * {@link handlePickedSession} keys "there is nothing to open yet" off.
220
+ */
221
+ export function liveSessionToMeta(a, self) {
222
+ const machine = a.machine ?? self;
223
+ const started = a.startedAtMs ?? a.lastActivityMs;
224
+ const topic = a.topic ?? a.preview;
225
+ return {
226
+ id: liveRowKey(a, self),
227
+ // No session id to short — name the row by what it IS (a cloud task, a pid),
228
+ // so the id column still identifies the process you'd go looking for.
229
+ shortId: a.sessionId
230
+ ? a.sessionId.slice(0, 8)
231
+ : a.cloudTaskId
232
+ ? a.cloudTaskId.slice(0, 8)
233
+ : `p:${a.pid ?? '?'}`,
234
+ agent: isSessionTrackedAgent(a.kind) ? a.kind : 'claude',
235
+ timestamp: new Date(started ?? Date.now()).toISOString(),
236
+ lastActivity: a.lastActivityMs ? new Date(a.lastActivityMs).toISOString() : undefined,
237
+ project: a.cwd ? path.basename(a.cwd) : undefined,
238
+ cwd: a.cwd,
239
+ filePath: a.sessionFile ?? '',
240
+ // A live topic is raw transcript text: a newline in it would break the row
241
+ // into two and misalign every column after it. Indexed rows are already
242
+ // cleaned at scan time, so this puts projected rows on the same footing.
243
+ topic: topic ? cleanPreview(topic) : undefined,
244
+ label: a.label ? cleanPreview(a.label) : undefined,
245
+ machine,
246
+ // Reading/resuming a peer's session hops back over SSH — same contract the
247
+ // cross-machine listing sets, so handlePickedSession routes it correctly.
248
+ _remote: machine !== self,
249
+ prUrl: a.pr?.url,
250
+ prNumber: a.pr?.number,
251
+ ticketId: a.ticket?.id,
252
+ worktreeSlug: a.worktree?.slug,
253
+ };
254
+ }
255
+ /**
256
+ * Fold the live scan INTO the transcript pool. The running filter used to be a
257
+ * pure intersection (`pool ∩ live`), which meant a session had to already be in
258
+ * the pool to be shown as running — so every session on another machine, and
259
+ * every local one outside the pool's window, was invisible in the browser while
260
+ * `--active --json` listed it. Live sessions the pool lacks are appended as their
261
+ * own rows, then the whole set is grouped local-machine-first.
262
+ */
263
+ export function mergeLiveIntoPool(rows, live, self) {
264
+ const known = new Set(rows.map((r) => r.id));
265
+ const extra = [];
266
+ for (const [key, a] of live) {
267
+ if (!known.has(key))
268
+ extra.push(liveSessionToMeta(a, self));
269
+ }
270
+ return extra.length === 0 ? rows : mergeLocalFirst([...rows, ...extra], self);
271
+ }
272
+ /**
273
+ * Whether to render the host-program column. It belongs to the running view
274
+ * only, so this gates on the FILTER — not on the live index being populated.
275
+ * `liveCache` outlives a toggle of the `r` hotkey, so testing `live` alone would
276
+ * keep the column after running is turned back off, widening a plain transcript
277
+ * listing that has no live rows to explain it.
278
+ */
279
+ export function shouldShowHostColumn(f, live, rows) {
280
+ if (!f.running || !live)
281
+ return false;
282
+ return rows.some((r) => liveHostLabel(live.get(r.id)) !== '');
283
+ }
185
284
  /** Apply the cheap in-memory filters (agent / device / project / running). */
186
285
  function applyFilters(rows, live, f, self) {
187
286
  let out = rows;
@@ -269,11 +368,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
269
368
  return []; // superseded — don't touch shared state
270
369
  pool = fetched;
271
370
  }
272
- // Only pay for the live scan when the running filter needs it.
371
+ // Only pay for the live scan when the running filter needs it. Same fleet
372
+ // sweep the static `--active` view uses, so the two never disagree about
373
+ // what is running.
273
374
  let live = liveCache;
274
375
  if (f.running && !live) {
275
376
  try {
276
- live = indexActiveBySessionId(await getActiveSessions());
377
+ const { sessions } = await gatherActiveSessions({ local, hosts });
378
+ live = indexLiveRows(sessions, self);
277
379
  }
278
380
  catch {
279
381
  live = new Map();
@@ -286,10 +388,14 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
286
388
  rawCache = pool;
287
389
  if (live)
288
390
  liveCache = live;
289
- agentsInPool = distinct(pool.rows.map((r) => r.agent));
290
- devicesInPool = distinct(pool.rows.map((r) => r.machine ?? self));
291
- const filtered = applyFilters(pool.rows, live ?? new Map(), f, self);
391
+ // Live sessions the transcript pool lacks become rows of their own, so the
392
+ // running view lists every active session, not just the ones already indexed.
393
+ const rows = f.running && live ? mergeLiveIntoPool(pool.rows, live, self) : pool.rows;
394
+ agentsInPool = distinct(rows.map((r) => r.agent));
395
+ devicesInPool = distinct(rows.map((r) => r.machine ?? self));
396
+ const filtered = applyFilters(rows, live ?? new Map(), f, self);
292
397
  cols = pickerColumnsFor(filtered);
398
+ cols.showHost = shouldShowHostColumn(f, live, filtered);
293
399
  return filtered;
294
400
  };
295
401
  const picked = await dynamicPicker({
@@ -297,7 +403,7 @@ export async function runSessionBrowser(initial = {}, opts = {}) {
297
403
  initialFilter,
298
404
  load,
299
405
  keyFor: (s) => s.id,
300
- labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id)),
406
+ labelFor: (s, q) => formatPickerLabel(s, q, cols, sshOriginTagFor(liveCache, s.id), liveHostLabel(liveCache?.get(s.id))),
301
407
  matches: sessionMatchesQuery,
302
408
  buildPreview,
303
409
  headerFor,
@@ -1,2 +1,5 @@
1
1
  import type { Command } from 'commander';
2
+ import type { SessionMeta } from '../lib/session/types.js';
2
3
  export declare function registerSessionsExportCommand(sessionsCmd: Command): void;
4
+ /** ids > query > everything-in-scope. Exported for the id-resolution test. */
5
+ export declare function selectSessions(metas: SessionMeta[], selectors: string[]): SessionMeta[];
@@ -20,7 +20,7 @@
20
20
  import * as fs from 'fs';
21
21
  import * as path from 'path';
22
22
  import chalk from 'chalk';
23
- import { discoverSessions, resolveSessionById, isCompleteSessionId } from '../lib/session/discover.js';
23
+ import { discoverSessions, resolveSessionById, looksLikeSessionId } from '../lib/session/discover.js';
24
24
  import { findSessionsById } from '../lib/session/db.js';
25
25
  import { filterSessionsByQuery, parseAgentFilter } from './sessions.js';
26
26
  import { listLocalTranscripts, SYNC_AGENTS } from '../lib/session/sync/agents.js';
@@ -222,8 +222,8 @@ function resolveAgentShorthand(g) {
222
222
  return 'antigravity';
223
223
  return undefined;
224
224
  }
225
- /** ids > query > everything-in-scope. */
226
- function selectSessions(metas, selectors) {
225
+ /** ids > query > everything-in-scope. Exported for the id-resolution test. */
226
+ export function selectSessions(metas, selectors) {
227
227
  if (selectors.length === 0)
228
228
  return metas;
229
229
  const byId = [];
@@ -231,9 +231,11 @@ function selectSessions(metas, selectors) {
231
231
  for (const sel of selectors) {
232
232
  const trimmed = sel.trim();
233
233
  // Same reason as resolveSessionQuery: the discovered pool is a minority of
234
- // the index, so a complete id absent from it may still be indexed here.
234
+ // the index, so an id-shaped selector absent from it may still be indexed
235
+ // here. Gate on looksLikeSessionId, not isCompleteSessionId, so a SHORT id
236
+ // ("d3470b57") also resolves through the index instead of falling to content.
235
237
  const hits = resolveSessionById(metas, trimmed);
236
- const resolved = hits.length > 0 || !isCompleteSessionId(trimmed) ? hits : findSessionsById(trimmed);
238
+ const resolved = hits.length > 0 || !looksLikeSessionId(trimmed) ? hits : findSessionsById(trimmed);
237
239
  if (resolved.length > 0)
238
240
  byId.push(...resolved);
239
241
  else
@@ -243,11 +245,12 @@ function selectSessions(metas, selectors) {
243
245
  const seen = new Set();
244
246
  return byId.filter(s => (seen.has(s.id) ? false : (seen.add(s.id), true)));
245
247
  }
246
- // A selector that is a COMPLETE session id and still missed cannot be widened:
247
- // the id is unique, so the text query below could only bundle sessions that
248
- // merely mention it. Bundling those would ship unrelated transcripts to
249
- // whoever receives the export, so select nothing and let the caller report it.
250
- const missingIds = unmatched.filter(isCompleteSessionId);
248
+ // A selector that is an id (complete OR a short hex id/prefix) and still missed
249
+ // cannot be widened: the id is unique, so the text query below could only bundle
250
+ // sessions that merely mention it. Bundling those would ship unrelated
251
+ // transcripts to whoever receives the export, so select nothing and let the
252
+ // caller report it — a short id like "d3470b57" must never content-search.
253
+ const missingIds = unmatched.filter(looksLikeSessionId);
251
254
  if (missingIds.length > 0) {
252
255
  process.stderr.write(chalk.red(`No session with id ${missingIds.join(', ')} on this machine.\n`));
253
256
  return [];
@@ -1,4 +1,5 @@
1
- import type { SessionMeta, TodoProgress } from '../lib/session/types.js';
1
+ import type { SessionEvent, SessionMeta, TodoProgress } from '../lib/session/types.js';
2
+ import { classifyFileChanges } from '../lib/session/digest.js';
2
3
  /**
3
4
  * Compact checklist tally for list rows and previews (RUSH-2045).
4
5
  * Example: `✓6/8 · A5 wiring runner`. Empty string when there is no list.
@@ -30,5 +31,18 @@ export interface SessionPickerConfig {
30
31
  }
31
32
  /** Build a cached multi-line preview string for display in the session picker. */
32
33
  export declare function buildPreview(session: SessionMeta): string;
34
+ /** Optional dirs-touched field the parser teammate may attach; we prefer it. */
35
+ type SessionMetaWithDirs = SessionMeta & {
36
+ dirsTouched?: string[];
37
+ };
38
+ /**
39
+ * Unique directories the session touched, compact and human-readable.
40
+ * Prefer `session.dirsTouched` when the parser teammate has populated it;
41
+ * otherwise derive from file-change + tool paths already available here.
42
+ */
43
+ export declare function directoriesTouched(session: SessionMetaWithDirs, events: SessionEvent[], changes: ReturnType<typeof classifyFileChanges>): string[];
44
+ /** Relativize a file path to its parent dir, short enough for one preview line. */
45
+ export declare function relativizeDir(filePath: string, cwd?: string): string | undefined;
33
46
  /** Show an interactive session picker and return the selected session with its action (resume or view). */
34
47
  export declare function sessionPicker(config: SessionPickerConfig): Promise<PickedSession | null>;
48
+ export {};
@@ -13,7 +13,7 @@ import { parseSession, sanitizeForTerminal } from '../lib/session/parse.js';
13
13
  import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.js';
14
14
  import { linkPath, linkUrl, relativeToCwd } from '../lib/session/render.js';
15
15
  import { linearIssueUrl } from '../lib/session/linear.js';
16
- import { extractTodoProgress } from '../lib/session/state.js';
16
+ import { extractTodoProgress, WORKTREE_RE } from '../lib/session/state.js';
17
17
  import { renderMarkdown } from '../lib/markdown.js';
18
18
  import { itemPicker } from '../lib/picker.js';
19
19
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
@@ -408,7 +408,7 @@ function formatCompactPreview(events, session) {
408
408
  * Prefer `session.dirsTouched` when the parser teammate has populated it;
409
409
  * otherwise derive from file-change + tool paths already available here.
410
410
  */
411
- function directoriesTouched(session, events, changes) {
411
+ export function directoriesTouched(session, events, changes) {
412
412
  const fromMeta = session.dirsTouched;
413
413
  if (Array.isArray(fromMeta) && fromMeta.length > 0) {
414
414
  return fromMeta
@@ -437,19 +437,73 @@ function directoriesTouched(session, events, changes) {
437
437
  .map(([d]) => d)
438
438
  .slice(0, DIRS_TOUCHED_MAX);
439
439
  }
440
+ /**
441
+ * Claude names its per-project transcript store `~/.claude/projects/<slug>`, where
442
+ * `<slug>` is the cwd with every `/` AND `.` replaced by `-` — so `/Users/me/app`
443
+ * → `-Users-me-app` and `.agents/worktrees/x` → `--agents-worktrees-x`. That slug
444
+ * leaks into transcript paths as a leading segment (`<slug>/<session-id>/…`).
445
+ *
446
+ * The encoding is LOSSY and irreversible (`-`, `/`, and `.` all collapse to `-`),
447
+ * so we never try to decode it back to a path. Instead we ENCODE the comparison
448
+ * targets (cwd, the worktree marker) into the same slug space and match there. See
449
+ * {@link relativizeDir}. `encodeClaudeSlug` mirrors Claude's own transform.
450
+ */
451
+ function encodeClaudeSlug(absPath) {
452
+ return absPath.replace(/[/.]/g, '-');
453
+ }
454
+ /** The `.agents/worktrees/<name>` marker, Claude-slug-encoded (`/.` → `--`). */
455
+ const SLUG_WORKTREE_RE = /--agents-worktrees-(.+)$/;
440
456
  /** Relativize a file path to its parent dir, short enough for one preview line. */
441
- function relativizeDir(filePath, cwd) {
457
+ export function relativizeDir(filePath, cwd) {
442
458
  const norm = filePath.replace(/\\/g, '/');
443
459
  if (!norm || norm.includes('node_modules') || norm.includes('/.git/') || norm.includes('/plans/')) {
444
460
  return undefined;
445
461
  }
446
462
  let dir = path.posix.dirname(norm);
463
+ // Claude project-slug form: a leading `-`-segment (`-home-me-…`) that carries
464
+ // the cwd, lossily encoded. Handle it in SLUG SPACE — never lossy-decode to a
465
+ // fake path. The slug is the first path segment; any real `/`-subdirs after it
466
+ // are Claude's internal storage (`<session-id>/scratchpad|tasks`), not code.
467
+ if (dir.startsWith('-')) {
468
+ const slash = dir.indexOf('/');
469
+ const slug = slash === -1 ? dir : dir.slice(0, slash);
470
+ // CWD FIRST: if the slug is (or is under) this session's own cwd, the leaked
471
+ // path is Claude's internal projects-storage scratch (`<id>/scratchpad`) —
472
+ // not a meaningful code dir — so drop it like node_modules. Precedence over
473
+ // the worktree collapse so a session editing its OWN worktree isn't relabeled.
474
+ if (cwd) {
475
+ const cwdSlug = encodeClaudeSlug(cwd.replace(/\\/g, '/').replace(/\/$/, ''));
476
+ if (slug === cwdSlug || slug.startsWith(cwdSlug + '-'))
477
+ return undefined;
478
+ }
479
+ // Only a DIFFERENT worktree than cwd reaches here: a worktree encodes its
480
+ // `/.agents/worktrees/<name>` marker as `--agents-worktrees-<name>`, so
481
+ // collapse to the worktree name to disambiguate. (The name may contain `-`;
482
+ // we can't losslessly re-split it, so show the whole encoded remainder.)
483
+ const wtSlug = slug.match(SLUG_WORKTREE_RE);
484
+ if (wtSlug)
485
+ return `⧉ ${wtSlug[1]}`;
486
+ // An unattributable slug: don't invent a `/`-joined fake path. Show only the
487
+ // trailing `-`-group as a minimal, honest token.
488
+ const segs = slug.split('-').filter(Boolean);
489
+ return segs.length ? segs[segs.length - 1] : undefined;
490
+ }
491
+ // CWD FIRST for real paths too: strip the session-cwd prefix so a session
492
+ // editing its OWN worktree renders the concise relative remainder (`src/lib`),
493
+ // not the longer `⧉ <slug>/…` collapse.
447
494
  if (cwd) {
448
495
  const base = cwd.replace(/\\/g, '/').replace(/\/$/, '');
449
496
  if (dir === base)
450
497
  return '.';
451
498
  if (dir.startsWith(base + '/'))
452
- dir = dir.slice(base.length + 1);
499
+ return dir.slice(base.length + 1);
500
+ }
501
+ // No cwd match. If the dir is in a (different) git worktree, collapse to the
502
+ // worktree NAME + in-worktree remainder to disambiguate; else home→`~` + trim.
503
+ const wt = dir.match(WORKTREE_RE);
504
+ if (wt) {
505
+ const after = dir.slice(dir.indexOf(wt[0]) + wt[0].length).replace(/^\//, '');
506
+ return after ? `⧉ ${wt[1]}/${after}` : `⧉ ${wt[1]}`;
453
507
  }
454
508
  // Collapse home prefix.
455
509
  const home = (process.env.HOME || '').replace(/\\/g, '/');
@@ -1,6 +1,7 @@
1
1
  import type { Command } from 'commander';
2
2
  import type { SessionAgentId, SessionMeta, ViewMode } from '../lib/session/types.js';
3
3
  import { type ActiveSession } from '../lib/session/active.js';
4
+ import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
4
5
  import { type PickedSession } from './sessions-picker.js';
5
6
  /**
6
7
  * Strip terminal/harness noise from a preview so the column stays a single line
@@ -150,6 +151,25 @@ export declare function shouldIncludeLocal(hosts: string[] | undefined, self: st
150
151
  * Exported for unit testing.
151
152
  */
152
153
  export declare function remoteHostsToDial(hosts: string[] | undefined, self: string): string[] | undefined;
154
+ /**
155
+ * The fleet-wide live-session set behind every `--active` surface. Local sessions
156
+ * come from `getActiveSessions()` and (unless `--local`) the registered online
157
+ * devices from `ag devices` are folded in over SSH. An explicit `--host`/`--device`
158
+ * list SCOPES the sweep to exactly those machines — the local machine is included
159
+ * only when it is itself named — so `--host` is a filter, not an addition.
160
+ *
161
+ * This is the single gather: the static renderer AND the interactive browser both
162
+ * call it, so the browser can never disagree with `--active --json` about which
163
+ * sessions are live (it used to call the local-only `getActiveSessions()` directly
164
+ * and silently hid every remote session).
165
+ */
166
+ export declare function gatherActiveSessions(opts?: {
167
+ local?: boolean;
168
+ hosts?: string[];
169
+ }): Promise<{
170
+ sessions: ActiveSession[];
171
+ remoteDeviceCount: number;
172
+ }>;
153
173
  /**
154
174
  * Group key for the overview: prefer the indexed project name; else fold the cwd
155
175
  * to its repo — a worktree (`.../<repo>/.agents/worktrees/<slug>`) folds to the
@@ -203,6 +223,13 @@ export interface PickerColumns {
203
223
  machineWidth?: number;
204
224
  /** Render the ticket/PR column (only when at least one row carries a ref). */
205
225
  showTicket?: boolean;
226
+ /**
227
+ * Render the host-program column — which terminal/editor the session is running
228
+ * in (`ghostty`, `codium`, `tmux→ghostty`, …). Live-only: it comes from the
229
+ * active-session scan, so it is set by the running-filtered browser and stays
230
+ * off for a plain transcript listing, where no row has a host.
231
+ */
232
+ showHost?: boolean;
206
233
  /**
207
234
  * Cells the picker prepends before each row: 2 for the single-select cursor
208
235
  * ('> '), 6 for the multi-select cursor + checkbox ('> [x] '). Reserved from
@@ -223,13 +250,30 @@ export declare function machineLabeler(machines: string[]): (m: string) => strin
223
250
  * than one box, the ticket column only when some row carries a PR/ticket ref.
224
251
  */
225
252
  export declare function pickerColumnsFor(sessions: SessionMeta[]): PickerColumns;
226
- export declare function formatPickerLabel(s: SessionMeta, query: string, cols?: PickerColumns, ssh?: SshOriginTag): string;
253
+ /**
254
+ * Which program a live session is running in, for the picker's host column: the
255
+ * immediate host app (`codium`, `ghostty`, `tmux`, `iterm`, …) and — when a tmux
256
+ * session is currently being watched through a different app — the app it is
257
+ * viewed in, as `tmux→ghostty`. A tmux session with no attached client stays a
258
+ * bare `tmux`, which is exactly "running detached". Empty when the session has no
259
+ * resolvable host (cloud rows, an unreadable process env).
260
+ */
261
+ export declare function liveHostLabel(a: ActiveSession | undefined): string;
262
+ export declare function formatPickerLabel(s: SessionMeta, query: string, cols?: PickerColumns, ssh?: SshOriginTag, host?: string): string;
227
263
  /**
228
264
  * Pick a hint to show above the picker. Deterministic (keys off the pool size)
229
265
  * so it stays fixed across the picker's re-renders within a single run.
230
266
  */
231
267
  export declare function formatPickerTip(sessions: SessionMeta[]): string;
232
268
  export declare function pickSessionInteractive(sessions: SessionMeta[], message?: string, initialSearch?: string, hiddenCount?: number, enterHint?: string): Promise<PickedSession | null>;
269
+ /**
270
+ * Row-id prefix for a live session whose agent has not reported a session id yet
271
+ * (a booting harness, a queued teammate). The browser lists these so nothing live
272
+ * is hidden, but they address no transcript — {@link isIdlessLiveRow} is what the
273
+ * pick handler checks before trying to read or resume one.
274
+ */
275
+ export declare const LIVE_ROW_PREFIX = "live:";
276
+ export declare function isIdlessLiveRow(s: SessionMeta): boolean;
233
277
  export declare function handlePickedSession(picked: PickedSession): Promise<void>;
234
278
  /**
235
279
  * Resume a session in the current terminal — a foreground takeover of this
@@ -285,6 +329,56 @@ export interface SessionQueryResolution {
285
329
  export declare function resolveSessionQuery(pool: SessionMeta[], query: string): SessionQueryResolution;
286
330
  /** Filter and rank sessions by a multi-term search query across metadata and content. */
287
331
  export declare function filterSessionsByQuery(sessions: SessionMeta[], query: string | undefined): SessionMeta[];
332
+ /**
333
+ * Whether a missed local id lookup should widen to a cross-machine sweep.
334
+ *
335
+ * Gate (all must hold):
336
+ * - the query is id-shaped (`looksLikeSessionId`) — only an identifier resolves
337
+ * across the fleet; a search phrase never does.
338
+ * - not `--local` — the caller opted out of cross-machine lookup (deterministic
339
+ * local behavior for scripts, RUSH-2024 acceptance: "--local still restricts").
340
+ * - `AGENTS_SESSIONS_LOCAL` is unset — we are not ourselves a peer answering a
341
+ * parent's sweep, so a locate can never recurse (RUSH-2024: avoid double-fan-out).
342
+ *
343
+ * Pure + exported so the gate is unit-tested without driving discovery / SSH.
344
+ */
345
+ export declare function shouldFanOutForId(query: string, local: boolean | undefined): boolean;
346
+ /** Injectable SSH/peer boundary so the fleet-resolve logic is unit-testable
347
+ * without a live tailnet. Production wires these to the real remote-list infra. */
348
+ export interface FleetResolveDeps {
349
+ gatherRemoteList: typeof gatherRemoteList;
350
+ runOnPeer: typeof runOnPeer;
351
+ }
352
+ /** One distinct machine that reported the id, plus its winning row. */
353
+ interface FleetHit {
354
+ machine: string;
355
+ session: SessionMeta;
356
+ }
357
+ /** Group a fleet sweep's rows to the DISTINCT machines that hold the id. Each
358
+ * peer answered `sessions <id> --json --local`, which (post-fix) id-resolves and
359
+ * so returns the matching row(s); a peer with a synced MIRROR of the same id can
360
+ * emit more than one row, so we keep the first per machine. Rows the peer somehow
361
+ * returned that do NOT match the id (defensive against version skew) are dropped
362
+ * so a stray content hit can never masquerade as an exact resolution. */
363
+ export declare function fleetHitsById(rows: SessionMeta[], id: string): FleetHit[];
364
+ /**
365
+ * Locate a full session id across the online fleet and render it from the machine
366
+ * that holds it. The local disk already missed; this fans `sessions <id> --json
367
+ * --all` out to every registered online peer (or the explicit `hosts` set),
368
+ * groups the rows to distinct machines, then:
369
+ *
370
+ * - exactly one machine → delegate rendering to that peer via `runOnPeer`
371
+ * (its transcript and agent binary live there — a local `--host` hop would
372
+ * re-discover locally and dead-end), returning `'rendered'`.
373
+ * - more than one machine → print the conflict with machine labels so the user
374
+ * can disambiguate with `--device <host>`, returning `'conflict'`.
375
+ * - none → `'not-found'`, letting the caller print the local
376
+ * "no session on this machine" message.
377
+ *
378
+ * No fuzzy/content fallback: the sweep forwards a UUID, each peer id-resolves it,
379
+ * and `fleetHitsById` drops anything that isn't an exact id match.
380
+ */
381
+ export declare function resolveSessionAcrossFleet(id: string, mode: ViewMode, hosts?: string[], deps?: FleetResolveDeps): Promise<'rendered' | 'conflict' | 'not-found'>;
288
382
  /** Register the `agents sessions` command with all its options and help text. */
289
383
  export declare function registerSessionsCommands(program: Command): void;
290
384
  export {};