@phnx-labs/agents-cli 1.20.76 → 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 (119) hide show
  1. package/CHANGELOG.md +264 -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 +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -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 +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. package/package.json +1 -1
@@ -6,12 +6,14 @@
6
6
  * response) and delegates to the generic `itemPicker` for the interactive UI.
7
7
  */
8
8
  import fs from 'node:fs';
9
+ import path from 'node:path';
9
10
  import chalk from 'chalk';
10
11
  import { truncate, humanDuration } from '../lib/format.js';
11
12
  import { parseSession, sanitizeForTerminal } from '../lib/session/parse.js';
12
13
  import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.js';
13
14
  import { linkPath, linkUrl, relativeToCwd } from '../lib/session/render.js';
14
15
  import { linearIssueUrl } from '../lib/session/linear.js';
16
+ import { extractTodoProgress, WORKTREE_RE } from '../lib/session/state.js';
15
17
  import { renderMarkdown } from '../lib/markdown.js';
16
18
  import { itemPicker } from '../lib/picker.js';
17
19
  import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } from '../lib/session/digest.js';
@@ -23,6 +25,32 @@ import { classifyFileChanges, changeCounts, toolHistogram, detectTestResult } fr
23
25
  function remoteMachineOf(session) {
24
26
  return session._remote ? session.machine : undefined;
25
27
  }
28
+ /**
29
+ * Compact checklist tally for list rows and previews (RUSH-2045).
30
+ * Example: `✓6/8 · A5 wiring runner`. Empty string when there is no list.
31
+ * Consumes `SessionMeta.todos` / `ActiveSession.todos` as populated by the
32
+ * state engine — does not re-parse transcripts.
33
+ */
34
+ export function formatTodoCompact(todos) {
35
+ if (!todos || !Number.isFinite(todos.total) || todos.total < 1)
36
+ return '';
37
+ const done = Number.isFinite(todos.done) ? Math.max(0, todos.done) : 0;
38
+ const tally = `✓${done}/${todos.total}`;
39
+ const step = todos.activeForm?.replace(/\s+/g, ' ').trim();
40
+ return step ? `${tally} · ${step}` : tally;
41
+ }
42
+ /**
43
+ * Best-effort GitHub repo URL from a checkout path shaped like
44
+ * `…/github.com/<owner>/<repo>/…`. Used to make the project name clickable
45
+ * when no Linear project URL is available.
46
+ */
47
+ export function githubRepoUrlFromCwd(cwd) {
48
+ if (!cwd)
49
+ return undefined;
50
+ const norm = cwd.replace(/\\/g, '/');
51
+ const m = norm.match(/\/github\.com\/([^/]+\/[^/]+)/);
52
+ return m ? `https://github.com/${m[1]}` : undefined;
53
+ }
26
54
  /**
27
55
  * SessionMeta originates in discover.ts (gitBranch, cwd, label, etc. read from
28
56
  * untrusted session files). parseSession sanitizes event payloads at its
@@ -31,6 +59,17 @@ function remoteMachineOf(session) {
31
59
  */
32
60
  function sanitizeMeta(s) {
33
61
  const clean = (v) => (v == null ? v : sanitizeForTerminal(v));
62
+ const todos = s.todos
63
+ ? {
64
+ ...s.todos,
65
+ activeForm: clean(s.todos.activeForm),
66
+ items: s.todos.items.map((it) => ({
67
+ ...it,
68
+ content: sanitizeForTerminal(it.content),
69
+ activeForm: clean(it.activeForm),
70
+ })),
71
+ }
72
+ : s.todos;
34
73
  return {
35
74
  ...s,
36
75
  id: sanitizeForTerminal(s.id),
@@ -45,6 +84,7 @@ function sanitizeMeta(s) {
45
84
  label: clean(s.label),
46
85
  ticketId: clean(s.ticketId),
47
86
  prUrl: clean(s.prUrl),
87
+ todos,
48
88
  };
49
89
  }
50
90
  const previewCache = new Map();
@@ -62,7 +102,8 @@ export function buildPreview(session) {
62
102
  if (remote) {
63
103
  const note = ' ' + chalk.gray(`on `) + chalk.bold.white(remote)
64
104
  + chalk.gray(` — enter to resume there, or space then enter to read it over SSH`);
65
- const output = [formatHeader(safe, []), '', note].join('\n');
105
+ const metaBody = formatMetaOnlyBody(safe);
106
+ const output = [formatHeader(safe, []), '', note, metaBody].filter(Boolean).join('\n');
66
107
  previewCache.set(cacheKey, output);
67
108
  return output;
68
109
  }
@@ -70,7 +111,8 @@ export function buildPreview(session) {
70
111
  // entry (e.g. `sessions go`). Show the header + a clean note, not a parse error.
71
112
  if (!session.filePath || !fs.existsSync(session.filePath)) {
72
113
  const note = ' ' + chalk.gray('Live session — full transcript not indexed here.');
73
- const output = [formatHeader(safe, []), '', note].filter(Boolean).join('\n');
114
+ const metaBody = formatMetaOnlyBody(safe);
115
+ const output = [formatHeader(safe, []), '', note, metaBody].filter(Boolean).join('\n');
74
116
  previewCache.set(cacheKey, output);
75
117
  return output;
76
118
  }
@@ -99,19 +141,27 @@ function formatHeader(session, events) {
99
141
  const { startedAgo, duration } = extractTiming(events);
100
142
  const totalMessages = session.messageCount ?? countMessages(events);
101
143
  const totalTokens = session.tokenCount;
102
- // Line 1: Agent v version · model · account
144
+ // Line 1: Agent v version · shortId · model · account
103
145
  const line1 = [];
104
146
  line1.push(chalk.gray(`${displayAgent(session.agent)}${session.version ? ` v${session.version}` : ''}`));
147
+ if (session.shortId)
148
+ line1.push(chalk.dim(session.shortId));
105
149
  if (model)
106
150
  line1.push(chalk.bold.white(model));
107
151
  if (session.account)
108
152
  line1.push(chalk.gray(session.account));
109
- // Line 2: cwd · branch · started X ago · lasted Y
153
+ // Line 2: cwd · project · branch · started X ago · lasted Y
154
+ // Project is clickable: Linear issue URL is for tickets (line 4); for the
155
+ // project name prefer a GitHub repo URL derived from the checkout path.
110
156
  const line2 = [];
111
157
  if (session.cwd) {
112
158
  const label = relativeToCwd(session.cwd);
113
159
  line2.push(chalk.bold.white(linkPath(session.cwd, label)));
114
160
  }
161
+ if (session.project) {
162
+ const repoUrl = githubRepoUrlFromCwd(session.cwd);
163
+ line2.push(chalk.cyan(repoUrl ? linkUrl(repoUrl, session.project) : session.project));
164
+ }
115
165
  if (session.gitBranch)
116
166
  line2.push(chalk.cyan(session.gitBranch));
117
167
  if (startedAgo)
@@ -128,7 +178,10 @@ function formatHeader(session, events) {
128
178
  }
129
179
  if (session.label)
130
180
  line3.push(chalk.white(session.label));
131
- line3.push(chalk.gray(linkPath(session.filePath, session.id)));
181
+ if (session.filePath)
182
+ line3.push(chalk.gray(linkPath(session.filePath, session.id)));
183
+ else
184
+ line3.push(chalk.gray(session.id));
132
185
  // Line 4: ticket + PR — clickable when a URL is resolvable (OSC 8 hyperlink),
133
186
  // plain text otherwise. Only rendered when the session carries either.
134
187
  const line4 = [];
@@ -147,6 +200,24 @@ function formatHeader(session, events) {
147
200
  ...(line4.length ? [line4.join(DOT)] : []),
148
201
  ].join('\n');
149
202
  }
203
+ /**
204
+ * Body lines available from SessionMeta alone (no transcript parse) — used for
205
+ * remote / unindexed sessions so checklist progress still surfaces when the
206
+ * parser teammate (or a prior scan) has populated `session.todos`.
207
+ */
208
+ function formatMetaOnlyBody(session) {
209
+ const lines = [];
210
+ if (session.topic) {
211
+ lines.push(chalk.cyan('Prompt: ') + chalk.white(truncate(session.topic.trim(), (process.stdout.columns || 80) - 12)));
212
+ }
213
+ const compact = formatTodoCompact(session.todos);
214
+ if (compact) {
215
+ lines.push(chalk.cyan('Todos: ') + chalk.white(compact));
216
+ }
217
+ if (lines.length === 0)
218
+ return '';
219
+ return lines.map(l => ' ' + l).join('\n');
220
+ }
150
221
  function extractModel(events) {
151
222
  for (let i = events.length - 1; i >= 0; i--) {
152
223
  const m = events[i].model;
@@ -204,6 +275,7 @@ function stripTags(text) {
204
275
  const LAST_RESPONSE_MAX_LINES = 15;
205
276
  const LAST_RESPONSE_MAX_LINES_WITH_TODOS = 8;
206
277
  const TODOS_MAX_ITEMS = 5;
278
+ const DIRS_TOUCHED_MAX = 5;
207
279
  function formatCompactPreview(events, session) {
208
280
  let firstUser = '';
209
281
  let lastAssistant = '';
@@ -211,7 +283,8 @@ function formatCompactPreview(events, session) {
211
283
  const toolCounts = {};
212
284
  let toolCalls = 0;
213
285
  let planFile = '';
214
- let latestTodos = null;
286
+ /** Latest checklist from the transcript (Claude TodoWrite / Codex update_plan). */
287
+ let latestTodos;
215
288
  for (const event of events) {
216
289
  if (event.type === 'message') {
217
290
  if (event.role === 'user' && !firstUser && event.content) {
@@ -232,25 +305,53 @@ function formatCompactPreview(events, session) {
232
305
  if (!planFile && p && /\/plans\/[^/]+\.md$/.test(p)) {
233
306
  planFile = p;
234
307
  }
235
- if (tool === 'TodoWrite' && Array.isArray(event.args?.todos)) {
236
- latestTodos = event.args.todos;
308
+ // Claude TodoWrite (`todos`) and Codex update_plan (`plan`) — same source as
309
+ // extractTodoProgress in the state engine. Prefer the most recent write.
310
+ if (tool === 'TodoWrite' || tool === 'update_plan') {
311
+ const progress = extractTodoProgress(event.args);
312
+ if (progress)
313
+ latestTodos = progress;
237
314
  }
238
315
  if (tool)
239
316
  toolCounts[tool] = (toolCounts[tool] ?? 0) + 1;
240
317
  toolCalls++;
241
318
  }
242
319
  }
320
+ // Prefer the transcript-derived list when we just re-parsed the file (freshest
321
+ // checklist write); fall back to SessionMeta.todos for rows where the scan/
322
+ // fan-out attached progress but the event stream has no TodoWrite yet.
323
+ const todos = latestTodos ?? session.todos;
243
324
  // Digest signals folded into the preview: change lifecycle, tool mix, tests.
244
325
  const changes = classifyFileChanges(events);
245
326
  const chg = changeCounts(changes);
246
327
  const lines = [];
247
328
  const termWidth = process.stdout.columns || 80;
329
+ // Originating user prompt (first non-system user turn).
248
330
  if (firstUser) {
249
331
  const first = extractSessionTopic(firstUser) || cleanSessionPrompt(firstUser).split('\n').find(l => l.trim()) || '';
250
332
  if (first) {
251
333
  lines.push(chalk.cyan('Prompt: ') + chalk.white(truncate(first.trim(), termWidth - 12)));
252
334
  }
253
335
  }
336
+ else if (session.topic) {
337
+ lines.push(chalk.cyan('Prompt: ') + chalk.white(truncate(session.topic.trim(), termWidth - 12)));
338
+ }
339
+ // Compact checklist: ✓done/total · current step (RUSH-2045).
340
+ const compact = formatTodoCompact(todos);
341
+ if (compact) {
342
+ lines.push(chalk.cyan('Todos: ') + chalk.white(compact));
343
+ }
344
+ const todosRendered = todos?.items?.length ? renderTodos(todos.items, termWidth) : [];
345
+ if (todosRendered.length > 0) {
346
+ for (const l of todosRendered)
347
+ lines.push(' ' + l);
348
+ }
349
+ // Recent activity = directories touched (not raw tool calls). Prefer a
350
+ // parser-supplied dirsTouched when present; else derive from event paths.
351
+ const dirs = directoriesTouched(session, events, changes);
352
+ if (dirs.length) {
353
+ lines.push(chalk.cyan('Dirs: ') + chalk.white(dirs.join(chalk.gray(' · '))));
354
+ }
254
355
  const activity = [];
255
356
  const changed = chg.created + chg.modified + chg.deleted;
256
357
  if (changed) {
@@ -287,14 +388,8 @@ function formatCompactPreview(events, session) {
287
388
  const basename = planFile.split('/').pop() || planFile;
288
389
  lines.push(chalk.cyan('Plan: ') + chalk.white(linkPath(planFile, basename)));
289
390
  }
290
- const todosRendered = latestTodos ? renderTodos(latestTodos, termWidth) : [];
291
- if (todosRendered.length > 0) {
292
- lines.push(chalk.cyan('Todos:'));
293
- for (const l of todosRendered)
294
- lines.push(' ' + l);
295
- }
296
391
  if (lastAssistant) {
297
- const maxLines = todosRendered.length > 0 ? LAST_RESPONSE_MAX_LINES_WITH_TODOS : LAST_RESPONSE_MAX_LINES;
392
+ const maxLines = todosRendered.length > 0 || compact ? LAST_RESPONSE_MAX_LINES_WITH_TODOS : LAST_RESPONSE_MAX_LINES;
298
393
  const rendered = renderLastResponse(lastAssistant, maxLines);
299
394
  if (rendered.length > 0) {
300
395
  lines.push('');
@@ -308,6 +403,118 @@ function formatCompactPreview(events, session) {
308
403
  }
309
404
  return lines.map(l => ' ' + l).join('\n');
310
405
  }
406
+ /**
407
+ * Unique directories the session touched, compact and human-readable.
408
+ * Prefer `session.dirsTouched` when the parser teammate has populated it;
409
+ * otherwise derive from file-change + tool paths already available here.
410
+ */
411
+ export function directoriesTouched(session, events, changes) {
412
+ const fromMeta = session.dirsTouched;
413
+ if (Array.isArray(fromMeta) && fromMeta.length > 0) {
414
+ return fromMeta
415
+ .map((d) => sanitizeForTerminal(String(d).trim()))
416
+ .filter(Boolean)
417
+ .slice(0, DIRS_TOUCHED_MAX);
418
+ }
419
+ const counts = new Map();
420
+ const bump = (raw) => {
421
+ const dir = relativizeDir(raw, session.cwd);
422
+ if (!dir)
423
+ return;
424
+ counts.set(dir, (counts.get(dir) ?? 0) + 1);
425
+ };
426
+ for (const ch of changes)
427
+ bump(ch.path);
428
+ for (const event of events) {
429
+ if (event.type !== 'tool_use' || event._local)
430
+ continue;
431
+ const p = event.path || event.args?.file_path || event.args?.path || '';
432
+ if (p)
433
+ bump(p);
434
+ }
435
+ return [...counts.entries()]
436
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
437
+ .map(([d]) => d)
438
+ .slice(0, DIRS_TOUCHED_MAX);
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-(.+)$/;
456
+ /** Relativize a file path to its parent dir, short enough for one preview line. */
457
+ export function relativizeDir(filePath, cwd) {
458
+ const norm = filePath.replace(/\\/g, '/');
459
+ if (!norm || norm.includes('node_modules') || norm.includes('/.git/') || norm.includes('/plans/')) {
460
+ return undefined;
461
+ }
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.
494
+ if (cwd) {
495
+ const base = cwd.replace(/\\/g, '/').replace(/\/$/, '');
496
+ if (dir === base)
497
+ return '.';
498
+ if (dir.startsWith(base + '/'))
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]}`;
507
+ }
508
+ // Collapse home prefix.
509
+ const home = (process.env.HOME || '').replace(/\\/g, '/');
510
+ if (home && dir.startsWith(home + '/'))
511
+ dir = '~' + dir.slice(home.length);
512
+ // Drop ultra-deep absolute noise; keep last 3 segments.
513
+ const parts = dir.split('/').filter(Boolean);
514
+ if (parts.length > 3 && dir.startsWith('/'))
515
+ dir = parts.slice(-3).join('/');
516
+ return dir || undefined;
517
+ }
311
518
  function renderLastResponse(content, maxLines = LAST_RESPONSE_MAX_LINES) {
312
519
  const cleaned = stripTags(content).trim();
313
520
  if (!cleaned)
@@ -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
@@ -9,6 +10,18 @@ import { type PickedSession } from './sessions-picker.js';
9
10
  * a captured transcript tail. Collapses runs of whitespace.
10
11
  */
11
12
  export declare function cleanPreview(text: string): string;
13
+ /**
14
+ * Identity + checklist + live snippet for an --active / cross-machine row.
15
+ * Surfaces agent-adjacent identity the flat table already has (label, project)
16
+ * with a clickable project when a GitHub URL is resolvable, then the checklist
17
+ * tally and the latest-turn snippet.
18
+ *
19
+ * Free-text fields are cleaned individually; OSC 8 hyperlinks are applied
20
+ * *after* cleaning so `cleanPreview` does not strip the clickable targets
21
+ * (RUSH-2045 review). Ticket is clickable via {@link signalBadges}, not here,
22
+ * so the id is not printed twice.
23
+ */
24
+ export declare function formatActiveRowDescription(s: ActiveSession): string;
12
25
  /**
13
26
  * Index live sessions by their full session UUID so a historical `SessionMeta`
14
27
  * row (`meta.id`) can be matched to the session that is still running now.
@@ -138,6 +151,25 @@ export declare function shouldIncludeLocal(hosts: string[] | undefined, self: st
138
151
  * Exported for unit testing.
139
152
  */
140
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
+ }>;
141
173
  /**
142
174
  * Group key for the overview: prefer the indexed project name; else fold the cwd
143
175
  * to its repo — a worktree (`.../<repo>/.agents/worktrees/<slug>`) folds to the
@@ -175,6 +207,12 @@ export declare function renderSessionLog(session: SessionMeta, mode?: ViewMode):
175
207
  */
176
208
  export declare function renderSessionLogJson(session: SessionMeta): Promise<void>;
177
209
  /** Column-visibility flags for the picker row, computed once over the whole pool. */
210
+ /** The SSH-launch origin for a picker row, resolved from the live session's
211
+ * provenance (transport 'ssh'). `device` is set when the client IP matched a
212
+ * registered device; absent for an unresolved IP (renders a bare `ssh`). */
213
+ export interface SshOriginTag {
214
+ device?: string;
215
+ }
178
216
  export interface PickerColumns {
179
217
  /** Render the machine column (only when the pool spans more than one machine). */
180
218
  showMachine?: boolean;
@@ -185,6 +223,13 @@ export interface PickerColumns {
185
223
  machineWidth?: number;
186
224
  /** Render the ticket/PR column (only when at least one row carries a ref). */
187
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;
188
233
  /**
189
234
  * Cells the picker prepends before each row: 2 for the single-select cursor
190
235
  * ('> '), 6 for the multi-select cursor + checkbox ('> [x] '). Reserved from
@@ -205,13 +250,30 @@ export declare function machineLabeler(machines: string[]): (m: string) => strin
205
250
  * than one box, the ticket column only when some row carries a PR/ticket ref.
206
251
  */
207
252
  export declare function pickerColumnsFor(sessions: SessionMeta[]): PickerColumns;
208
- export declare function formatPickerLabel(s: SessionMeta, query: string, cols?: PickerColumns): 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;
209
263
  /**
210
264
  * Pick a hint to show above the picker. Deterministic (keys off the pool size)
211
265
  * so it stays fixed across the picker's re-renders within a single run.
212
266
  */
213
267
  export declare function formatPickerTip(sessions: SessionMeta[]): string;
214
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;
215
277
  export declare function handlePickedSession(picked: PickedSession): Promise<void>;
216
278
  /**
217
279
  * Resume a session in the current terminal — a foreground takeover of this
@@ -259,13 +321,64 @@ export interface SessionQueryResolution {
259
321
  /**
260
322
  * The single entry point for turning a `sessions <query>` argument into rows.
261
323
  *
262
- * A complete session id resolves by id alone. Anything else keeps the existing
263
- * ladder: id lookup first (so a short id still wins), then the ranked
264
- * metadata+content search.
324
+ * An id-shaped query a complete id OR a hex short-id/prefix (looksLikeSessionId)
325
+ * resolves by id alone, through the index, and never falls back to content
326
+ * search (a bare id must not surface every transcript that merely mentions it).
327
+ * A genuine search phrase keeps the ranked metadata+content search.
265
328
  */
266
329
  export declare function resolveSessionQuery(pool: SessionMeta[], query: string): SessionQueryResolution;
267
330
  /** Filter and rank sessions by a multi-term search query across metadata and content. */
268
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'>;
269
382
  /** Register the `agents sessions` command with all its options and help text. */
270
383
  export declare function registerSessionsCommands(program: Command): void;
271
384
  export {};