@phnx-labs/agents-cli 1.20.85 → 1.20.87

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 (53) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/events.d.ts +16 -0
  4. package/dist/commands/events.js +44 -5
  5. package/dist/commands/models.js +1 -1
  6. package/dist/commands/sessions-browser.d.ts +18 -0
  7. package/dist/commands/sessions-browser.js +126 -24
  8. package/dist/commands/sessions-picker.d.ts +21 -8
  9. package/dist/commands/sessions-picker.js +88 -11
  10. package/dist/commands/sessions.d.ts +19 -0
  11. package/dist/commands/sessions.js +147 -18
  12. package/dist/commands/ssh.js +59 -1
  13. package/dist/commands/teams-picker.d.ts +2 -0
  14. package/dist/commands/teams-picker.js +2 -1
  15. package/dist/commands/teams.d.ts +4 -1
  16. package/dist/commands/teams.js +106 -70
  17. package/dist/commands/view.js +14 -3
  18. package/dist/index.js +31 -1
  19. package/dist/lib/claude-account-token.d.ts +12 -0
  20. package/dist/lib/claude-account-token.js +63 -0
  21. package/dist/lib/devices/registry.d.ts +25 -0
  22. package/dist/lib/devices/registry.js +82 -1
  23. package/dist/lib/events.d.ts +8 -1
  24. package/dist/lib/events.js +13 -0
  25. package/dist/lib/exec.js +10 -1
  26. package/dist/lib/format.d.ts +7 -0
  27. package/dist/lib/format.js +11 -0
  28. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  29. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  30. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  31. package/dist/lib/models.d.ts +21 -0
  32. package/dist/lib/models.js +133 -4
  33. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  34. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  35. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +2 -2
  37. package/dist/lib/session/db.d.ts +21 -0
  38. package/dist/lib/session/db.js +45 -4
  39. package/dist/lib/session/parse.d.ts +11 -0
  40. package/dist/lib/session/parse.js +24 -7
  41. package/dist/lib/session/remote-list.d.ts +7 -0
  42. package/dist/lib/session/remote-list.js +8 -4
  43. package/dist/lib/session/state.d.ts +6 -5
  44. package/dist/lib/session/state.js +84 -11
  45. package/dist/lib/session/team-filter.d.ts +22 -3
  46. package/dist/lib/session/team-filter.js +106 -17
  47. package/dist/lib/session/types.d.ts +8 -0
  48. package/dist/lib/signin-badge.d.ts +17 -0
  49. package/dist/lib/signin-badge.js +19 -0
  50. package/dist/lib/state.d.ts +2 -0
  51. package/dist/lib/state.js +2 -0
  52. package/dist/lib/usage.js +1 -60
  53. package/package.json +1 -1
@@ -31,18 +31,31 @@ export interface SessionPickerConfig {
31
31
  }
32
32
  /** Build a cached multi-line preview string for display in the session picker. */
33
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
- };
34
+ /**
35
+ * Body lines available from SessionMeta alone (no transcript parse) — used for
36
+ * remote / unindexed sessions so checklist progress still surfaces when the
37
+ * parser teammate (or a prior scan) has populated `session.todos`.
38
+ */
39
+ /**
40
+ * The session's place in a team, from whichever end it sits at: the orchestrator
41
+ * that ran `agents teams create` (from the scan-derived `spawnedTeam`), or a
42
+ * teammate (from its `meta.json`, via `classifyTeamSession`). Empty for a session
43
+ * with no team involvement, which is the overwhelming majority.
44
+ *
45
+ * Deliberately no live teammate counts: tallying a team means reading every
46
+ * record under the teams-agents dir, which runs to thousands of files on a busy
47
+ * machine — far too much for a pane that repaints as the cursor moves. The line
48
+ * names the command that does report them instead.
49
+ */
50
+ export declare function formatTeamLineage(session: SessionMeta): string;
38
51
  /**
39
52
  * 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.
53
+ * Prefer `session.recentDirectoriesTouched` the scan records it on the row, so
54
+ * it is present for remote rows whose transcript we can't parse here — otherwise
55
+ * derive from the file-change + tool paths already available.
42
56
  */
43
- export declare function directoriesTouched(session: SessionMetaWithDirs, events: SessionEvent[], changes: ReturnType<typeof classifyFileChanges>): string[];
57
+ export declare function directoriesTouched(session: SessionMeta, events: SessionEvent[], changes: ReturnType<typeof classifyFileChanges>): string[];
44
58
  /** Relativize a file path to its parent dir, short enough for one preview line. */
45
59
  export declare function relativizeDir(filePath: string, cwd?: string): string | undefined;
46
60
  /** Show an interactive session picker and return the selected session with its action (resume or view). */
47
61
  export declare function sessionPicker(config: SessionPickerConfig): Promise<PickedSession | null>;
48
- export {};
@@ -9,7 +9,8 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import chalk from 'chalk';
11
11
  import { truncate, humanDuration } from '../lib/format.js';
12
- import { parseSession, sanitizeForTerminal } from '../lib/session/parse.js';
12
+ import { parseSession, sanitizeForTerminal, SNAPSHOT_TODO_TOOLS } from '../lib/session/parse.js';
13
+ import { safeTeamText } from '../lib/session/team-filter.js';
13
14
  import { cleanSessionPrompt, extractSessionTopic } from '../lib/session/prompt.js';
14
15
  import { linkPath, linkUrl, relativeToCwd, shortenModel } from '../lib/session/render.js';
15
16
  import { linearIssueUrl } from '../lib/session/linear.js';
@@ -84,6 +85,12 @@ function sanitizeMeta(s) {
84
85
  label: clean(s.label),
85
86
  ticketId: clean(s.ticketId),
86
87
  prUrl: clean(s.prUrl),
88
+ // A remote row's meta is peer-supplied JSON that parseRemoteList hands over
89
+ // unsanitized, and both of these reach the preview pane — so an escape
90
+ // sequence in a peer's plan text or path list would otherwise hit our TTY.
91
+ plan: clean(s.plan),
92
+ spawnedTeam: clean(s.spawnedTeam),
93
+ recentDirectoriesTouched: s.recentDirectoriesTouched?.map(sanitizeForTerminal),
87
94
  todos,
88
95
  };
89
96
  }
@@ -205,15 +212,69 @@ function formatHeader(session, events) {
205
212
  * remote / unindexed sessions so checklist progress still surfaces when the
206
213
  * parser teammate (or a prior scan) has populated `session.todos`.
207
214
  */
215
+ /**
216
+ * The session's place in a team, from whichever end it sits at: the orchestrator
217
+ * that ran `agents teams create` (from the scan-derived `spawnedTeam`), or a
218
+ * teammate (from its `meta.json`, via `classifyTeamSession`). Empty for a session
219
+ * with no team involvement, which is the overwhelming majority.
220
+ *
221
+ * Deliberately no live teammate counts: tallying a team means reading every
222
+ * record under the teams-agents dir, which runs to thousands of files on a busy
223
+ * machine — far too much for a pane that repaints as the cursor moves. The line
224
+ * names the command that does report them instead.
225
+ */
226
+ export function formatTeamLineage(session) {
227
+ const origin = session.teamOrigin;
228
+ if (origin) {
229
+ const team = safeTeamText(origin.team);
230
+ const handleName = safeTeamText(origin.handle);
231
+ const mode = safeTeamText(origin.mode);
232
+ const parent = safeTeamText(origin.parentSessionId);
233
+ const parts = [chalk.white(team ?? 'team')];
234
+ const handle = handleName ? `teammate ${handleName}` : 'teammate';
235
+ parts.push(chalk.white(mode ? `${handle} (${mode})` : handle));
236
+ if (parent) {
237
+ parts.push(chalk.gray('spawned by ') + chalk.white(parent.slice(0, 8)));
238
+ }
239
+ return parts.join(chalk.gray(' · '));
240
+ }
241
+ const spawned = safeTeamText(session.spawnedTeam);
242
+ if (spawned) {
243
+ return (chalk.gray('spawned team ') +
244
+ chalk.white(spawned) +
245
+ chalk.gray(` · agents teams status ${spawned}`));
246
+ }
247
+ return '';
248
+ }
208
249
  function formatMetaOnlyBody(session) {
209
250
  const lines = [];
210
251
  if (session.topic) {
211
252
  lines.push(chalk.cyan('Prompt: ') + chalk.white(truncate(session.topic.trim(), (process.stdout.columns || 80) - 12)));
212
253
  }
254
+ const teamLine = formatTeamLineage(session);
255
+ if (teamLine) {
256
+ lines.push(chalk.cyan('Team: ') + teamLine);
257
+ }
213
258
  const compact = formatTodoCompact(session.todos);
214
259
  if (compact) {
215
260
  lines.push(chalk.cyan('Todos: ') + chalk.white(compact));
216
261
  }
262
+ const termWidth = process.stdout.columns || 80;
263
+ for (const l of session.todos?.items?.length ? renderTodos(session.todos.items, termWidth) : []) {
264
+ lines.push(l);
265
+ }
266
+ const dirs = session.recentDirectoriesTouched?.slice(0, DIRS_TOUCHED_MAX) ?? [];
267
+ if (dirs.length) {
268
+ lines.push(chalk.cyan('Dirs: ') + chalk.white(dirs.join(chalk.gray(' · '))));
269
+ }
270
+ // `plan` is the whole ExitPlanMode markdown, not a path — summarize it. The
271
+ // pane is height-clamped anyway, so pasting the blob would just push every
272
+ // other line out of view.
273
+ const planLines = session.plan?.trim() ? session.plan.trim().split('\n') : [];
274
+ if (planLines.length) {
275
+ const head = truncate(planLines[0].replace(/^#+\s*/, ''), termWidth - 20);
276
+ lines.push(chalk.cyan('Plan: ') + chalk.white(head) + chalk.gray(` · ${planLines.length} lines`));
277
+ }
217
278
  if (lines.length === 0)
218
279
  return '';
219
280
  return lines.map(l => ' ' + l).join('\n');
@@ -312,9 +373,10 @@ function formatCompactPreview(events, session) {
312
373
  if (!planFile && p && /\/plans\/[^/]+\.md$/.test(p)) {
313
374
  planFile = p;
314
375
  }
315
- // Claude TodoWrite (`todos`) and Codex update_plan (`plan`) same source as
316
- // extractTodoProgress in the state engine. Prefer the most recent write.
317
- if (tool === 'TodoWrite' || tool === 'update_plan') {
376
+ // Every harness's checklist-snapshot tool (Claude TodoWrite, Kimi TodoList,
377
+ // Codex update_plan, …) — the same registry the state engine folds through
378
+ // extractTodoProgress. Prefer the most recent write.
379
+ if (SNAPSHOT_TODO_TOOLS.has(tool)) {
318
380
  const progress = extractTodoProgress(event.args);
319
381
  if (progress)
320
382
  latestTodos = progress;
@@ -359,6 +421,10 @@ function formatCompactPreview(events, session) {
359
421
  if (dirs.length) {
360
422
  lines.push(chalk.cyan('Dirs: ') + chalk.white(dirs.join(chalk.gray(' · '))));
361
423
  }
424
+ const teamLine = formatTeamLineage(session);
425
+ if (teamLine) {
426
+ lines.push(chalk.cyan('Team: ') + teamLine);
427
+ }
362
428
  const activity = [];
363
429
  const changed = chg.created + chg.modified + chg.deleted;
364
430
  if (changed) {
@@ -434,16 +500,27 @@ function isSubAgentTool(tool, command) {
434
500
  }
435
501
  /**
436
502
  * Unique directories the session touched, compact and human-readable.
437
- * Prefer `session.dirsTouched` when the parser teammate has populated it;
438
- * otherwise derive from file-change + tool paths already available here.
503
+ * Prefer `session.recentDirectoriesTouched` the scan records it on the row, so
504
+ * it is present for remote rows whose transcript we can't parse here — otherwise
505
+ * derive from the file-change + tool paths already available.
439
506
  */
440
507
  export function directoriesTouched(session, events, changes) {
441
- const fromMeta = session.dirsTouched;
508
+ const fromMeta = session.recentDirectoriesTouched;
442
509
  if (Array.isArray(fromMeta) && fromMeta.length > 0) {
443
- return fromMeta
444
- .map((d) => sanitizeForTerminal(String(d).trim()))
445
- .filter(Boolean)
446
- .slice(0, DIRS_TOUCHED_MAX);
510
+ // The scan stores ABSOLUTE paths, so they go through the same relativizer the
511
+ // derived branch below uses — otherwise adopting this field (which a remote row
512
+ // needs, having no transcript to derive from) would turn every local preview's
513
+ // `Dirs:` line from `src/lib · docs` into a column of full home-rooted paths.
514
+ const seen = new Set();
515
+ for (const raw of fromMeta) {
516
+ const dir = relativizeDir(sanitizeForTerminal(String(raw).trim()), session.cwd);
517
+ if (dir)
518
+ seen.add(dir);
519
+ if (seen.size >= DIRS_TOUCHED_MAX)
520
+ break;
521
+ }
522
+ if (seen.size > 0)
523
+ return [...seen];
447
524
  }
448
525
  const counts = new Map();
449
526
  const bump = (raw) => {
@@ -8,6 +8,7 @@ interface SessionFilterOptions {
8
8
  project?: string;
9
9
  all?: boolean;
10
10
  teams?: boolean;
11
+ inTeam?: string;
11
12
  routine?: boolean;
12
13
  since?: string;
13
14
  until?: string;
@@ -274,6 +275,24 @@ export declare function isBareBrowserListing(options: SessionsOptions, query: st
274
275
  * `runSessionBrowser` picker cannot represent.
275
276
  */
276
277
  export declare function hasNoBrowserDisqualifyingFlags(options: SessionsOptions, query: string | undefined): boolean;
278
+ /**
279
+ * Whether a session belongs to `team`, from either end: it spawned the team, or
280
+ * it is one of the team's teammates. Case-insensitive, matching the SQL
281
+ * predicate behind `querySessions({ spawnedTeam })`.
282
+ */
283
+ export declare function matchesTeam(session: SessionMeta, team: string): boolean;
284
+ /**
285
+ * The `team:<name>` badge for a session that SPAWNED a team — the orchestrator
286
+ * end of the lineage, from the scan-derived `spawnedTeam`. Returned as a plain
287
+ * (uncolored) string plus its display width so callers can reserve the width
288
+ * from the topic budget and color it as their own segment: folding it into the
289
+ * topic string would lose the color, since renderTopicCell strips ANSI and
290
+ * re-whitens every slice.
291
+ */
292
+ export declare function teamBadge(session: SessionMeta): {
293
+ plain: string;
294
+ width: number;
295
+ };
277
296
  /** One flat table row:
278
297
  * shortId · agent · version · model · project · [glyph] label·doing · [ticket] · [wt] · time
279
298
  * `doing` is the live preview when running, else the topic. The `ticket` column
@@ -28,7 +28,7 @@ import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/
28
28
  import { inferSessionState } from '../lib/session/state.js';
29
29
  import { discoverSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots } from '../lib/session/discover.js';
30
30
  import { findSessionsById } from '../lib/session/db.js';
31
- import { filterTeamSessions } from '../lib/session/team-filter.js';
31
+ import { filterTeamSessions, safeTeamText } from '../lib/session/team-filter.js';
32
32
  import { parseSession } from '../lib/session/parse.js';
33
33
  import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
34
34
  import { formatRelativeTime } from '../lib/session/relative-time.js';
@@ -113,7 +113,15 @@ function createScanProgressTracker(verbs, suffix, spinner) {
113
113
  };
114
114
  }
115
115
  const PICKER_RECENT_COUNT = 15;
116
- const PICKER_POOL_LIMIT = 200;
116
+ /**
117
+ * The `--limit` default, shared with its `.option()` registration. Commander fills
118
+ * the default in, so `options.limit` is never falsy — code that wants to know
119
+ * whether the USER set a limit has to compare against this rather than test
120
+ * truthiness.
121
+ */
122
+ const DEFAULT_LIMIT = '50';
123
+ /** Pool size for `--in-team`: one team's rows can sit anywhere in the history. */
124
+ const WHOLE_TEAM_POOL_LIMIT = 5000;
117
125
  // The grouped default view ("overview"): fetch a generous recency-ordered pool
118
126
  // for accurate per-project totals, show each project's most-recent rows grouped
119
127
  // by project, newest-active project first.
@@ -1016,7 +1024,12 @@ function useInteractiveBrowser(options) {
1016
1024
  * browser (preview-rich, selectable) instead of the legacy per-host raw stream.
1017
1025
  */
1018
1026
  export function isBareBrowserListing(options, query) {
1019
- return useInteractiveBrowser(options) && hasNoBrowserDisqualifyingFlags(options, query);
1027
+ return (useInteractiveBrowser(options) &&
1028
+ // A peer answering a fan-out must never open a TUI. It has no TTY either, so
1029
+ // this is the explicit half of a guard that otherwise rests on the implicit
1030
+ // invariant that peers are always dialed with --json (see remote-list.ts).
1031
+ process.env.AGENTS_SESSIONS_LOCAL !== '1' &&
1032
+ hasNoBrowserDisqualifyingFlags(options, query));
1020
1033
  }
1021
1034
  /**
1022
1035
  * Pure flag-gate half of {@link isBareBrowserListing} (TTY-independent, so it is
@@ -1033,7 +1046,15 @@ export function hasNoBrowserDisqualifyingFlags(options, query) {
1033
1046
  !options.project &&
1034
1047
  !options.sort &&
1035
1048
  !options.artifacts &&
1036
- options.artifact === undefined);
1049
+ options.artifact === undefined &&
1050
+ // --cloud lists a provider's tasks, not the transcript index, and
1051
+ // runCloudSessions has no host scope — letting a `--device X --cloud` fall
1052
+ // through to the browser gate would silently drop the X the user asked for.
1053
+ !options.cloud &&
1054
+ // The browser carries ONE device in its filter and `y` copies back exactly
1055
+ // one --device, so a multi-host scope can't round-trip. Those stay on the
1056
+ // legacy per-host stream, which prints each peer under its own banner.
1057
+ (options.host?.length ?? 0) <= 1);
1037
1058
  }
1038
1059
  /** The canonical `ag sessions …` command for a set of flags — the twin of the
1039
1060
  * browser's `y` hotkey (see --print-cmd). Normalizes to the stable flag form. */
@@ -1043,6 +1064,8 @@ function canonicalSessionsCommand(query, options) {
1043
1064
  a.push('--active');
1044
1065
  if (options.teams)
1045
1066
  a.push('--teams');
1067
+ if (options.inTeam)
1068
+ a.push('--in-team', options.inTeam);
1046
1069
  if (options.routine)
1047
1070
  a.push('--routine');
1048
1071
  if (options.agent)
@@ -1100,7 +1123,13 @@ async function renderSessionPreview(query, scope) {
1100
1123
  console.log(buildPreview(session));
1101
1124
  }
1102
1125
  /** Main action handler for `agents sessions`. Routes to picker, table, or single-session render. */
1103
- async function sessionsAction(query, options) {
1126
+ async function sessionsAction(query, options,
1127
+ /**
1128
+ * Where commander got `--limit` from: 'cli'/'env' when the user supplied it,
1129
+ * 'default' when it filled in its own. Truthiness can't tell those apart,
1130
+ * because the default arrives as a string like any typed value.
1131
+ */
1132
+ limitSource) {
1104
1133
  // Explicit --query is interchangeable with the positional; it's how you search
1105
1134
  // for text that collides with a subcommand name (e.g. `sessions --query go`).
1106
1135
  query = query ?? options.query;
@@ -1133,6 +1162,14 @@ async function sessionsAction(query, options) {
1133
1162
  // `── host ──` banner). With --active, the hosts are folded into the merged
1134
1163
  // machine-grouped view instead (handled below).
1135
1164
  if (options.host && options.host.length > 0 && !options.active) {
1165
+ // --local means "skip the SSH fan-out"; --host means "look only over there".
1166
+ // Together they ask for a peer's sessions without dialing the peer, which can
1167
+ // only ever be empty — so say that instead of rendering a blank list.
1168
+ if (options.local === true && !shouldIncludeLocal(options.host, machineId())) {
1169
+ console.error(chalk.red('--local and --device name opposite scopes: --local skips the SSH fan-out that --device needs.'));
1170
+ console.error(chalk.gray('Drop one — `--device <box>` to read that machine, `--local` to stay on this one.'));
1171
+ process.exit(1);
1172
+ }
1136
1173
  if (options.json) {
1137
1174
  await runRemoteSessionsJson(options.host);
1138
1175
  return;
@@ -1165,6 +1202,14 @@ async function sessionsAction(query, options) {
1165
1202
  return;
1166
1203
  }
1167
1204
  if (options.active) {
1205
+ // The running view is built from the live scan, which carries no team lineage
1206
+ // (that comes off the transcript index and the teams meta dir), so --in-team
1207
+ // has nothing to match on here. Say so rather than ignoring the flag.
1208
+ if (options.inTeam) {
1209
+ console.error(chalk.red('--in-team does not apply to --active: the running view carries no team lineage.'));
1210
+ console.error(chalk.gray('Drop --active to filter by team, or use `agents teams status <name>` for a live team.'));
1211
+ process.exit(1);
1212
+ }
1168
1213
  // On a TTY (and not a scripting path), open the interactive browser seeded to
1169
1214
  // running-only. --json / --waiting / --no-interactive / a peer fan-out keep the
1170
1215
  // static dump untouched, so scripts and agents are unaffected. An explicit
@@ -1212,6 +1257,8 @@ async function sessionsAction(query, options) {
1212
1257
  agent: options.agent,
1213
1258
  all: options.all,
1214
1259
  since: options.since,
1260
+ host: options.host,
1261
+ inTeam: options.inTeam,
1215
1262
  }), { local: options.local === true, hosts: options.host });
1216
1263
  return;
1217
1264
  }
@@ -1265,14 +1312,25 @@ async function sessionsAction(query, options) {
1265
1312
  // no query, no path drill-in, not explicitly --flat/--tree. It drops the silent
1266
1313
  // cwd-scope + 50-cap + 30-day window that hide most of a large index.
1267
1314
  const wantsOverview = isInteractive && !searchQuery && !pathFilter && !options.flat && !options.tree;
1315
+ // --in-team asks for ONE team's whole lineage, which is a handful of rows that
1316
+ // can sit anywhere in history. Bounding it by the default top-50 / 30-day window
1317
+ // silently returns nothing for any team older than that — so the flag widens its
1318
+ // own scope, the way --all does, unless the caller set an explicit --limit.
1319
+ const wantsWholeTeam = !!options.inTeam;
1320
+ // `--limit` has a commander default, so an untouched flag still arrives as a
1321
+ // string and truthiness can't tell it from a typed one. Commander records where
1322
+ // each value came from, which is the only signal that distinguishes an explicit
1323
+ // `--limit 50` from no flag at all — a script asking for 50 must get 50, not the
1324
+ // whole-team pool.
1325
+ const userSetLimit = limitSource === 'cli' || limitSource === 'env';
1268
1326
  const limit = wantsOverview
1269
1327
  ? OVERVIEW_POOL_LIMIT
1270
- : parseInt(options.limit || (isInteractive ? String(PICKER_POOL_LIMIT) : '50'), 10);
1328
+ : parseInt(userSetLimit ? options.limit : wantsWholeTeam ? String(WHOLE_TEAM_POOL_LIMIT) : DEFAULT_LIMIT, 10);
1271
1329
  // Overview: recency order across the whole index, no default window; an explicit
1272
1330
  // --since still narrows. Non-overview keeps the prior interactive-30d default.
1273
1331
  const since = wantsOverview
1274
1332
  ? options.since
1275
- : (options.since ?? (isInteractive && !options.all ? '30d' : undefined));
1333
+ : (options.since ?? (isInteractive && !options.all && !wantsWholeTeam ? '30d' : undefined));
1276
1334
  const spinner = options.json ? null : ora().start();
1277
1335
  const tracker = createScanProgressTracker(LOAD_VERBS, 'sessions', spinner);
1278
1336
  try {
@@ -1285,12 +1343,15 @@ async function sessionsAction(query, options) {
1285
1343
  const scope = {
1286
1344
  agent,
1287
1345
  version,
1288
- all: pathFilter ? undefined : options.all,
1346
+ // --in-team spans directories by construction: a team's teammates run in
1347
+ // their own worktrees, so scoping to the current cwd hides most of the
1348
+ // lineage the flag exists to show.
1349
+ all: pathFilter ? undefined : options.all || wantsWholeTeam,
1289
1350
  cwd: process.cwd(),
1290
1351
  // Default overview scopes to the current repo SUBTREE (prefix match), so a
1291
1352
  // monorepo shows its sub-projects grouped instead of collapsing to the one
1292
1353
  // exact-cwd project. `--all` clears the prefix and spans the whole index.
1293
- cwdPrefix: pathFilter ?? (wantsOverview && !options.all ? process.cwd() : undefined),
1354
+ cwdPrefix: pathFilter ?? (wantsOverview && !options.all && !wantsWholeTeam ? process.cwd() : undefined),
1294
1355
  project: options.project,
1295
1356
  since,
1296
1357
  until: options.until,
@@ -1316,7 +1377,15 @@ async function sessionsAction(query, options) {
1316
1377
  // enriched/hidden.
1317
1378
  const { visible: visibleSessions } = filterTeamSessions(sessions, !!options.teams);
1318
1379
  sessions = visibleSessions;
1319
- const hiddenCount = options.teams
1380
+ // --in-team spans both ends of the lineage, so it can't be one SQL predicate:
1381
+ // the orchestrator matches on the scan-derived `spawnedTeam` column, while a
1382
+ // teammate only knows its team from the meta.json filterTeamSessions just
1383
+ // read. Match either, after that pass has populated `teamOrigin`.
1384
+ if (options.inTeam)
1385
+ sessions = sessions.filter((s) => matchesTeam(s, options.inTeam));
1386
+ // Under --in-team the visible list is one team, so the whole-index team-origin
1387
+ // count would be a non-sequitur next to it.
1388
+ const hiddenCount = options.teams || options.inTeam
1320
1389
  ? 0
1321
1390
  : countSessionsInScope({ ...scope, onlyTeamOrigin: true });
1322
1391
  // Smart ID routing: a bare query that resolves to one session renders
@@ -1437,12 +1506,55 @@ async function sessionsAction(query, options) {
1437
1506
  process.exit(1);
1438
1507
  }
1439
1508
  }
1509
+ /**
1510
+ * Prefix marking a row as somebody's teammate: `[<team>/<handle>] `, falling back
1511
+ * to the handle alone when the record predates team-name capture. The mode is
1512
+ * deliberately dropped here (it survives in the preview pane) — this tag is
1513
+ * folded into the topic cell, whose floor is 16 columns, so every character it
1514
+ * takes is one the actual prompt loses.
1515
+ */
1440
1516
  function teamTag(session) {
1441
1517
  const origin = session.teamOrigin;
1442
1518
  if (!origin)
1443
1519
  return '';
1444
- const parts = [origin.handle, origin.mode].filter(Boolean).join(' · ');
1445
- return parts ? `[${parts}] ` : '[team] ';
1520
+ const handle = safeTeamText(origin.handle);
1521
+ const team = safeTeamText(origin.team);
1522
+ if (team)
1523
+ return `[${team}${handle ? `/${handle}` : ''}] `;
1524
+ return handle ? `[${handle}] ` : '[team] ';
1525
+ }
1526
+ /**
1527
+ * Whether a session belongs to `team`, from either end: it spawned the team, or
1528
+ * it is one of the team's teammates. Case-insensitive, matching the SQL
1529
+ * predicate behind `querySessions({ spawnedTeam })`.
1530
+ */
1531
+ export function matchesTeam(session, team) {
1532
+ // The needle is peer-derived in the browser: `f.team` comes off the team cycle,
1533
+ // which is built from rows another machine sent. Guard it the same way as the
1534
+ // fields it is compared against, so a non-string can't throw out of a filter
1535
+ // that runs over every row.
1536
+ const want = safeTeamText(team)?.trim().toLowerCase();
1537
+ if (!want)
1538
+ return true;
1539
+ return (safeTeamText(session.spawnedTeam)?.toLowerCase() === want ||
1540
+ safeTeamText(session.teamOrigin?.team)?.toLowerCase() === want);
1541
+ }
1542
+ /** Longest team name rendered in the `team:` row badge before truncation. */
1543
+ const TEAM_BADGE_MAX = 10;
1544
+ /**
1545
+ * The `team:<name>` badge for a session that SPAWNED a team — the orchestrator
1546
+ * end of the lineage, from the scan-derived `spawnedTeam`. Returned as a plain
1547
+ * (uncolored) string plus its display width so callers can reserve the width
1548
+ * from the topic budget and color it as their own segment: folding it into the
1549
+ * topic string would lose the color, since renderTopicCell strips ANSI and
1550
+ * re-whitens every slice.
1551
+ */
1552
+ export function teamBadge(session) {
1553
+ const team = safeTeamText(session.spawnedTeam);
1554
+ if (!team)
1555
+ return { plain: '', width: 0 };
1556
+ const plain = `team:${truncate(team, TEAM_BADGE_MAX)} `;
1557
+ return { plain, width: stringWidth(plain) };
1446
1558
  }
1447
1559
  function originTag(session) {
1448
1560
  if (session.origin !== 'routine')
@@ -1477,6 +1589,8 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}) {
1477
1589
  const topicBase = tag ? `${tag}${session.topic ?? ''}` : session.topic;
1478
1590
  const doing = [restingTodo, preview || topicBase].filter(Boolean).join(' · ') || undefined;
1479
1591
  const wt = session.worktreeSlug ? chalk.magenta(`wt:${session.worktreeSlug}`) : '';
1592
+ const team = teamBadge(session);
1593
+ const teamSeg = team.plain ? chalk.green(team.plain) : '';
1480
1594
  // The machine column only earns its width when the listing spans more than one
1481
1595
  // box (i.e. the cross-machine fan-out folded remotes in) — same rule and
1482
1596
  // pool-derived width as the picker.
@@ -1497,7 +1611,7 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}) {
1497
1611
  const wtW = wt ? stringWidth(wt) + 1 : 0;
1498
1612
  const width = terminalWidth();
1499
1613
  const requestedModelW = cols.showModel ? (cols.modelWidth ?? PICKER_MODEL_MAX) : 0;
1500
- const fixedW = (10 + 9 + 8 + 16) + glyphW + statusW + machineW + ticketW + wtW + stringWidth(when) + 1;
1614
+ const fixedW = (10 + 9 + 8 + 16) + glyphW + statusW + machineW + ticketW + wtW + team.width + stringWidth(when) + 1;
1501
1615
  const modelSlack = width - fixedW - 16;
1502
1616
  const modelW = requestedModelW <= modelSlack
1503
1617
  ? requestedModelW
@@ -1511,6 +1625,7 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}) {
1511
1625
  chalk.cyan(linkCwdCell(session, padToWidth(truncateToWidth(project, 14), 16))) +
1512
1626
  (glyph ? glyph + ' ' : '') +
1513
1627
  statusCell +
1628
+ teamSeg +
1514
1629
  renderTopicCell(label, doing, '', topicW, topicW) +
1515
1630
  ticketCell +
1516
1631
  (wt ? wt + ' ' : '') +
@@ -1530,16 +1645,19 @@ function treeSessionRow(session, live) {
1530
1645
  const topic = [restingTodo, topicBase].filter(Boolean).join(' · ') || '-';
1531
1646
  const badges = signalBadges(metaSignals(session));
1532
1647
  const badgeW = badges ? stringWidth(badges) + 1 : 0;
1648
+ const team = teamBadge(session);
1649
+ const teamSeg = team.plain ? chalk.green(team.plain) : '';
1533
1650
  const head = label ? `${label} · ${topic}` : topic;
1534
1651
  const { cell: statusCell, width: statusW } = liveStatusCell(live);
1535
1652
  const glyphW = glyph ? 2 : 0;
1536
- const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - glyphW - statusW - badgeW - stringWidth(when) - 1);
1653
+ const topicW = Math.max(12, terminalWidth() - (2 + 9 + 8) - glyphW - statusW - badgeW - team.width - stringWidth(when) - 1);
1537
1654
  return (' ' +
1538
1655
  chalk.dim(padToWidth(session.shortId, 9)) +
1539
1656
  agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
1540
1657
  (badges ? badges + ' ' : '') +
1541
1658
  (glyph ? glyph + ' ' : '') +
1542
1659
  statusCell +
1660
+ teamSeg +
1543
1661
  padToWidth(chalk.white(truncateToWidth(head, topicW)), topicW) +
1544
1662
  ' ' + chalk.gray(when));
1545
1663
  }
@@ -1934,6 +2052,10 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
1934
2052
  const sshPlain = ssh ? (ssh.device ? `ssh←${ssh.device} ` : 'ssh ') : '';
1935
2053
  const sshSeg = sshPlain ? chalk.red(sshPlain) : '';
1936
2054
  const sshW = sshPlain ? stringWidth(sshPlain) : 0;
2055
+ // Orchestrator badge — same own-segment treatment as `ssh` above, for the same
2056
+ // reason: renderTopicCell would strip its colour if it rode inside the topic.
2057
+ const team = teamBadge(s);
2058
+ const teamSeg = team.plain ? chalk.green(team.plain) : '';
1937
2059
  const tag = originTag(s) || teamTag(s);
1938
2060
  const label = s.label;
1939
2061
  const topic = tag ? `${tag}${s.topic ?? ''}` : s.topic;
@@ -1960,7 +2082,7 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
1960
2082
  const ticketW = cols.showTicket ? TICKET_W + 1 : 0;
1961
2083
  const hostW = cols.showHost ? PICKER_HOST_W : 0;
1962
2084
  const wtW = wt ? stringWidth(wt) + 1 : 0;
1963
- const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - stringWidth(when) - 1);
2085
+ const topicW = Math.max(16, terminalWidth() - gutter - (10 + 9 + 8 + 16) - machineColW - hostW - ticketW - wtW - sshW - team.width - stringWidth(when) - 1);
1964
2086
  return (
1965
2087
  // Truncated, not just padded: an indexed shortId is always 8 chars, but a
1966
2088
  // live row with no session id is named by its pid or cloud task, which can
@@ -1972,6 +2094,7 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '') {
1972
2094
  hostCell +
1973
2095
  chalk.cyan(padRight(truncate(project, 14), 16)) +
1974
2096
  sshSeg +
2097
+ teamSeg +
1975
2098
  renderTopicCell(label, topic, query, topicW, topicW) +
1976
2099
  ticketCell +
1977
2100
  (wt ? wt + ' ' : '') +
@@ -2816,11 +2939,12 @@ export function registerSessionsCommands(program) {
2816
2939
  .option('--all', 'Widen every non-status filter to "all": every directory (not just this project) and all time (no window cap). Status filters like --active still compose; -a/--device/--since still narrow their axis.')
2817
2940
  .option('--unmanaged', "Also show sessions from your own ~/.<agent> installs (hidden once agents-cli manages that agent)")
2818
2941
  .option('--teams', 'Include team-spawned sessions (hidden by default)')
2942
+ .option('--in-team <name>', "Only this team: the session that spawned it plus (with --teams) its teammates. Spans every directory and all time, since a team's worktrees and history sit outside the default window.")
2819
2943
  .option('--routine', 'Show only sessions archived from routine runs')
2820
2944
  .option('-p, --project <name>', 'Filter by project name (searches across all directories)')
2821
2945
  .option('--since <time>', 'Only sessions newer than this (e.g., 2h, 7d, 4w, or ISO date)')
2822
2946
  .option('--until <time>', 'Only sessions older than this (ISO timestamp)')
2823
- .option('-n, --limit <n>', 'Maximum number of sessions to return', '50')
2947
+ .option('-n, --limit <n>', 'Maximum number of sessions to return', DEFAULT_LIMIT)
2824
2948
  .option('--sort <field>', 'Sort the list by: recent (default), cost, or duration')
2825
2949
  .option('--markdown', 'Render the session as markdown (user, assistant, thinking, tool calls)')
2826
2950
  .option('--no-redact', 'Disable default secret redaction in rendered session output (--markdown and --json)')
@@ -2866,6 +2990,10 @@ export function registerSessionsCommands(program) {
2866
2990
  # Search across every directory, not just this project
2867
2991
  agents sessions "topic" --all
2868
2992
 
2993
+ # Who spawned which team: an orchestrator row carries team:<name>, and a
2994
+ # teammate row [<team>/<handle>]. --in-team narrows to one team's lineage.
2995
+ agents sessions --in-team redesign --teams
2996
+
2869
2997
  # Show routine-run sessions and open one by routine run id
2870
2998
  agents sessions --routine --all
2871
2999
  agents sessions 2026-07-21T10-30-00-000Z
@@ -2882,6 +3010,7 @@ export function registerSessionsCommands(program) {
2882
3010
  notes: `
2883
3011
  - The interactive listing folds in your other online machines automatically (live over SSH, no sync) — each row is labelled by host, this machine first. Use --local to skip the fan-out; --json and single-id lookups stay local.
2884
3012
  - --host runs the query on the remote's own index over SSH (host alias or user@host); repeat or pass several to fan out. SSH access is the only auth.
3013
+ - --in-team matches both ends of the lineage: the session that ran 'agents teams create/add', and (with --teams) that team's teammates. In the interactive list, 't' cycles the same filter over the teams in view.
2885
3014
  - --include and --exclude are mutually exclusive.
2886
3015
  - --first and --last are mutually exclusive.
2887
3016
  - A filter flag (--include/--exclude/--first/--last) without --markdown/--json defaults to --markdown output.
@@ -2890,13 +3019,13 @@ export function registerSessionsCommands(program) {
2890
3019
  - Without --teams, team-spawned sessions are hidden by default.
2891
3020
  `,
2892
3021
  });
2893
- sessionsCmd.action(async (query, options) => {
3022
+ sessionsCmd.action(async (query, options, command) => {
2894
3023
  if (options.browser) {
2895
3024
  // Alias for `agents browser sessions`: a profile positional narrows to one profile.
2896
3025
  runBrowserSessions({ profile: query, json: options.json });
2897
3026
  return;
2898
3027
  }
2899
- await sessionsAction(query, options);
3028
+ await sessionsAction(query, options, command.getOptionValueSource('limit'));
2900
3029
  });
2901
3030
  registerSessionsTailCommand(sessionsCmd);
2902
3031
  registerSessionsSyncCommand(sessionsCmd);